PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Services / EmailMergeTagRegistry.php

EmailMergeTagRegistry.php in Yatra – Travel Booking & Tour Operator Software 3.0.15, at app/Services/EmailMergeTagRegistry.php

1,042 lines 47.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Central registry of email merge tags.
4 *
5 * The single source of truth for every {{tag}} an operator can put inside
6 * an email subject or body. Defines the tag's label, description, category
7 * and sample value, plus which automation events resolve it at send time.
8 *
9 * Consumed by:
10 * - {@see \YatraPro\Modules\EmailAutomation\Services\EmailAutomationService}
11 * for the Template Editor's "Available Variables" sidebar and for
12 * preview-sample defaults — instead of maintaining a parallel catalogue.
13 * - {@see \YatraPro\Modules\EmailAutomation\Support\EmailAutomationEvents}
14 * to derive each event's variable whitelist, so adding a new tag to
15 * the registry automatically surfaces it on the events that resolve it.
16 *
17 * To add a new tag: append a row in {@see self::definitions()} with the
18 * event keys that actually inject the value, then make the renderer
19 * (`variablesFromBooking`, etc.) inject it. Both ends stay in sync.
20 *
21 * @package Yatra\Services
22 * @since 3.0.5
23 */
24
25 declare(strict_types=1);
26
27 namespace Yatra\Services;
28
29 final class EmailMergeTagRegistry
30 {
31 public const CATEGORY_GENERAL = 'general';
32 public const CATEGORY_CUSTOMER = 'customer';
33 public const CATEGORY_BOOKING = 'booking';
34 public const CATEGORY_PAYMENT = 'payment';
35 public const CATEGORY_SCHEDULED_PAYMENT = 'scheduled_payment';
36 public const CATEGORY_ENQUIRY = 'enquiry';
37 public const CATEGORY_TRIP_CONSENT = 'trip_consent';
38 public const CATEGORY_ACCOUNT = 'account';
39 public const CATEGORY_ABANDONED_RECOVERY = 'abandoned_recovery';
40 public const CATEGORY_REMINDER = 'reminder';
41
42 public const EVENT_BOOKING_CREATED = 'booking.created';
43 public const EVENT_BOOKING_CONFIRMED = 'booking.confirmed';
44 public const EVENT_BOOKING_CANCELLED = 'booking.cancelled';
45 public const EVENT_BOOKING_COMPLETED = 'booking.completed';
46 public const EVENT_BOOKING_EXPIRED = 'booking.expired';
47 public const EVENT_PAYMENT_RECEIVED = 'payment.received';
48 /** A payment landed but a balance is still outstanding (deposit / instalment). */
49 public const EVENT_PAYMENT_PARTIAL_RECEIVED = 'payment.partial_received';
50 public const EVENT_PAYMENT_REMINDER = 'payment.reminder';
51 public const EVENT_REMINDER_TRIP = 'reminder.trip';
52 public const EVENT_ENQUIRY_CREATED = 'enquiry.created';
53 public const EVENT_ENQUIRY_RESPONDED = 'enquiry.responded';
54 public const EVENT_REVIEW_REQUEST = 'marketing.review_request';
55 public const EVENT_CONSENT_REQUESTED = 'consent.requested';
56 public const EVENT_ACCOUNT_EMAIL_VERIFICATION = 'account.email_verification';
57 // Account email change (CustomerService): the request goes to the NEW
58 // address with a confirmation link; the "changed" security notice goes to
59 // the OLD address. Pro seeds templates on these keys, so they must be
60 // registered here or the template editor rejects them ("Invalid event key").
61 public const EVENT_ACCOUNT_EMAIL_CHANGE_REQUEST = 'account.email_change_request';
62 public const EVENT_ACCOUNT_EMAIL_CHANGED = 'account.email_changed';
63 public const EVENT_SCHEDULED_PAYMENT_REMINDER = 'scheduled.payment.reminder';
64 public const EVENT_SCHEDULED_PAYMENT_SUCCEEDED = 'scheduled.payment.succeeded';
65 public const EVENT_SCHEDULED_PAYMENT_FAILED = 'scheduled.payment.failed';
66 public const EVENT_BOOKING_ABANDONED_RECOVERY = 'booking.abandoned_recovery';
67
68 /**
69 * Every event that resolves a `variablesFromBooking()`-derived
70 * booking context. Booking-context tags inherit this list so
71 * the per-event whitelist stays in sync as events evolve.
72 */
73 /** Customer-account emails: share the customer + intro/footer tags. */
74 private const ACCOUNT_CONTEXT_EVENTS = [
75 self::EVENT_ACCOUNT_EMAIL_VERIFICATION,
76 self::EVENT_ACCOUNT_EMAIL_CHANGE_REQUEST,
77 self::EVENT_ACCOUNT_EMAIL_CHANGED,
78 ];
79
80 private const BOOKING_CONTEXT_EVENTS = [
81 self::EVENT_BOOKING_CREATED,
82 self::EVENT_BOOKING_CONFIRMED,
83 self::EVENT_BOOKING_CANCELLED,
84 self::EVENT_BOOKING_COMPLETED,
85 self::EVENT_BOOKING_EXPIRED,
86 self::EVENT_PAYMENT_RECEIVED,
87 self::EVENT_PAYMENT_PARTIAL_RECEIVED,
88 self::EVENT_PAYMENT_REMINDER,
89 self::EVENT_REMINDER_TRIP,
90 self::EVENT_REVIEW_REQUEST,
91 self::EVENT_SCHEDULED_PAYMENT_REMINDER,
92 self::EVENT_SCHEDULED_PAYMENT_SUCCEEDED,
93 self::EVENT_SCHEDULED_PAYMENT_FAILED,
94 ];
95
96 private const ENQUIRY_CONTEXT_EVENTS = [
97 self::EVENT_ENQUIRY_CREATED,
98 self::EVENT_ENQUIRY_RESPONDED,
99 ];
100
101 private const SCHEDULED_PAYMENT_EVENTS = [
102 self::EVENT_SCHEDULED_PAYMENT_REMINDER,
103 self::EVENT_SCHEDULED_PAYMENT_SUCCEEDED,
104 self::EVENT_SCHEDULED_PAYMENT_FAILED,
105 ];
106
107 /**
108 * Full merge-tag catalogue keyed by tag key.
109 *
110 * Each entry shape:
111 * - key (string) — the literal token, e.g. `booking_reference`
112 * - label (string) — human-readable label for the UI sidebar
113 * - description (string) — short hint shown under the label
114 * - category (string) — bucket for grouping in the sidebar
115 * - sample (string) — preview value when no real context is present
116 * - events (list|string) — automation events that inject this tag at
117 * send time. Use the literal '*' to mark
118 * event-independent tags (site_name, etc.).
119 *
120 * Filter `yatra_email_merge_tag_definitions` lets Pro modules / custom
121 * integrations append their own tags without editing this method.
122 *
123 * @return array<string, array{key:string,label:string,description:string,category:string,sample:string,events:list<string>|string}>
124 */
125 public static function definitions(): array
126 {
127 $previewTok = defined('YATRA_EMAIL_VERIFICATION_PREVIEW_TOKEN')
128 ? (string) YATRA_EMAIL_VERIFICATION_PREVIEW_TOKEN
129 : 'preview-verify-token';
130 $verificationSampleLink = function_exists('yatra_get_email_verification_url')
131 ? yatra_get_email_verification_url($previewTok)
132 : home_url('/?yatra_verify_email=' . rawurlencode($previewTok));
133
134 $dateFormat = function_exists('get_option') ? (string) get_option('date_format') : 'Y-m-d';
135 $sampleTravelDate = function_exists('date_i18n')
136 ? date_i18n($dateFormat, strtotime('+30 days') ?: time())
137 : date('Y-m-d', strtotime('+30 days') ?: time());
138 $sampleDueDate = function_exists('date_i18n')
139 ? date_i18n($dateFormat, strtotime('+14 days') ?: time())
140 : date('Y-m-d', strtotime('+14 days') ?: time());
141
142 $homeUrl = function_exists('home_url') ? home_url('/') : 'https://example.test/';
143 $adminUrl = function_exists('admin_url') ? admin_url('admin.php?page=yatra') : $homeUrl . 'wp-admin/admin.php?page=yatra';
144 $siteName = function_exists('get_bloginfo') ? (string) get_bloginfo('name') : 'Your Site';
145 $adminEmail = function_exists('get_option') ? (string) get_option('admin_email') : '[email protected]';
146
147 $bookingContextEvents = self::BOOKING_CONTEXT_EVENTS;
148 $enquiryContextEvents = self::ENQUIRY_CONTEXT_EVENTS;
149 $scheduledPaymentEvents = self::SCHEDULED_PAYMENT_EVENTS;
150
151 $catalog = [
152 // ---------------------------------------------------------
153 // General — available to every event because parseTemplate
154 // merges getDefaultVariables() over the caller's $variables.
155 // ---------------------------------------------------------
156 'site_name' => [
157 'key' => 'site_name',
158 'label' => 'Site Name',
159 'description' => 'Your website name (from WordPress Site Title).',
160 'category' => self::CATEGORY_GENERAL,
161 'sample' => $siteName !== '' ? $siteName : 'Your Site',
162 'events' => '*',
163 ],
164 'site_url' => [
165 'key' => 'site_url',
166 'label' => 'Site URL',
167 'description' => 'Your website home URL.',
168 'category' => self::CATEGORY_GENERAL,
169 'sample' => $homeUrl,
170 'events' => '*',
171 ],
172 'admin_email' => [
173 'key' => 'admin_email',
174 'label' => 'Admin Email',
175 'description' => 'Site administrator email address.',
176 'category' => self::CATEGORY_GENERAL,
177 'sample' => $adminEmail !== '' ? $adminEmail : '[email protected]',
178 'events' => '*',
179 ],
180 'admin_url' => [
181 'key' => 'admin_url',
182 'label' => 'Admin URL',
183 'description' => 'Link to the Yatra admin dashboard.',
184 'category' => self::CATEGORY_GENERAL,
185 'sample' => $adminUrl,
186 'events' => '*',
187 ],
188 'current_date' => [
189 'key' => 'current_date',
190 'label' => 'Current Date',
191 'description' => "Today's date formatted per the site's date format.",
192 'category' => self::CATEGORY_GENERAL,
193 'sample' => function_exists('date_i18n') ? date_i18n($dateFormat) : date($dateFormat),
194 'events' => '*',
195 ],
196 'current_year' => [
197 'key' => 'current_year',
198 'label' => 'Current Year',
199 'description' => 'Current four-digit year.',
200 'category' => self::CATEGORY_GENERAL,
201 'sample' => date('Y'),
202 'events' => '*',
203 ],
204
205 // ---------------------------------------------------------
206 // Customer — produced by variablesFromBooking + variablesFromEnquiry
207 // + the verification + consent + recovery senders.
208 // ---------------------------------------------------------
209 'customer_name' => [
210 'key' => 'customer_name',
211 'label' => 'Customer Name',
212 'description' => 'Full name (first + last) of the customer / enquirer.',
213 'category' => self::CATEGORY_CUSTOMER,
214 'sample' => 'John Doe',
215 'events' => array_merge(
216 $bookingContextEvents,
217 $enquiryContextEvents,
218 self::ACCOUNT_CONTEXT_EVENTS,
219 [self::EVENT_BOOKING_ABANDONED_RECOVERY]
220 ),
221 ],
222 'customer_first_name' => [
223 'key' => 'customer_first_name',
224 'label' => 'First Name',
225 'description' => 'Customer first name only.',
226 'category' => self::CATEGORY_CUSTOMER,
227 'sample' => 'John',
228 'events' => array_merge(
229 $bookingContextEvents,
230 self::ACCOUNT_CONTEXT_EVENTS
231 ),
232 ],
233 'customer_last_name' => [
234 'key' => 'customer_last_name',
235 'label' => 'Last Name',
236 'description' => 'Customer last name only.',
237 'category' => self::CATEGORY_CUSTOMER,
238 'sample' => 'Doe',
239 'events' => $bookingContextEvents,
240 ],
241 'customer_email' => [
242 'key' => 'customer_email',
243 'label' => 'Customer Email',
244 'description' => 'Customer email address.',
245 'category' => self::CATEGORY_CUSTOMER,
246 'sample' => '[email protected]',
247 'events' => array_merge(
248 $bookingContextEvents,
249 $enquiryContextEvents,
250 self::ACCOUNT_CONTEXT_EVENTS,
251 [self::EVENT_BOOKING_ABANDONED_RECOVERY]
252 ),
253 ],
254 'customer_phone' => [
255 'key' => 'customer_phone',
256 'label' => 'Customer Phone',
257 'description' => 'Customer phone number.',
258 'category' => self::CATEGORY_CUSTOMER,
259 'sample' => '+1 234 567 8900',
260 'events' => array_merge($bookingContextEvents, $enquiryContextEvents),
261 ],
262
263 // ---------------------------------------------------------
264 // Booking — core fields from variablesFromBooking + the rich
265 // tags appended by BookingEmailRichMergeTags::forBooking.
266 // ---------------------------------------------------------
267 'booking_reference' => [
268 'key' => 'booking_reference',
269 'label' => 'Booking Reference',
270 'description' => 'Customer-visible booking code (e.g. YTR-12345).',
271 'category' => self::CATEGORY_BOOKING,
272 'sample' => 'YTR-2024-001234',
273 'events' => array_merge(
274 $bookingContextEvents,
275 [
276 self::EVENT_CONSENT_REQUESTED,
277 self::EVENT_BOOKING_ABANDONED_RECOVERY,
278 ]
279 ),
280 ],
281 'booking_id' => [
282 'key' => 'booking_id',
283 'label' => 'Booking ID',
284 'description' => 'Internal numeric booking identifier.',
285 'category' => self::CATEGORY_BOOKING,
286 'sample' => '1234',
287 'events' => $bookingContextEvents,
288 ],
289 'booking_url' => [
290 'key' => 'booking_url',
291 'label' => 'Booking URL',
292 'description' => 'Link to view the booking in My Account.',
293 'category' => self::CATEGORY_BOOKING,
294 'sample' => $homeUrl . 'my-account/bookings/1234',
295 'events' => $bookingContextEvents,
296 ],
297 'booking_status' => [
298 'key' => 'booking_status',
299 'label' => 'Booking Status',
300 'description' => 'pending / confirmed / cancelled / completed.',
301 'category' => self::CATEGORY_BOOKING,
302 'sample' => 'confirmed',
303 'events' => $bookingContextEvents,
304 ],
305 'payment_status' => [
306 'key' => 'payment_status',
307 'label' => 'Payment Status',
308 'description' => 'unpaid / partial / paid / refunded.',
309 'category' => self::CATEGORY_BOOKING,
310 'sample' => 'paid',
311 'events' => $bookingContextEvents,
312 ],
313 'trip_name' => [
314 'key' => 'trip_name',
315 'label' => 'Trip Name',
316 'description' => 'Title of the trip the booking / enquiry is for.',
317 'category' => self::CATEGORY_BOOKING,
318 'sample' => 'Amazing Mountain Adventure',
319 'events' => array_merge(
320 $bookingContextEvents,
321 $enquiryContextEvents,
322 [
323 self::EVENT_CONSENT_REQUESTED,
324 self::EVENT_BOOKING_ABANDONED_RECOVERY,
325 ]
326 ),
327 ],
328 'trip_url' => [
329 'key' => 'trip_url',
330 'label' => 'Trip URL',
331 'description' => 'Public link to the trip detail page.',
332 'category' => self::CATEGORY_BOOKING,
333 'sample' => $homeUrl . 'trips/amazing-mountain-adventure',
334 'events' => array_merge($bookingContextEvents, $enquiryContextEvents),
335 ],
336 'travel_date' => [
337 'key' => 'travel_date',
338 'label' => 'Travel Date',
339 'description' => 'Departure date formatted per site settings.',
340 'category' => self::CATEGORY_BOOKING,
341 'sample' => $sampleTravelDate,
342 'events' => array_merge($bookingContextEvents, [self::EVENT_CONSENT_REQUESTED]),
343 ],
344 'travelers_count' => [
345 'key' => 'travelers_count',
346 'label' => 'Travelers Count',
347 'description' => 'Number of travelers on the booking.',
348 'category' => self::CATEGORY_BOOKING,
349 'sample' => '4',
350 'events' => $bookingContextEvents,
351 ],
352 'travelers_list' => [
353 'key' => 'travelers_list',
354 'label' => 'Travelers List (plain)',
355 'description' => 'Plain-text list of traveler names.',
356 'category' => self::CATEGORY_BOOKING,
357 'sample' => "John Doe\nJane Doe",
358 'events' => $bookingContextEvents,
359 ],
360 'travelers_list_html' => [
361 'key' => 'travelers_list_html',
362 'label' => 'Travelers List (HTML)',
363 'description' => 'HTML-formatted list of traveler names.',
364 'category' => self::CATEGORY_BOOKING,
365 'sample' => '<ul><li>John Doe</li><li>Jane Doe</li></ul>',
366 'events' => $bookingContextEvents,
367 ],
368 'traveler_custom_fields_html' => [
369 'key' => 'traveler_custom_fields_html',
370 'label' => 'Traveler Custom Fields (HTML)',
371 'description' => 'Dynamic Form Field answers per traveler, rendered as HTML.',
372 'category' => self::CATEGORY_BOOKING,
373 'sample' => '',
374 'events' => $bookingContextEvents,
375 ],
376 'booking_custom_fields_html' => [
377 'key' => 'booking_custom_fields_html',
378 'label' => 'Booking Custom Fields (HTML)',
379 'description' => 'Booking-level Dynamic Form Field answers as HTML.',
380 'category' => self::CATEGORY_BOOKING,
381 'sample' => '',
382 'events' => $bookingContextEvents,
383 ],
384 'special_requests' => [
385 'key' => 'special_requests',
386 'label' => 'Special Requests (plain)',
387 'description' => 'Customer-entered special requests text.',
388 'category' => self::CATEGORY_BOOKING,
389 'sample' => 'Vegetarian meals please.',
390 'events' => $bookingContextEvents,
391 ],
392 'special_requests_html' => [
393 'key' => 'special_requests_html',
394 'label' => 'Special Requests (HTML)',
395 'description' => 'Special requests with line breaks preserved.',
396 'category' => self::CATEGORY_BOOKING,
397 'sample' => 'Vegetarian meals please.',
398 'events' => $bookingContextEvents,
399 ],
400 'cancellation_reason' => [
401 'key' => 'cancellation_reason',
402 'label' => 'Cancellation Reason',
403 'description' => 'Reason recorded when the booking was cancelled.',
404 'category' => self::CATEGORY_BOOKING,
405 'sample' => 'Change of plans',
406 'events' => [self::EVENT_BOOKING_CANCELLED],
407 ],
408 'completion_date' => [
409 'key' => 'completion_date',
410 'label' => 'Completion Date',
411 'description' => 'Date the trip / booking was marked completed.',
412 'category' => self::CATEGORY_BOOKING,
413 'sample' => function_exists('date_i18n') ? date_i18n($dateFormat) : date($dateFormat),
414 'events' => [self::EVENT_BOOKING_COMPLETED, self::EVENT_REVIEW_REQUEST],
415 ],
416 'expiry_policy_note' => [
417 'key' => 'expiry_policy_note',
418 'label' => 'Expiry Policy Note',
419 'description' => 'Message shown when a booking auto-expires for non-payment.',
420 'category' => self::CATEGORY_BOOKING,
421 'sample' => 'This booking was automatically cancelled after the payment window expired.',
422 'events' => [self::EVENT_BOOKING_EXPIRED],
423 ],
424
425 // ---------------------------------------------------------
426 // Payment
427 // ---------------------------------------------------------
428 'total_amount_formatted' => [
429 'key' => 'total_amount_formatted',
430 'label' => 'Total Amount (formatted)',
431 'description' => 'Total cost with currency symbol — preferred over total_amount.',
432 'category' => self::CATEGORY_PAYMENT,
433 'sample' => '$2,500.00',
434 'events' => $bookingContextEvents,
435 ],
436 // Alias of `total_amount_formatted` — exposed for templates
437 // that use the short name. Renders identically (formatted
438 // with currency) so admins can pick whichever reads
439 // naturally in their copy.
440 'total_amount' => [
441 'key' => 'total_amount',
442 'label' => 'Total Amount',
443 'description' => 'Total cost with currency symbol (alias of total_amount_formatted).',
444 'category' => self::CATEGORY_PAYMENT,
445 'sample' => '$2,500.00',
446 'events' => $bookingContextEvents,
447 ],
448 'amount_due_formatted' => [
449 'key' => 'amount_due_formatted',
450 'label' => 'Amount Due (formatted)',
451 'description' => 'Remaining balance with currency symbol.',
452 'category' => self::CATEGORY_PAYMENT,
453 'sample' => '$2,000.00',
454 'events' => $bookingContextEvents,
455 ],
456 // Aliases for the remaining balance — same value, different
457 // common spellings. `{{balance_due}}` is the most common
458 // legacy spelling in customer-edited templates; `amount_due`
459 // (unformatted-looking name but actually formatted with
460 // currency) matches the booking record column.
461 'balance_due' => [
462 'key' => 'balance_due',
463 'label' => 'Balance Due',
464 'description' => 'Remaining balance with currency symbol (alias of amount_due_formatted).',
465 'category' => self::CATEGORY_PAYMENT,
466 'sample' => '$2,000.00',
467 'events' => $bookingContextEvents,
468 ],
469 'amount_due' => [
470 'key' => 'amount_due',
471 'label' => 'Amount Due',
472 'description' => 'Remaining balance with currency symbol (alias of amount_due_formatted).',
473 'category' => self::CATEGORY_PAYMENT,
474 'sample' => '$2,000.00',
475 'events' => $bookingContextEvents,
476 ],
477 'amount_paid' => [
478 'key' => 'amount_paid',
479 'label' => 'Amount Paid',
480 'description' => 'Total paid so far with currency symbol.',
481 'category' => self::CATEGORY_PAYMENT,
482 'sample' => '$500.00',
483 'events' => $bookingContextEvents,
484 ],
485 'amount_paid_formatted' => [
486 'key' => 'amount_paid_formatted',
487 'label' => 'Amount Paid (formatted)',
488 'description' => 'Total paid so far with currency symbol (alias of amount_paid).',
489 'category' => self::CATEGORY_PAYMENT,
490 'sample' => '$500.00',
491 'events' => $bookingContextEvents,
492 ],
493 'currency' => [
494 'key' => 'currency',
495 'label' => 'Currency',
496 'description' => 'ISO 4217 currency code (e.g. USD).',
497 'category' => self::CATEGORY_PAYMENT,
498 'sample' => 'USD',
499 'events' => $bookingContextEvents,
500 ],
501 'payment_amount_formatted' => [
502 'key' => 'payment_amount_formatted',
503 'label' => 'Payment Amount (formatted)',
504 'description' => 'Amount of the specific payment with currency.',
505 'category' => self::CATEGORY_PAYMENT,
506 'sample' => '$500.00',
507 'events' => [self::EVENT_PAYMENT_RECEIVED, self::EVENT_PAYMENT_PARTIAL_RECEIVED, self::EVENT_PAYMENT_REMINDER],
508 ],
509 'payment_method' => [
510 'key' => 'payment_method',
511 'label' => 'Payment Method',
512 'description' => 'Instrument label (e.g. Card, Bank Transfer).',
513 'category' => self::CATEGORY_PAYMENT,
514 'sample' => 'Credit Card',
515 'events' => [self::EVENT_PAYMENT_RECEIVED, self::EVENT_PAYMENT_PARTIAL_RECEIVED, self::EVENT_PAYMENT_REMINDER],
516 ],
517 'transaction_id' => [
518 'key' => 'transaction_id',
519 'label' => 'Transaction ID',
520 'description' => 'Gateway transaction reference for the payment.',
521 'category' => self::CATEGORY_PAYMENT,
522 'sample' => 'ch_3O8XYZabc123',
523 'events' => [self::EVENT_PAYMENT_RECEIVED],
524 ],
525 'payment_gateway' => [
526 'key' => 'payment_gateway',
527 'label' => 'Payment Gateway (slug)',
528 'description' => 'Internal gateway slug — stripe / paypal / razorpay etc.',
529 'category' => self::CATEGORY_PAYMENT,
530 'sample' => 'stripe',
531 'events' => $bookingContextEvents,
532 ],
533 'payment_gateway_label' => [
534 'key' => 'payment_gateway_label',
535 'label' => 'Payment Gateway (label)',
536 'description' => 'Human-readable gateway name — Stripe, PayPal etc.',
537 'category' => self::CATEGORY_PAYMENT,
538 'sample' => 'Stripe',
539 'events' => $bookingContextEvents,
540 ],
541 'payment_schedule' => [
542 'key' => 'payment_schedule',
543 'label' => 'Payment Schedule (slug)',
544 'description' => 'full / deposit / partial — raw value.',
545 'category' => self::CATEGORY_PAYMENT,
546 'sample' => 'deposit',
547 'events' => $bookingContextEvents,
548 ],
549 'payment_schedule_label' => [
550 'key' => 'payment_schedule_label',
551 'label' => 'Payment Schedule (label)',
552 'description' => 'Humanised schedule (e.g. Deposit, Full Payment).',
553 'category' => self::CATEGORY_PAYMENT,
554 'sample' => 'Deposit',
555 'events' => $bookingContextEvents,
556 ],
557 'due_date' => [
558 'key' => 'due_date',
559 'label' => 'Due Date',
560 'description' => 'Payment due date for reminders.',
561 'category' => self::CATEGORY_PAYMENT,
562 'sample' => $sampleDueDate,
563 'events' => [self::EVENT_PAYMENT_REMINDER],
564 ],
565
566 // ---------------------------------------------------------
567 // Scheduled payments (installments)
568 // ---------------------------------------------------------
569 'scheduled_amount_formatted' => [
570 'key' => 'scheduled_amount_formatted',
571 'label' => 'Scheduled Amount (formatted)',
572 'description' => 'Amount of the upcoming scheduled charge with currency.',
573 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
574 'sample' => '$750.00',
575 'events' => $scheduledPaymentEvents,
576 ],
577 'scheduled_date_formatted' => [
578 'key' => 'scheduled_date_formatted',
579 'label' => 'Scheduled Date (formatted)',
580 'description' => 'When the next scheduled charge will run.',
581 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
582 'sample' => $sampleDueDate,
583 'events' => $scheduledPaymentEvents,
584 ],
585 'payment_type_label' => [
586 'key' => 'payment_type_label',
587 'label' => 'Payment Type Label',
588 'description' => 'Humanised type (Deposit, Final, Installment 2 of 4 ...).',
589 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
590 'sample' => 'Installment 2 of 4',
591 'events' => $scheduledPaymentEvents,
592 ],
593 'balance_after_formatted' => [
594 'key' => 'balance_after_formatted',
595 'label' => 'Balance After (formatted)',
596 'description' => 'Balance remaining after this charge succeeds.',
597 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
598 'sample' => '$1,250.00',
599 'events' => [self::EVENT_SCHEDULED_PAYMENT_SUCCEEDED],
600 ],
601 'failure_reason' => [
602 'key' => 'failure_reason',
603 'label' => 'Failure Reason',
604 'description' => 'Provided by the gateway when a scheduled charge fails.',
605 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
606 'sample' => 'Card declined',
607 'events' => [self::EVENT_SCHEDULED_PAYMENT_FAILED],
608 ],
609 'failure_intro_html' => [
610 'key' => 'failure_intro_html',
611 'label' => 'Failure Intro (HTML)',
612 'description' => 'Intro block for the payment-failure email body.',
613 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
614 'sample' => '<p>We were unable to process your scheduled payment.</p>',
615 'events' => [self::EVENT_SCHEDULED_PAYMENT_FAILED],
616 ],
617 'failure_followup_html' => [
618 'key' => 'failure_followup_html',
619 'label' => 'Failure Follow-up (HTML)',
620 'description' => 'Closing block prompting the customer to update payment.',
621 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
622 'sample' => '<p>Please update your payment method to avoid cancellation.</p>',
623 'events' => [self::EVENT_SCHEDULED_PAYMENT_FAILED],
624 ],
625
626 // ---------------------------------------------------------
627 // Reminder (trip & booking reminders)
628 // ---------------------------------------------------------
629 'days_until_trip' => [
630 'key' => 'days_until_trip',
631 'label' => 'Days Until Trip',
632 'description' => 'Days remaining until departure.',
633 'category' => self::CATEGORY_REMINDER,
634 'sample' => '30',
635 'events' => [self::EVENT_REMINDER_TRIP],
636 ],
637 'reminder_days' => [
638 'key' => 'reminder_days',
639 'label' => 'Reminder Days',
640 'description' => 'Configured number of days before the trip when the reminder fires.',
641 'category' => self::CATEGORY_REMINDER,
642 'sample' => '3',
643 'events' => [self::EVENT_REMINDER_TRIP],
644 ],
645 'reminder_extra_html' => [
646 'key' => 'reminder_extra_html',
647 'label' => 'Reminder Extra (HTML)',
648 'description' => 'Optional extra block appended to reminder emails (packing list, etc.).',
649 'category' => self::CATEGORY_REMINDER,
650 'sample' => '<p>Don\'t forget your passport and travel insurance.</p>',
651 'events' => [self::EVENT_REMINDER_TRIP],
652 ],
653 'review_url' => [
654 'key' => 'review_url',
655 'label' => 'Review URL',
656 'description' => 'Public link the customer opens to leave a review.',
657 'category' => self::CATEGORY_REMINDER,
658 'sample' => $homeUrl . 'trips/amazing-mountain-adventure#reviews',
659 'events' => [self::EVENT_REVIEW_REQUEST],
660 ],
661
662 // ---------------------------------------------------------
663 // Enquiry
664 // ---------------------------------------------------------
665 'enquiry_id' => [
666 'key' => 'enquiry_id',
667 'label' => 'Enquiry ID',
668 'description' => 'Internal numeric enquiry identifier.',
669 'category' => self::CATEGORY_ENQUIRY,
670 'sample' => '4567',
671 'events' => $enquiryContextEvents,
672 ],
673 'enquiry_date' => [
674 'key' => 'enquiry_date',
675 'label' => 'Enquiry Date',
676 'description' => 'When the enquiry was submitted.',
677 'category' => self::CATEGORY_ENQUIRY,
678 'sample' => function_exists('date_i18n') ? date_i18n($dateFormat) : date($dateFormat),
679 'events' => $enquiryContextEvents,
680 ],
681 'subject' => [
682 'key' => 'subject',
683 'label' => 'Subject',
684 'description' => 'Subject line the customer provided.',
685 'category' => self::CATEGORY_ENQUIRY,
686 'sample' => 'Question about Amazing Mountain Adventure',
687 'events' => $enquiryContextEvents,
688 ],
689 'message' => [
690 'key' => 'message',
691 'label' => 'Message',
692 'description' => 'Customer message body (sanitised, line breaks preserved).',
693 'category' => self::CATEGORY_ENQUIRY,
694 'sample' => 'I would like to know more about this trip. What is included in the package?',
695 'events' => $enquiryContextEvents,
696 ],
697 'original_message' => [
698 'key' => 'original_message',
699 'label' => 'Original Message',
700 'description' => 'First message in the enquiry thread (no line-break escaping).',
701 'category' => self::CATEGORY_ENQUIRY,
702 'sample' => 'I would like to know more about this trip.',
703 'events' => $enquiryContextEvents,
704 ],
705 'response' => [
706 'key' => 'response',
707 'label' => 'Response',
708 'description' => "Operator's typed reply (alias of response_message).",
709 'category' => self::CATEGORY_ENQUIRY,
710 'sample' => 'Thank you for your interest! The package includes accommodation, meals, and guided tours.',
711 'events' => [self::EVENT_ENQUIRY_RESPONDED],
712 ],
713 'response_message' => [
714 'key' => 'response_message',
715 'label' => 'Response Message',
716 'description' => "Operator's typed reply.",
717 'category' => self::CATEGORY_ENQUIRY,
718 'sample' => 'Thank you for your interest! The package includes accommodation, meals, and guided tours.',
719 'events' => [self::EVENT_ENQUIRY_RESPONDED],
720 ],
721 'response_date' => [
722 'key' => 'response_date',
723 'label' => 'Response Date',
724 'description' => 'When the reply was sent.',
725 'category' => self::CATEGORY_ENQUIRY,
726 'sample' => function_exists('date_i18n') ? date_i18n($dateFormat) : date($dateFormat),
727 'events' => [self::EVENT_ENQUIRY_RESPONDED],
728 ],
729
730 // ---------------------------------------------------------
731 // Trip consent (Pro)
732 // ---------------------------------------------------------
733 'recipient_name' => [
734 'key' => 'recipient_name',
735 'label' => 'Recipient Name',
736 'description' => 'Traveler receiving the consent email.',
737 'category' => self::CATEGORY_TRIP_CONSENT,
738 'sample' => 'Alex Traveler',
739 'events' => [self::EVENT_CONSENT_REQUESTED],
740 ],
741 'form_name' => [
742 'key' => 'form_name',
743 'label' => 'Consent Form Name',
744 'description' => 'Title of the consent form.',
745 'category' => self::CATEGORY_TRIP_CONSENT,
746 'sample' => 'Trip liability & release',
747 'events' => [self::EVENT_CONSENT_REQUESTED],
748 ],
749 'consent_link' => [
750 'key' => 'consent_link',
751 'label' => 'Consent Link',
752 'description' => 'URL to open and sign the form.',
753 'category' => self::CATEGORY_TRIP_CONSENT,
754 'sample' => $homeUrl . 'trip-consent/preview-token/',
755 'events' => [self::EVENT_CONSENT_REQUESTED],
756 ],
757 'consent_test_notice_html' => [
758 'key' => 'consent_test_notice_html',
759 'label' => 'Test Notice (HTML)',
760 'description' => 'Shown only on admin test sends.',
761 'category' => self::CATEGORY_TRIP_CONSENT,
762 'sample' => '',
763 'events' => [self::EVENT_CONSENT_REQUESTED],
764 ],
765
766 // ---------------------------------------------------------
767 // Account verification
768 // ---------------------------------------------------------
769 'verification_link' => [
770 'key' => 'verification_link',
771 'label' => 'Verification Link',
772 'description' => 'Magic link the customer opens to verify their email, or to confirm a requested new address.',
773 'category' => self::CATEGORY_ACCOUNT,
774 'sample' => $verificationSampleLink,
775 // Not offered for the "changed" notice — that email carries no link.
776 'events' => [self::EVENT_ACCOUNT_EMAIL_VERIFICATION, self::EVENT_ACCOUNT_EMAIL_CHANGE_REQUEST],
777 ],
778 'new_email' => [
779 'key' => 'new_email',
780 'label' => 'New Email Address',
781 'description' => 'The address the customer asked to switch their account to.',
782 'category' => self::CATEGORY_ACCOUNT,
783 'sample' => '[email protected]',
784 'events' => [self::EVENT_ACCOUNT_EMAIL_CHANGE_REQUEST, self::EVENT_ACCOUNT_EMAIL_CHANGED],
785 ],
786 'intro_paragraph' => [
787 'key' => 'intro_paragraph',
788 'label' => 'Intro Paragraph',
789 'description' => 'Opening sentence, set by the sender for each account email (verification, change request, changed notice).',
790 'category' => self::CATEGORY_ACCOUNT,
791 'sample' => 'Thank you for registering. Click the button in this email to verify your address.',
792 'events' => self::ACCOUNT_CONTEXT_EVENTS,
793 ],
794 'footer_note' => [
795 'key' => 'footer_note',
796 'label' => 'Footer Note',
797 'description' => 'Disclaimer for unintended recipients.',
798 'category' => self::CATEGORY_ACCOUNT,
799 'sample' => 'If you did not create an account, you can ignore this email.',
800 'events' => self::ACCOUNT_CONTEXT_EVENTS,
801 ],
802 'expiry_notice_html' => [
803 'key' => 'expiry_notice_html',
804 'label' => 'Expiry Notice (HTML)',
805 'description' => 'Link-expiry messaging block (consent / verification emails).',
806 'category' => self::CATEGORY_ACCOUNT,
807 'sample' => '<strong>Security note:</strong> This link expires in 24 hours.',
808 'events' => [
809 self::EVENT_ACCOUNT_EMAIL_VERIFICATION,
810 self::EVENT_CONSENT_REQUESTED,
811 ],
812 ],
813
814 // ---------------------------------------------------------
815 // Abandoned booking recovery (Pro)
816 // ---------------------------------------------------------
817 'recovery_link' => [
818 'key' => 'recovery_link',
819 'label' => 'Recovery Link',
820 'description' => 'Resume the abandoned checkout from the customer email.',
821 'category' => self::CATEGORY_ABANDONED_RECOVERY,
822 'sample' => $homeUrl . 'checkout/recover/sample-token',
823 'events' => [self::EVENT_BOOKING_ABANDONED_RECOVERY],
824 ],
825 'recovery_reminder_label' => [
826 'key' => 'recovery_reminder_label',
827 'label' => 'Reminder Label',
828 'description' => 'Sequence-stage label (First, Second, Final).',
829 'category' => self::CATEGORY_ABANDONED_RECOVERY,
830 'sample' => 'First reminder',
831 'events' => [self::EVENT_BOOKING_ABANDONED_RECOVERY],
832 ],
833 'recovery_intro_html' => [
834 'key' => 'recovery_intro_html',
835 'label' => 'Intro Paragraph (HTML)',
836 'description' => 'Lead paragraph specific to each recovery email.',
837 'category' => self::CATEGORY_ABANDONED_RECOVERY,
838 'sample' => '<p>You\'re just one step away from booking your dream trip.</p>',
839 'events' => [self::EVENT_BOOKING_ABANDONED_RECOVERY],
840 ],
841 ];
842
843 // Dynamically expose every enabled Contact/Emergency booking-form field —
844 // including custom fields an operator adds — so they're discoverable and
845 // usable as email variables. Values are resolved at send time by
846 // BookingEmailRichMergeTags (contact_/emergency_ prefixes).
847 $catalog = array_merge($catalog, self::bookingFormFieldDefinitions($bookingContextEvents));
848
849 /**
850 * Filter the email merge-tag catalogue so integrations (Channel
851 * Manager, WhatsApp, custom modules) can append their own tags.
852 *
853 * @param array $catalog The tag definitions keyed by tag key.
854 */
855 return function_exists('apply_filters')
856 ? (array) apply_filters('yatra_email_merge_tag_definitions', $catalog)
857 : $catalog;
858 }
859
860 /**
861 * Build merge-tag definitions from the live booking-form config so dynamic
862 * (and custom) Contact/Emergency fields surface in the email editor. Only
863 * enabled fields in enabled sections are included; existing canonical tags
864 * are never overwritten.
865 *
866 * @param array<int,string> $events
867 * @return array<string, array<string,mixed>>
868 */
869 private static function bookingFormFieldDefinitions(array $events): array
870 {
871 // Custom/dynamic booking-form fields are a Pro-module feature. When the
872 // Dynamic Form Field module is off the form is fixed, so we don't surface
873 // these extra tags — free installs keep their existing tag list unchanged.
874 if (!function_exists('apply_filters') || !apply_filters('yatra_dynamic_form_field_enabled', false)) {
875 return [];
876 }
877 if (!function_exists('yatra_get_booking_form_config')) {
878 return [];
879 }
880
881 $config = yatra_get_booking_form_config();
882 if (!is_array($config)) {
883 return [];
884 }
885
886 $sections = [
887 'contact_form' => ['prefix' => 'contact_', 'category' => self::CATEGORY_CUSTOMER],
888 'emergency_contact_form' => ['prefix' => 'emergency_', 'category' => self::CATEGORY_BOOKING],
889 ];
890
891 $defs = [];
892 foreach ($sections as $sectionKey => $meta) {
893 $section = $config[$sectionKey] ?? null;
894 if (!is_array($section) || (isset($section['enabled']) && !$section['enabled'])) {
895 continue;
896 }
897 // Fields the section can ask on ANY trip: the global list plus every
898 // per-trip condition's list (Pro) — a field that only a "Trekking"
899 // version of the form asks still needs its merge tag.
900 $fields = is_array($section['fields'] ?? null) ? $section['fields'] : [];
901 foreach ((array) ($section['conditions'] ?? []) as $condition) {
902 if (is_array($condition) && is_array($condition['fields'] ?? null)) {
903 $fields = array_merge($fields, $condition['fields']);
904 }
905 }
906 foreach ($fields as $field) {
907 if (!is_array($field) || empty($field['enabled']) || empty($field['id'])) {
908 continue;
909 }
910 // Text blocks are display-only content, not inputs — they hold no
911 // booking value, so they must not become email merge tags.
912 if (($field['type'] ?? '') === 'text_block') {
913 continue;
914 }
915 $id = sanitize_key((string) $field['id']);
916 if ($id === '') {
917 continue;
918 }
919 $tagKey = $meta['prefix'] . $id;
920 if (isset($defs[$tagKey])) {
921 continue;
922 }
923 $label = (string) ($field['label'] ?? ucwords(str_replace('_', ' ', $id)));
924 $defs[$tagKey] = [
925 'key' => $tagKey,
926 'label' => $label,
927 /* translators: %s: booking form field label. */
928 'description' => sprintf(__('Booking form field: %s', 'yatra'), $label),
929 'category' => $meta['category'],
930 'sample' => '',
931 'events' => $events,
932 ];
933 }
934 }
935
936 return $defs;
937 }
938
939 /**
940 * Return tag definitions grouped by category, optionally filtered to
941 * those that resolve for the given automation event.
942 *
943 * When $eventKey is empty or unknown, returns the full catalogue so
944 * the operator never sees an empty sidebar.
945 *
946 * @return array<string, list<array{key:string,label:string,description:string}>>
947 */
948 public static function groupedForEvent(string $eventKey = ''): array
949 {
950 $eventKey = trim($eventKey);
951 $defs = self::definitions();
952
953 if ($eventKey !== '' && !self::eventHasAnyDefinitions($eventKey, $defs)) {
954 // Unknown event — return the full registry rather than nothing.
955 $eventKey = '';
956 }
957
958 $grouped = [];
959 foreach ($defs as $def) {
960 if ($eventKey !== '' && !self::tagAppliesToEvent($def, $eventKey)) {
961 continue;
962 }
963 $cat = $def['category'] ?? self::CATEGORY_GENERAL;
964 $grouped[$cat] = $grouped[$cat] ?? [];
965 $grouped[$cat][] = [
966 'key' => $def['key'],
967 'label' => $def['label'],
968 'description' => $def['description'],
969 ];
970 }
971
972 return $grouped;
973 }
974
975 /**
976 * Flat list of tag keys that resolve for the given event. Used by
977 * EmailAutomationEvents::definitions to derive each event's variable
978 * whitelist instead of hand-maintaining a parallel list.
979 *
980 * @return list<string>
981 */
982 public static function keysForEvent(string $eventKey): array
983 {
984 $eventKey = trim($eventKey);
985 if ($eventKey === '') {
986 return [];
987 }
988 $keys = [];
989 foreach (self::definitions() as $def) {
990 if (self::tagAppliesToEvent($def, $eventKey)) {
991 $keys[] = $def['key'];
992 }
993 }
994 return $keys;
995 }
996
997 /**
998 * Sample variable map for the in-editor preview pipeline. Includes
999 * every tag with a non-empty sample value — operators see realistic
1000 * placeholders rather than the literal {{tag}} string.
1001 *
1002 * @return array<string, string>
1003 */
1004 public static function samples(): array
1005 {
1006 $out = [];
1007 foreach (self::definitions() as $def) {
1008 $out[$def['key']] = (string) ($def['sample'] ?? '');
1009 }
1010 return $out;
1011 }
1012
1013 /**
1014 * @param array{events:list<string>|string} $def
1015 */
1016 private static function tagAppliesToEvent(array $def, string $eventKey): bool
1017 {
1018 $events = $def['events'] ?? [];
1019 if ($events === '*' || $events === ['*']) {
1020 return true;
1021 }
1022 return is_array($events) && in_array($eventKey, $events, true);
1023 }
1024
1025 /**
1026 * @param array<string, array{events:list<string>|string}> $defs
1027 */
1028 private static function eventHasAnyDefinitions(string $eventKey, array $defs): bool
1029 {
1030 foreach ($defs as $def) {
1031 $events = $def['events'] ?? [];
1032 if ($events === '*' || $events === ['*']) {
1033 continue; // General tags don't qualify the event as "known".
1034 }
1035 if (is_array($events) && in_array($eventKey, $events, true)) {
1036 return true;
1037 }
1038 }
1039 return false;
1040 }
1041 }
1042