| @@ -9,9 +9,8 @@ | ||
| 9 | 9 | use WP_Error; |
| 10 | 10 | use Yatra\PaymentGateways\PaymentGatewayRegistry; |
| 11 | 11 | use Yatra\Repositories\BookingRepository; |
| 12 | 12 | use Yatra\Repositories\PaymentRepository; |
| 13 | -use Yatra\Repositories\ScheduledPaymentRepository; | |
| 14 | 13 | use Yatra\Repositories\TripRepository; |
| 15 | 14 | use Yatra\Helpers\FormatHelper; |
| 16 | 15 | use Yatra\Services\PdfService; |
| 17 | 16 | use Yatra\Services\SettingsService; |
| @@ -25,9 +24,8 @@ | ||
| 25 | 24 | { |
| 26 | 25 | private PaymentGatewayRegistry $registry; |
| 27 | 26 | private BookingRepository $bookingRepository; |
| 28 | 27 | private PaymentRepository $paymentRepository; |
| 29 | - private ScheduledPaymentRepository $scheduledPaymentRepository; | |
| 30 | 28 | private TripRepository $tripRepository; |
| 31 | 29 | |
| 32 | 30 | public function __construct() |
| 33 | 31 | { |
| @@ -33,9 +31,8 @@ | ||
| 33 | 31 | { |
| 34 | 32 | $this->registry = PaymentGatewayRegistry::getInstance(); |
| 35 | 33 | $this->bookingRepository = new BookingRepository(); |
| 36 | 34 | $this->paymentRepository = new PaymentRepository(); |
| 37 | - $this->scheduledPaymentRepository = new ScheduledPaymentRepository(); | |
| 38 | 35 | $this->tripRepository = new TripRepository(); |
| 39 | 36 | } |
| 40 | 37 | |
| 41 | 38 | public function register_routes(): void |
| @@ -147,16 +144,31 @@ | ||
| 147 | 144 | 'callback' => [$this, 'download_voucher'], |
| 148 | 145 | 'permission_callback' => '__return_true', // Auth checked inside callback |
| 149 | 146 | ], |
| 150 | 147 | ]); |
| 148 | + | |
| 149 | + // Download a pro-forma invoice for a booking that has no payment yet | |
| 150 | + // (offline gateways, e.g. Bank Transfer). Includes payment instructions | |
| 151 | + // so the customer knows how to pay. Auth checked inside the callback. | |
| 152 | + register_rest_route($namespace, '/booking/(?P<booking_id>[\d]+)/invoice', [ | |
| 153 | + [ | |
| 154 | + 'methods' => \WP_REST_Server::READABLE, | |
| 155 | + 'callback' => [$this, 'download_booking_invoice'], | |
| 156 | + 'permission_callback' => '__return_true', | |
| 157 | + ], | |
| 158 | + ]); | |
| 151 | 159 | } |
| 152 | 160 | |
| 153 | 161 | /** |
| 154 | - * Check admin permission | |
| 162 | + * Payment gateway config — critical-sensitivity cap. By default | |
| 163 | + * only the Owner role holds `yatra_manage_payment_gateways` | |
| 164 | + * (Manager doesn't, deliberately — gateway keys are among the | |
| 165 | + * most sensitive credentials on the site). WP admins pass via | |
| 166 | + * the Team module's admin-fallback filter. | |
| 155 | 167 | */ |
| 156 | 168 | public function check_admin_permission(): bool |
| 157 | 169 | { |
| 158 | - return current_user_can('manage_options'); | |
| 170 | + return current_user_can('yatra_manage_payment_gateways'); | |
| 159 | 171 | } |
| 160 | 172 | |
| 161 | 173 | public function check_customer_permission(): bool |
| 162 | 174 | { |
| @@ -202,8 +214,22 @@ | ||
| 202 | 214 | |
| 203 | 215 | $customerEmail = $booking->contact_email ?? ($booking->customer_email ?? ''); |
| 204 | 216 | $customerName = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')); |
| 205 | 217 | |
| 218 | + // Append `balance=paid` to the gateway's return URL so the booking-confirmation | |
| 219 | + // template can render a "balance just paid" banner instead of the generic "booking | |
| 220 | + // confirmed" copy. Same canonical URL — the flag only switches contextual content. | |
| 221 | + $confirmationUrl = $this->getConfirmationUrl($booking->reference ?? (string) $bookingId); | |
| 222 | + $confirmationUrl = add_query_arg('balance', 'paid', $confirmationUrl); | |
| 223 | + | |
| 224 | + // Customer-account base is configurable under Settings → Permalink. Don't | |
| 225 | + // hardcode `/my-account` — that breaks for sites that have customised the slug. | |
| 226 | + $accountUrl = home_url('/' . SettingsService::getAccountBase()); | |
| 227 | + $cancelUrl = add_query_arg( | |
| 228 | + ['tab' => 'payments', 'payment' => 'cancelled'], | |
| 229 | + $accountUrl | |
| 230 | + ); | |
| 231 | + | |
| 206 | 232 | $paymentData = [ |
| 207 | 233 | 'amount' => $remainingAmount, |
| 208 | 234 | 'currency' => $booking->currency ?? get_option('yatra_currency', 'USD'), |
| 209 | 235 | 'booking_id' => $bookingId, |
| @@ -208,11 +234,15 @@ | ||
| 208 | 234 | 'currency' => $booking->currency ?? get_option('yatra_currency', 'USD'), |
| 209 | 235 | 'booking_id' => $bookingId, |
| 210 | 236 | 'customer_email' => $customerEmail, |
| 211 | 237 | 'customer_name' => $customerName ?: $customerEmail, |
| 212 | - 'return_url' => $this->getConfirmationUrl($booking->reference ?? (string) $bookingId), | |
| 213 | - 'description' => sprintf(__('Remaining balance for Booking #%s', 'yatra'), $booking->reference ?? $bookingId), | |
| 214 | - 'cancel_url' => home_url('/my-account?tab=payments&payment=cancelled'), | |
| 238 | + 'return_url' => $confirmationUrl, | |
| 239 | + 'description' => sprintf( | |
| 240 | + /* translators: %s: booking reference. */ | |
| 241 | + __('Remaining balance for Booking #%s', 'yatra'), | |
| 242 | + $booking->reference ?? $bookingId | |
| 243 | + ), | |
| 244 | + 'cancel_url' => $cancelUrl, | |
| 215 | 245 | ]; |
| 216 | 246 | |
| 217 | 247 | $result = $this->registry->processPayment($method, $paymentData); |
| 218 | 248 | |
| @@ -309,15 +339,45 @@ | ||
| 309 | 339 | ]); |
| 310 | 340 | } |
| 311 | 341 | |
| 312 | 342 | /** |
| 313 | - * Get available gateways for checkout | |
| 343 | + * Get available gateways for checkout. | |
| 344 | + * | |
| 345 | + * For the *remaining-balance* checkout (when `yatra_has_remaining_session()` is | |
| 346 | + * true OR the request explicitly carries `?context=remaining`), offline gateways | |
| 347 | + * are filtered out — Pay Later / Bank Transfer don't actually collect money, so | |
| 348 | + * picking them to "settle a balance" leaves the booking still unpaid and the | |
| 349 | + * customer thinking they finished the flow. Filterable via | |
| 350 | + * `yatra_remaining_payment_allowed_gateways` if a site needs custom behaviour. | |
| 314 | 351 | */ |
| 315 | 352 | public function get_available_gateways(WP_REST_Request $request): WP_REST_Response |
| 316 | 353 | { |
| 354 | + $gateways = $this->registry->getForCheckout(); | |
| 355 | + | |
| 356 | + $context = sanitize_key((string) ($request->get_param('context') ?? '')); | |
| 357 | + $isRemainingFlow = $context === 'remaining' | |
| 358 | + || (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session()); | |
| 359 | + | |
| 360 | + if ($isRemainingFlow) { | |
| 361 | + $gateways = array_values(array_filter($gateways, static function ($gw) { | |
| 362 | + return empty($gw['is_offline']); | |
| 363 | + })); | |
| 364 | + | |
| 365 | + /** | |
| 366 | + * Filter the gateway list shown in the remaining-balance checkout. | |
| 367 | + * | |
| 368 | + * Default: every offline gateway (Pay Later, Bank Transfer, etc.) is | |
| 369 | + * removed so the customer can only pick a real-money method. | |
| 370 | + * | |
| 371 | + * @param array $gateways Gateway entries (id, title, is_offline, …). | |
| 372 | + */ | |
| 373 | + $gateways = apply_filters('yatra_remaining_payment_allowed_gateways', $gateways); | |
| 374 | + } | |
| 375 | + | |
| 317 | 376 | return new WP_REST_Response([ |
| 318 | - 'gateways' => $this->registry->getForCheckout(), | |
| 377 | + 'gateways' => $gateways, | |
| 319 | 378 | 'currency' => get_option('yatra_currency', 'USD'), |
| 379 | + 'context' => $isRemainingFlow ? 'remaining' : 'initial', | |
| 320 | 380 | ], 200); |
| 321 | 381 | } |
| 322 | 382 | |
| 323 | 383 | /** |
| @@ -367,18 +427,54 @@ | ||
| 367 | 427 | 'customer_name' => sanitize_text_field($request->get_param('customer_name')), |
| 368 | 428 | 'return_url' => esc_url_raw($request->get_param('return_url')), |
| 369 | 429 | ]; |
| 370 | 430 | |
| 371 | - // Enrich payment data with booking context (reference, trip title, cancel URL) | |
| 431 | + // Enrich payment data with booking context (reference, trip title, cancel URL). | |
| 432 | + // SECURITY: when a booking_id is supplied, the authoritative amount/currency must come | |
| 433 | + // from the database row, NOT from the client. Otherwise an attacker can pay $1 for a | |
| 434 | + // $1000 trip by tampering with the JSON body. | |
| 372 | 435 | if ($paymentData['booking_id'] > 0) { |
| 373 | 436 | $booking = $this->bookingRepository->find($paymentData['booking_id']); |
| 374 | - if ($booking) { | |
| 375 | - $paymentData['reference'] = $booking->reference ?? ''; | |
| 376 | - $paymentData['trip_title'] = $booking->trip_title ?? ''; | |
| 377 | - if (empty($paymentData['trip_id'])) { | |
| 378 | - $paymentData['trip_id'] = (int) ($booking->trip_id ?? 0); | |
| 437 | + if (!$booking) { | |
| 438 | + return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]); | |
| 439 | + } | |
| 440 | + | |
| 441 | + // If the booking is owned by a registered user, only that user (or an admin) may pay it. | |
| 442 | + // Guest bookings (user_id = 0) remain payable without auth — the booking session controls access. | |
| 443 | + $bookingUserId = (int) ($booking->user_id ?? 0); | |
| 444 | + if ($bookingUserId > 0) { | |
| 445 | + $currentUserId = (int) get_current_user_id(); | |
| 446 | + if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) { | |
| 447 | + return new WP_Error('forbidden', __('You do not have permission to pay for this booking.', 'yatra'), ['status' => 403]); | |
| 379 | 448 | } |
| 380 | 449 | } |
| 450 | + | |
| 451 | + // Reject already-paid bookings to prevent duplicate intents. | |
| 452 | + if (isset($booking->payment_status) && $booking->payment_status === 'paid') { | |
| 453 | + return new WP_Error('already_paid', __('This booking is already fully paid.', 'yatra'), ['status' => 400]); | |
| 454 | + } | |
| 455 | + | |
| 456 | + // Server-authoritative amount/currency. Use amount_due, falling back to total - paid for older rows. | |
| 457 | + $serverAmount = (float) ($booking->amount_due ?? ($booking->total_amount - $booking->amount_paid)); | |
| 458 | + $serverCurrency = (string) ($booking->currency ?? get_option('yatra_currency', 'USD')); | |
| 459 | + | |
| 460 | + if ($serverAmount <= 0) { | |
| 461 | + return new WP_Error('no_balance_due', __('This booking has no outstanding balance.', 'yatra'), ['status' => 400]); | |
| 462 | + } | |
| 463 | + | |
| 464 | + // Tolerate sub-cent rounding drift only. | |
| 465 | + if (abs($paymentData['amount'] - $serverAmount) > 0.01) { | |
| 466 | + $this->log_amount_mismatch((int) $booking->id, $paymentData['amount'], $serverAmount); | |
| 467 | + } | |
| 468 | + | |
| 469 | + // Always overwrite with server values regardless of what the client sent. | |
| 470 | + $paymentData['amount'] = $serverAmount; | |
| 471 | + $paymentData['currency'] = $serverCurrency; | |
| 472 | + $paymentData['reference'] = $booking->reference ?? ''; | |
| 473 | + $paymentData['trip_title'] = $booking->trip_title ?? ''; | |
| 474 | + if (empty($paymentData['trip_id'])) { | |
| 475 | + $paymentData['trip_id'] = (int) ($booking->trip_id ?? 0); | |
| 476 | + } | |
| 381 | 477 | } |
| 382 | 478 | |
| 383 | 479 | if (empty($paymentData['return_url'])) { |
| 384 | 480 | $reference = $paymentData['reference'] ?? (string) $paymentData['booking_id']; |
| @@ -385,9 +481,12 @@ | ||
| 385 | 481 | $paymentData['return_url'] = add_query_arg('payment', 'success', $this->getConfirmationUrl($reference)); |
| 386 | 482 | } |
| 387 | 483 | |
| 388 | 484 | $cancelParam = esc_url_raw($request->get_param('cancel_url')); |
| 389 | - $paymentData['cancel_url'] = $cancelParam ?: home_url('/book/?payment=cancelled&ref=' . ($paymentData['reference'] ?? $paymentData['booking_id'])); | |
| 485 | + // Fall back to the booking-confirmation page (always a resolvable route) rather | |
| 486 | + // than `home_url('/book/?...')`, which 404s under a custom booking base/page. | |
| 487 | + $cancelReference = (string) ($paymentData['reference'] ?? $paymentData['booking_id']); | |
| 488 | + $paymentData['cancel_url'] = $cancelParam ?: add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelReference)); | |
| 390 | 489 | |
| 391 | 490 | if ($paymentData['amount'] <= 0) { |
| 392 | 491 | return new WP_Error('invalid_amount', __('Invalid payment amount', 'yatra'), ['status' => 400]); |
| 393 | 492 | } |
| @@ -407,8 +506,30 @@ | ||
| 407 | 506 | return yatra_get_booking_confirmation_url($reference); |
| 408 | 507 | } |
| 409 | 508 | |
| 410 | 509 | /** |
| 510 | + * Record an attempted payment-amount mismatch (client sent X, server expects Y). | |
| 511 | + * The transaction itself is forced to the server amount; this exists for fraud monitoring. | |
| 512 | + */ | |
| 513 | + private function log_amount_mismatch(int $bookingId, float $clientAmount, float $serverAmount): void | |
| 514 | + { | |
| 515 | + if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 516 | + error_log(sprintf( | |
| 517 | + '[Yatra] Payment amount mismatch for booking %d: client=%.4f server=%.4f', | |
| 518 | + $bookingId, | |
| 519 | + $clientAmount, | |
| 520 | + $serverAmount | |
| 521 | + )); | |
| 522 | + } | |
| 523 | + | |
| 524 | + /** | |
| 525 | + * Fires when a client-supplied payment amount disagrees with the server-side booking amount. | |
| 526 | + * Useful for fraud-monitoring integrations. | |
| 527 | + */ | |
| 528 | + do_action('yatra_payment_amount_mismatch', $bookingId, $clientAmount, $serverAmount); | |
| 529 | + } | |
| 530 | + | |
| 531 | + /** | |
| 411 | 532 | * Confirm payment |
| 412 | 533 | */ |
| 413 | 534 | public function confirm_payment(WP_REST_Request $request) |
| 414 | 535 | { |
| @@ -421,8 +542,63 @@ | ||
| 421 | 542 | if (!$gateway) { |
| 422 | 543 | return new WP_Error('invalid_gateway', __('Gateway not found', 'yatra'), ['status' => 404]); |
| 423 | 544 | } |
| 424 | 545 | |
| 546 | + // Offline gateways (Bank Transfer, Pay Later) settle out of band and take | |
| 547 | + // no money at checkout. They must NEVER be auto-completed through this | |
| 548 | + // endpoint — doing so marks the booking paid + records a "completed" | |
| 549 | + // payment before any funds have arrived. Confirmation is a manual admin | |
| 550 | + // action once the operator sees the money. Guard here as well as in the | |
| 551 | + // gateways' verifyPayment() so a future offline gateway can't regress. | |
| 552 | + if ($gateway->isOffline()) { | |
| 553 | + return new WP_Error( | |
| 554 | + 'offline_gateway_manual', | |
| 555 | + __('This payment method is settled manually and cannot be confirmed automatically.', 'yatra'), | |
| 556 | + ['status' => 400] | |
| 557 | + ); | |
| 558 | + } | |
| 559 | + | |
| 560 | + if ($bookingId <= 0 || $transactionId === '') { | |
| 561 | + return new WP_Error('invalid_request', __('booking_id and transaction_id are required.', 'yatra'), ['status' => 400]); | |
| 562 | + } | |
| 563 | + | |
| 564 | + // Resolve the booking up front so we can enforce ownership BEFORE confirming a charge against it. | |
| 565 | + // Without this check, an anonymous attacker could mark booking B as paid by replaying a successful | |
| 566 | + // transaction_id that actually belongs to booking A. | |
| 567 | + $booking = $this->bookingRepository->find($bookingId); | |
| 568 | + if (!$booking) { | |
| 569 | + return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]); | |
| 570 | + } | |
| 571 | + | |
| 572 | + $bookingUserId = (int) ($booking->user_id ?? 0); | |
| 573 | + if ($bookingUserId > 0) { | |
| 574 | + $currentUserId = (int) get_current_user_id(); | |
| 575 | + if ($currentUserId !== $bookingUserId && !current_user_can('manage_options')) { | |
| 576 | + return new WP_Error('forbidden', __('You do not have permission to confirm this payment.', 'yatra'), ['status' => 403]); | |
| 577 | + } | |
| 578 | + } | |
| 579 | + | |
| 580 | + // Idempotency: if we have already recorded this transaction, return the cached verification result | |
| 581 | + // without re-applying the payment. Prevents duplicate ledger rows and double-confirmed bookings | |
| 582 | + // when the user reloads the confirmation page. | |
| 583 | + $existing = $this->paymentRepository->findByTransactionId($transactionId); | |
| 584 | + if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) { | |
| 585 | + return new WP_REST_Response([ | |
| 586 | + 'success' => true, | |
| 587 | + 'status' => $existing->status ?? 'completed', | |
| 588 | + 'amount' => (float) ($existing->amount ?? 0), | |
| 589 | + 'currency' => $existing->currency ?? null, | |
| 590 | + 'transaction_id' => $transactionId, | |
| 591 | + 'idempotent' => true, | |
| 592 | + ], 200); | |
| 593 | + } | |
| 594 | + | |
| 595 | + // If a payment with this transaction id is already attached to a DIFFERENT booking, refuse — | |
| 596 | + // someone is trying to reuse a stranger's transaction to pay their own booking. | |
| 597 | + if ($existing && (int) ($existing->booking_id ?? 0) !== $bookingId) { | |
| 598 | + return new WP_Error('transaction_mismatch', __('Transaction does not belong to this booking.', 'yatra'), ['status' => 409]); | |
| 599 | + } | |
| 600 | + | |
| 425 | 601 | $result = $gateway->verifyPayment($transactionId); |
| 426 | 602 | |
| 427 | 603 | if ($result['success']) { |
| 428 | 604 | // Get customer and payment method from result |
| @@ -428,16 +604,23 @@ | ||
| 428 | 604 | // Get customer and payment method from result |
| 429 | 605 | $customerId = $result['customer_id'] ?? null; |
| 430 | 606 | $paymentMethodId = $result['payment_method_id'] ?? $result['token_id'] ?? $result['vault_id'] ?? null; |
| 431 | 607 | |
| 608 | + $passForSchedule = (bool) apply_filters( | |
| 609 | + 'yatra_pass_gateway_ids_for_scheduled_payments', | |
| 610 | + $saveCard, | |
| 611 | + $result, | |
| 612 | + $bookingId | |
| 613 | + ); | |
| 614 | + | |
| 432 | 615 | $this->handle_successful_payment( |
| 433 | - $bookingId, | |
| 434 | - $gatewayId, | |
| 435 | - $transactionId, | |
| 436 | - $result['amount'] ?? null, | |
| 616 | + $bookingId, | |
| 617 | + $gatewayId, | |
| 618 | + $transactionId, | |
| 619 | + $result['amount'] ?? null, | |
| 437 | 620 | $result['currency'] ?? null, |
| 438 | - $saveCard ? $customerId : null, | |
| 439 | - $saveCard ? $paymentMethodId : null | |
| 621 | + ($saveCard || $passForSchedule) ? $customerId : null, | |
| 622 | + ($saveCard || $passForSchedule) ? $paymentMethodId : null | |
| 440 | 623 | ); |
| 441 | 624 | } |
| 442 | 625 | |
| 443 | 626 | return new WP_REST_Response($result, 200); |
| @@ -480,10 +663,19 @@ | ||
| 480 | 663 | wp_redirect(home_url('/booking-failed/')); |
| 481 | 664 | exit; |
| 482 | 665 | } |
| 483 | 666 | |
| 667 | + // Offline gateways never redirect here, and must never be auto-completed: | |
| 668 | + // they settle out of band and are confirmed manually by the operator. | |
| 669 | + // Bounce a spoofed `?status=success` callback to the confirmation page | |
| 670 | + // (still pending) rather than recording a payment that never happened. | |
| 671 | + if ($gateway->isOffline()) { | |
| 672 | + wp_redirect(yatra_get_booking_confirmation_url($bookingId > 0 ? (string) $bookingId : '')); | |
| 673 | + exit; | |
| 674 | + } | |
| 675 | + | |
| 484 | 676 | // Get transaction ID from request (varies by gateway) |
| 485 | - $transactionId = $request->get_param('refId') | |
| 677 | + $transactionId = $request->get_param('refId') | |
| 486 | 678 | ?? $request->get_param('pidx') |
| 487 | 679 | ?? $request->get_param('transaction_id') |
| 488 | 680 | ?? ''; |
| 489 | 681 | |
| @@ -502,13 +694,23 @@ | ||
| 502 | 694 | } |
| 503 | 695 | |
| 504 | 696 | /** |
| 505 | 697 | * Get payment status |
| 698 | + * | |
| 699 | + * Endpoint is public (`__return_true` permission) so guest checkouts can poll. Authorisation | |
| 700 | + * is enforced inline: registered-user bookings require the owning user (or an admin); guest | |
| 701 | + * bookings additionally require a matching short-lived booking_token transient so a stranger | |
| 702 | + * can't enumerate booking IDs to harvest payment metadata. | |
| 506 | 703 | */ |
| 507 | 704 | public function get_payment_status(WP_REST_Request $request) |
| 508 | 705 | { |
| 509 | 706 | $bookingId = (int) $request->get_param('booking_id'); |
| 707 | + $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? '')); | |
| 510 | 708 | |
| 709 | + if ($bookingId <= 0) { | |
| 710 | + return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]); | |
| 711 | + } | |
| 712 | + | |
| 511 | 713 | $payment = $this->paymentRepository->findLatestByBookingId($bookingId); |
| 512 | 714 | |
| 513 | 715 | if (!$payment) { |
| 514 | 716 | return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]); |
| @@ -513,8 +715,33 @@ | ||
| 513 | 715 | if (!$payment) { |
| 514 | 716 | return new WP_Error('payment_not_found', __('Payment not found', 'yatra'), ['status' => 404]); |
| 515 | 717 | } |
| 516 | 718 | |
| 719 | + $booking = $this->bookingRepository->find($bookingId); | |
| 720 | + $bookingUserId = $booking ? (int) ($booking->user_id ?? 0) : 0; | |
| 721 | + $currentUserId = (int) get_current_user_id(); | |
| 722 | + $authorised = false; | |
| 723 | + | |
| 724 | + if (current_user_can('manage_options')) { | |
| 725 | + $authorised = true; | |
| 726 | + } elseif ($bookingUserId > 0 && $currentUserId === $bookingUserId) { | |
| 727 | + $authorised = true; | |
| 728 | + } elseif ($bookingUserId === 0 && $bookingToken !== '') { | |
| 729 | + // Guest booking: require the booking-session transient to prove the requester is the | |
| 730 | + // browser that started this checkout. | |
| 731 | + $session = get_transient($bookingToken); | |
| 732 | + if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) { | |
| 733 | + $authorised = true; | |
| 734 | + } | |
| 735 | + } | |
| 736 | + | |
| 737 | + if (!$authorised) { | |
| 738 | + if ($currentUserId > 0) { | |
| 739 | + return new WP_Error('forbidden', __('You do not have permission to view this payment.', 'yatra'), ['status' => 403]); | |
| 740 | + } | |
| 741 | + return new WP_Error('unauthorized', __('Authentication required.', 'yatra'), ['status' => 401]); | |
| 742 | + } | |
| 743 | + | |
| 517 | 744 | return new WP_REST_Response([ |
| 518 | 745 | 'status' => $payment->status, |
| 519 | 746 | 'amount' => (float) $payment->amount, |
| 520 | 747 | 'currency' => $payment->currency, |
| @@ -550,8 +777,18 @@ | ||
| 550 | 777 | |
| 551 | 778 | $paid_amount = $amount ?? (float) $booking->amount_due; |
| 552 | 779 | $payment_currency = $currency ?? $booking->currency; |
| 553 | 780 | |
| 781 | + // Idempotency guard: skip if we have already recorded this gateway transaction for this booking. | |
| 782 | + // Prevents double-applied payments when both confirm_payment and the gateway's own return-handler | |
| 783 | + // (or a webhook) fire for the same charge. | |
| 784 | + if ($transactionId !== '') { | |
| 785 | + $existing = $this->paymentRepository->findByTransactionId($transactionId); | |
| 786 | + if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) { | |
| 787 | + return; | |
| 788 | + } | |
| 789 | + } | |
| 790 | + | |
| 554 | 791 | $payment_data = [ |
| 555 | 792 | 'booking_id' => $bookingId, |
| 556 | 793 | 'gateway' => $gateway, |
| 557 | 794 | 'transaction_id' => $transactionId, |
| @@ -573,26 +810,28 @@ | ||
| 573 | 810 | if ($new_amount_due > 0) { |
| 574 | 811 | $payment_status = 'partial'; |
| 575 | 812 | } |
| 576 | 813 | |
| 814 | + $previousBookingStatus = (string) ($booking->status ?? 'pending'); | |
| 815 | + | |
| 816 | + // Only auto-confirm when "Auto-Confirm Bookings" is on; otherwise the | |
| 817 | + // booking stays pending for the operator to confirm manually, regardless | |
| 818 | + // of a successful (full or partial) payment. | |
| 819 | + $should_confirm = \yatra_should_confirm_booking_on_payment($new_amount_due <= 0, $bookingId); | |
| 820 | + | |
| 577 | 821 | // Update booking |
| 578 | - $this->bookingRepository->update($bookingId, [ | |
| 822 | + $booking_update = [ | |
| 579 | 823 | 'amount_paid' => $new_amount_paid, |
| 580 | 824 | 'amount_due' => $new_amount_due, |
| 581 | 825 | 'payment_status' => $payment_status, |
| 582 | - 'status' => 'confirmed', | |
| 583 | - ]); | |
| 826 | + ]; | |
| 827 | + if ($should_confirm) { | |
| 828 | + $booking_update['status'] = 'confirmed'; | |
| 829 | + } | |
| 830 | + $this->bookingRepository->update($bookingId, $booking_update); | |
| 584 | 831 | |
| 585 | - // Handle scheduled payments for remaining balance | |
| 586 | - if ($new_amount_due > 0 && $customerId && $paymentMethodId) { | |
| 587 | - $this->createScheduledPaymentsForBooking( | |
| 588 | - $bookingId, | |
| 589 | - $gateway, | |
| 590 | - $customerId, | |
| 591 | - $paymentMethodId, | |
| 592 | - $new_amount_due, | |
| 593 | - $payment_currency | |
| 594 | - ); | |
| 832 | + if ($should_confirm) { | |
| 833 | + \yatra_trigger_booking_confirmed($bookingId, $previousBookingStatus, true); | |
| 595 | 834 | } |
| 596 | 835 | |
| 597 | 836 | // Clear remaining payment session if this was a remaining payment |
| 598 | 837 | if (function_exists('yatra_has_remaining_session') && yatra_has_remaining_session()) { |
| @@ -607,103 +846,8 @@ | ||
| 607 | 846 | ]); |
| 608 | 847 | } |
| 609 | 848 | |
| 610 | 849 | /** |
| 611 | - * Create scheduled payments for remaining balance | |
| 612 | - */ | |
| 613 | - private function createScheduledPaymentsForBooking( | |
| 614 | - int $bookingId, | |
| 615 | - string $gateway, | |
| 616 | - string $customerId, | |
| 617 | - string $paymentMethodId, | |
| 618 | - float $remainingAmount, | |
| 619 | - string $currency | |
| 620 | - ): void { | |
| 621 | - // Get scheduled payment settings | |
| 622 | - $settings = \Yatra\Services\SettingsService::getAll(); | |
| 623 | - | |
| 624 | - // Check if auto-scheduled payments is enabled | |
| 625 | - if (empty($settings['enable_scheduled_payments'])) { | |
| 626 | - return; | |
| 627 | - } | |
| 628 | - | |
| 629 | - // Save the payment token first | |
| 630 | - $tokenId = $this->savePaymentToken($bookingId, $gateway, $customerId, $paymentMethodId); | |
| 631 | - | |
| 632 | - if (!$tokenId) { | |
| 633 | - return; | |
| 634 | - } | |
| 635 | - | |
| 636 | - // Get schedule configuration from settings | |
| 637 | - $schedule = [ | |
| 638 | - 'type' => $settings['scheduled_payment_type'] ?? 'single', // single, installments | |
| 639 | - 'days_until' => (int) ($settings['scheduled_payment_days'] ?? 15), | |
| 640 | - 'installments' => (int) ($settings['scheduled_payment_installments'] ?? 1), | |
| 641 | - 'interval_days' => (int) ($settings['scheduled_payment_interval'] ?? 30), | |
| 642 | - ]; | |
| 643 | - | |
| 644 | - // Create scheduled payments | |
| 645 | - \Yatra\Services\ScheduledPaymentService::createScheduledPayments( | |
| 646 | - $bookingId, | |
| 647 | - $gateway, | |
| 648 | - $customerId, | |
| 649 | - $tokenId, | |
| 650 | - $remainingAmount, | |
| 651 | - $currency, | |
| 652 | - $schedule | |
| 653 | - ); | |
| 654 | - } | |
| 655 | - | |
| 656 | - /** | |
| 657 | - * Save payment token for future charges | |
| 658 | - */ | |
| 659 | - private function savePaymentToken( | |
| 660 | - int $bookingId, | |
| 661 | - string $gateway, | |
| 662 | - string $customerId, | |
| 663 | - string $paymentMethodId | |
| 664 | - ): ?int { | |
| 665 | - // Get booking for customer info | |
| 666 | - $booking = $this->bookingRepository->find($bookingId); | |
| 667 | - | |
| 668 | - if (!$booking) { | |
| 669 | - return null; | |
| 670 | - } | |
| 671 | - | |
| 672 | - $userId = get_current_user_id() ?: 0; | |
| 673 | - | |
| 674 | - // Get payment method details from gateway | |
| 675 | - $gatewayInstance = $this->registry->get($gateway); | |
| 676 | - $cardInfo = []; | |
| 677 | - | |
| 678 | - if ($gatewayInstance) { | |
| 679 | - $methods = $gatewayInstance->getPaymentMethods($customerId); | |
| 680 | - foreach ($methods as $method) { | |
| 681 | - if ($method['id'] === $paymentMethodId) { | |
| 682 | - $cardInfo = $method; | |
| 683 | - break; | |
| 684 | - } | |
| 685 | - } | |
| 686 | - } | |
| 687 | - | |
| 688 | - // Create token via repository | |
| 689 | - $tokenId = $this->scheduledPaymentRepository->createPaymentToken([ | |
| 690 | - 'customer_id' => $userId, | |
| 691 | - 'user_id' => $userId, | |
| 692 | - 'gateway' => $gateway, | |
| 693 | - 'token' => $paymentMethodId, | |
| 694 | - 'payment_method_id' => $paymentMethodId, | |
| 695 | - 'card_brand' => $cardInfo['brand'] ?? null, | |
| 696 | - 'card_last4' => $cardInfo['last4'] ?? null, | |
| 697 | - 'card_exp_month' => $cardInfo['exp_month'] ?? null, | |
| 698 | - 'card_exp_year' => $cardInfo['exp_year'] ?? null, | |
| 699 | - 'is_default' => 1, | |
| 700 | - ]); | |
| 701 | - | |
| 702 | - return $tokenId ?: null; | |
| 703 | - } | |
| 704 | - | |
| 705 | - /** | |
| 706 | 850 | * Download invoice PDF for a payment |
| 707 | 851 | */ |
| 708 | 852 | public function download_invoice(WP_REST_Request $request) |
| 709 | 853 | { |
| @@ -709,8 +853,10 @@ | ||
| 709 | 853 | { |
| 710 | 854 | $paymentId = (int) $request->get_param('payment_id'); |
| 711 | 855 | $isPreview = $request->get_param('preview') === '1'; |
| 712 | 856 | $isDownload = $request->get_param('download') === '1'; |
| 857 | + $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? '')); | |
| 858 | + $invoiceToken = sanitize_text_field((string) ($request->get_param('invoice_token') ?? '')); | |
| 713 | 859 | |
| 714 | 860 | if ($paymentId <= 0) { |
| 715 | 861 | return new WP_Error('invalid_payment', __('Invalid payment ID.', 'yatra'), ['status' => 400]); |
| 716 | 862 | } |
| @@ -721,21 +867,47 @@ | ||
| 721 | 867 | if (!$payment) { |
| 722 | 868 | return new WP_Error('payment_not_found', __('Payment not found.', 'yatra'), ['status' => 404]); |
| 723 | 869 | } |
| 724 | 870 | |
| 725 | - // Verify user is logged in and owns this payment (or is admin) | |
| 726 | - $currentUserId = get_current_user_id(); | |
| 871 | + // Authorisation: | |
| 872 | + // 1. Staff can always access (no further checks): a WP administrator, | |
| 873 | + // or a user holding Yatra's yatra_view_bookings capability, which is | |
| 874 | + // the same audience that already sees every booking in the list. | |
| 875 | + // 2. Logged-in owner of the booking can access. | |
| 876 | + // 3. Anyone with a valid signed `invoice_token` (HMAC) can access — used on the | |
| 877 | + // booking-confirmation page so guest checkouts and post-session views work. | |
| 878 | + // 4. Legacy guest path: `booking_token` (active checkout transient) — kept for BC. | |
| 879 | + $currentUserId = (int) get_current_user_id(); | |
| 727 | 880 | $bookingUserId = (int) ($payment->booking_user_id ?? $payment->user_id ?? 0); |
| 728 | - | |
| 729 | - // Must be logged in | |
| 730 | - if (!$currentUserId) { | |
| 881 | + $paymentBookingId = (int) ($payment->booking_id ?? 0); | |
| 882 | + $isAdmin = current_user_can('manage_options') || current_user_can('yatra_view_bookings'); | |
| 883 | + $authorised = false; | |
| 884 | + | |
| 885 | + if ($isAdmin) { | |
| 886 | + $authorised = true; | |
| 887 | + } elseif ($currentUserId && $bookingUserId && $currentUserId === $bookingUserId) { | |
| 888 | + $authorised = true; | |
| 889 | + } elseif ($invoiceToken !== '' && self::verifyInvoiceToken($invoiceToken, (int) $payment->id, $paymentBookingId)) { | |
| 890 | + $authorised = true; | |
| 891 | + } elseif ($bookingToken !== '') { | |
| 892 | + $guestEnabled = (bool) SettingsService::get('allow_guest_checkout', true); | |
| 893 | + if ($guestEnabled) { | |
| 894 | + $session = get_transient($bookingToken); | |
| 895 | + if (is_array($session)) { | |
| 896 | + $sessionBookingId = (int) ($session['booking_id'] ?? 0); | |
| 897 | + if ($sessionBookingId > 0 && $paymentBookingId > 0 && $sessionBookingId === $paymentBookingId) { | |
| 898 | + $authorised = true; | |
| 899 | + } | |
| 900 | + } | |
| 901 | + } | |
| 902 | + } | |
| 903 | + | |
| 904 | + if (!$authorised) { | |
| 905 | + if ($currentUserId) { | |
| 906 | + return new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403]); | |
| 907 | + } | |
| 731 | 908 | return new WP_Error('unauthorized', __('You must be logged in to download invoices.', 'yatra'), ['status' => 401]); |
| 732 | 909 | } |
| 733 | - | |
| 734 | - // Must own the booking or be admin | |
| 735 | - if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) { | |
| 736 | - return new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403]); | |
| 737 | - } | |
| 738 | 910 | |
| 739 | 911 | // Get trip details if available |
| 740 | 912 | $trip = null; |
| 741 | 913 | if (!empty($payment->trip_id)) { |
| @@ -779,9 +951,11 @@ | ||
| 779 | 951 | $tax_amount += (float) ($tax['amount'] ?? 0); |
| 780 | 952 | $tax_breakdown[] = [ |
| 781 | 953 | 'name' => $tax['name'] ?? 'Tax', |
| 782 | 954 | 'rate' => $tax['rate'] ?? 0, |
| 783 | - 'amount' => $tax['amount'] ?? 0 | |
| 955 | + // Pre-formatted like every other invoice figure, so the tax | |
| 956 | + // rows honour the configured separators and symbol position. | |
| 957 | + 'amount' => yatra_format_price((float) ($tax['amount'] ?? 0), $currency, false) | |
| 784 | 958 | ]; |
| 785 | 959 | } |
| 786 | 960 | // Adjust subtotal for tax-exclusive pricing |
| 787 | 961 | if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) { |
| @@ -792,9 +966,9 @@ | ||
| 792 | 966 | $tax_amount = (float) $payment->tax_amount; |
| 793 | 967 | $tax_breakdown[] = [ |
| 794 | 968 | 'name' => __('Tax', 'yatra'), |
| 795 | 969 | 'rate' => (float) ($payment->tax_rate ?? 0), |
| 796 | - 'amount' => $tax_amount | |
| 970 | + 'amount' => yatra_format_price((float) $tax_amount, $currency, false) | |
| 797 | 971 | ]; |
| 798 | 972 | // Adjust subtotal for tax-exclusive pricing |
| 799 | 973 | if (!empty($payment->tax_inclusive) && $payment->tax_inclusive) { |
| 800 | 974 | $subtotal = (float) ($payment->subtotal ?? $subtotal); |
| @@ -805,28 +979,42 @@ | ||
| 805 | 979 | |
| 806 | 980 | $templateData = [ |
| 807 | 981 | 'company_name' => $companyName, |
| 808 | 982 | 'company_address' => $companyAddress, |
| 983 | + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(), | |
| 809 | 984 | 'company_email' => $companyEmail, |
| 810 | 985 | 'company_phone' => $companyPhone, |
| 811 | 986 | 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')), |
| 812 | 987 | 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '', |
| 813 | - 'payment_ref' => $payment->reference ?? '', | |
| 988 | + 'customer_address_lines' => FormatHelper::customerAddressLines($payment), | |
| 989 | + // The booking_payments table has no `reference` column — the payment | |
| 990 | + // reference is a derived value. Mirror PaymentService::formatPayment | |
| 991 | + // (`PAY-%06d`, the same string the React account page shows) so the | |
| 992 | + // invoice's "Invoice #" is populated and consistent, instead of blank. | |
| 993 | + // A real stored reference (if a future join ever provides one) still wins. | |
| 994 | + 'payment_ref' => (isset($payment->reference) && (string) $payment->reference !== '') | |
| 995 | + ? (string) $payment->reference | |
| 996 | + : sprintf('PAY-%06d', (int) ($payment->id ?? 0)), | |
| 814 | 997 | 'payment_date' => $paymentDate, |
| 815 | 998 | 'payment_status' => ucfirst($payment->status ?? 'paid'), |
| 816 | 999 | 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['paid', 'completed', 'success'], true) ? 'paid' : 'pending', |
| 817 | 1000 | 'trip_title' => $trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra'), |
| 818 | - 'payment_method' => ucfirst($payment->gateway ?? $payment->payment_method ?? 'Online'), | |
| 819 | - 'booking_ref' => $payment->booking_reference ?? $payment->booking_number ?? '', | |
| 1001 | + 'payment_method' => $this->gatewayLabel( | |
| 1002 | + $payment->gateway ?? $payment->payment_method ?? null, | |
| 1003 | + __('Online', 'yatra') | |
| 1004 | + ), | |
| 1005 | + // Booking-only fallback chain (never a payment identifier) so the | |
| 1006 | + // invoice number always resolves to the booking reference. | |
| 1007 | + 'booking_ref' => $payment->booking_reference ?? $payment->booking_number ?? (string) ($payment->booking_id ?? ''), | |
| 820 | 1008 | 'travel_date' => $travelDate, |
| 821 | 1009 | 'currency_symbol' => $currencySymbol, |
| 822 | - 'amount' => number_format((float) ($payment->amount ?? 0), 2), | |
| 823 | - 'booking_total' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2), | |
| 824 | - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2), | |
| 825 | - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2), | |
| 1010 | + 'amount' => yatra_format_price((float) ($payment->amount ?? 0), $currency, false), | |
| 1011 | + 'booking_total' => yatra_format_price((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), $currency, false), | |
| 1012 | + 'amount_paid' => yatra_format_price((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), $currency, false), | |
| 1013 | + 'amount_due' => yatra_format_price((float) ($payment->booking_amount_due ?? 0), $currency, false), | |
| 826 | 1014 | 'tax_breakdown' => $tax_breakdown, |
| 827 | - 'tax_amount' => number_format($tax_amount, 2), | |
| 828 | - 'subtotal' => number_format($subtotal, 2), | |
| 1015 | + 'tax_amount' => yatra_format_price((float) $tax_amount, $currency, false), | |
| 1016 | + 'subtotal' => yatra_format_price((float) $subtotal, $currency, false), | |
| 829 | 1017 | ]; |
| 830 | 1018 | |
| 831 | 1019 | $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [ |
| 832 | 1020 | 'paper' => 'A4', |
| @@ -848,8 +1036,144 @@ | ||
| 848 | 1036 | } |
| 849 | 1037 | } |
| 850 | 1038 | |
| 851 | 1039 | /** |
| 1040 | + * Customer-facing label for a gateway id on a document. | |
| 1041 | + * | |
| 1042 | + * Uses the same title the checkout shows (operator's custom title, else the | |
| 1043 | + * gateway's translated one). Falls back to the prettified id — the previous | |
| 1044 | + * behaviour — when the gateway is not registered any more, so an invoice for | |
| 1045 | + * a payment taken through a since-removed gateway still reads sensibly. | |
| 1046 | + */ | |
| 1047 | + private function gatewayLabel(?string $gatewayId, string $fallback = 'Online'): string | |
| 1048 | + { | |
| 1049 | + return function_exists('yatra_payment_gateway_label') | |
| 1050 | + ? yatra_payment_gateway_label($gatewayId, $fallback) | |
| 1051 | + : ($gatewayId ? ucwords(str_replace(['_', '-'], ' ', $gatewayId)) : $fallback); | |
| 1052 | + } | |
| 1053 | + | |
| 1054 | + /** | |
| 1055 | + * Download a PRO-FORMA invoice for a booking that has no payment yet | |
| 1056 | + * (offline gateways such as Bank Transfer). Shows the amount due and any | |
| 1057 | + * gateway-supplied payment instructions (via yatra_invoice_payment_instructions) | |
| 1058 | + * so the customer knows how to pay. Renders the same pdf/invoice.php template. | |
| 1059 | + */ | |
| 1060 | + public function download_booking_invoice(WP_REST_Request $request) | |
| 1061 | + { | |
| 1062 | + $bookingId = (int) $request->get_param('booking_id'); | |
| 1063 | + $isPreview = $request->get_param('preview') === '1'; | |
| 1064 | + $bookingToken = sanitize_text_field((string) ($request->get_param('booking_token') ?? '')); | |
| 1065 | + $invoiceToken = sanitize_text_field((string) ($request->get_param('invoice_token') ?? '')); | |
| 1066 | + | |
| 1067 | + if ($bookingId <= 0) { | |
| 1068 | + return new WP_Error('invalid_booking', __('Invalid booking ID.', 'yatra'), ['status' => 400]); | |
| 1069 | + } | |
| 1070 | + | |
| 1071 | + $bookingRepository = new \Yatra\Repositories\BookingRepository(); | |
| 1072 | + $booking = $bookingRepository->find($bookingId); | |
| 1073 | + if (!$booking) { | |
| 1074 | + return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]); | |
| 1075 | + } | |
| 1076 | + | |
| 1077 | + // Authorisation mirrors download_invoice: staff -> owner -> signed | |
| 1078 | + // booking-scoped invoice_token (paymentId 0) -> guest booking_token. | |
| 1079 | + // Staff means a WP admin OR a user holding Yatra's booking-view | |
| 1080 | + // capability, so Pro Team roles (which deliberately don't carry | |
| 1081 | + // manage_options) can use the admin "Download invoice" action. | |
| 1082 | + $currentUserId = (int) get_current_user_id(); | |
| 1083 | + $bookingUserId = (int) ($booking->user_id ?? 0); | |
| 1084 | + $authorised = false; | |
| 1085 | + if (current_user_can('manage_options') || current_user_can('yatra_view_bookings')) { | |
| 1086 | + $authorised = true; | |
| 1087 | + } elseif ($currentUserId && $bookingUserId && $currentUserId === $bookingUserId) { | |
| 1088 | + $authorised = true; | |
| 1089 | + } elseif ($invoiceToken !== '' && self::verifyInvoiceToken($invoiceToken, 0, $bookingId)) { | |
| 1090 | + $authorised = true; | |
| 1091 | + } elseif ($bookingToken !== '' && (bool) SettingsService::get('allow_guest_checkout', true)) { | |
| 1092 | + $session = get_transient($bookingToken); | |
| 1093 | + if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) { | |
| 1094 | + $authorised = true; | |
| 1095 | + } | |
| 1096 | + } | |
| 1097 | + if (!$authorised) { | |
| 1098 | + return $currentUserId | |
| 1099 | + ? new WP_Error('forbidden', __('You do not have permission to access this invoice.', 'yatra'), ['status' => 403]) | |
| 1100 | + : new WP_Error('unauthorized', __('You must be logged in to download invoices.', 'yatra'), ['status' => 401]); | |
| 1101 | + } | |
| 1102 | + | |
| 1103 | + $pdfService = new PdfService(); | |
| 1104 | + if (!$pdfService->isAvailable()) { | |
| 1105 | + return new WP_Error('pdf_engine_missing', __('Invoice PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'), ['status' => 500]); | |
| 1106 | + } | |
| 1107 | + | |
| 1108 | + $trip = !empty($booking->trip_id) ? $this->tripRepository->find((int) $booking->trip_id) : null; | |
| 1109 | + | |
| 1110 | + $currency = SettingsService::getCurrency(); | |
| 1111 | + $currencySymbol = FormatHelper::getCurrencySymbol($currency); | |
| 1112 | + $bookingRef = (string) ($booking->reference ?? $booking->booking_number ?? (string) $bookingId); | |
| 1113 | + $filename = 'Invoice #' . $bookingRef . '.pdf'; | |
| 1114 | + $travelDate = !empty($booking->travel_date) ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date)) : ''; | |
| 1115 | + | |
| 1116 | + $total = (float) ($booking->total_amount ?? 0); | |
| 1117 | + $paid = (float) ($booking->amount_paid ?? 0); | |
| 1118 | + $due = (float) ($booking->amount_due ?? max(0.0, $total - $paid)); | |
| 1119 | + | |
| 1120 | + // Gateway-supplied payment instructions (Bank Transfer fills this in Pro). | |
| 1121 | + $paymentInstructions = apply_filters('yatra_invoice_payment_instructions', [], $booking); | |
| 1122 | + | |
| 1123 | + $templateData = [ | |
| 1124 | + 'company_name' => SettingsService::get('company_name', get_bloginfo('name')), | |
| 1125 | + 'company_address' => SettingsService::get('company_address', ''), | |
| 1126 | + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(), | |
| 1127 | + 'company_email' => SettingsService::get('company_email', get_option('admin_email')), | |
| 1128 | + 'company_phone' => SettingsService::get('company_phone', ''), | |
| 1129 | + 'customer_name' => trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')) ?: __('Customer', 'yatra'), | |
| 1130 | + 'customer_email' => $booking->contact_email ?? '', | |
| 1131 | + 'customer_address_lines' => FormatHelper::customerAddressLines($booking), | |
| 1132 | + 'payment_ref' => $bookingRef, | |
| 1133 | + 'payment_date' => !empty($booking->created_at) ? date_i18n(get_option('date_format'), strtotime((string) $booking->created_at)) : '', | |
| 1134 | + // Reflect the booking's real payment state rather than a fixed | |
| 1135 | + // "Payment Pending" — a deposit-paid booking is Partially Paid. | |
| 1136 | + 'payment_status' => $due <= 0.0 | |
| 1137 | + ? __('Paid', 'yatra') | |
| 1138 | + : ($paid > 0.0 ? __('Partially Paid', 'yatra') : __('Payment Pending', 'yatra')), | |
| 1139 | + 'status_class' => $due <= 0.0 ? 'paid' : ($paid > 0.0 ? 'partial' : 'pending'), | |
| 1140 | + 'trip_title' => $trip->title ?? $booking->trip_title ?? __('Trip Booking', 'yatra'), | |
| 1141 | + 'payment_method' => $this->gatewayLabel( | |
| 1142 | + $booking->payment_gateway ?? null, | |
| 1143 | + __('Offline', 'yatra') | |
| 1144 | + ), | |
| 1145 | + 'booking_ref' => $bookingRef, | |
| 1146 | + 'travel_date' => $travelDate, | |
| 1147 | + 'currency_symbol' => $currencySymbol, | |
| 1148 | + 'amount' => yatra_format_price((float) $due, $currency, false), | |
| 1149 | + 'booking_total' => yatra_format_price((float) $total, $currency, false), | |
| 1150 | + 'amount_paid' => yatra_format_price((float) $paid, $currency, false), | |
| 1151 | + 'amount_due' => yatra_format_price((float) $due, $currency, false), | |
| 1152 | + 'tax_breakdown' => [], | |
| 1153 | + 'tax_amount' => yatra_format_price(0.0, $currency, false), | |
| 1154 | + 'subtotal' => yatra_format_price((float) $total, $currency, false), | |
| 1155 | + 'payment_instructions' => is_array($paymentInstructions) ? $paymentInstructions : [], | |
| 1156 | + ]; | |
| 1157 | + | |
| 1158 | + $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/invoice.php', $templateData, [ | |
| 1159 | + 'paper' => 'A4', | |
| 1160 | + 'orientation' => 'portrait', | |
| 1161 | + 'default_font' => 'DejaVu Sans', | |
| 1162 | + ]); | |
| 1163 | + | |
| 1164 | + if ($isPreview) { | |
| 1165 | + return new WP_REST_Response([ | |
| 1166 | + 'success' => true, | |
| 1167 | + 'pdf_data' => base64_encode($pdfBinary), | |
| 1168 | + 'filename' => $filename, | |
| 1169 | + ]); | |
| 1170 | + } | |
| 1171 | + $pdfService->outputPdfDownload($pdfBinary, $filename); | |
| 1172 | + exit; | |
| 1173 | + } | |
| 1174 | + | |
| 1175 | + /** | |
| 852 | 1176 | * Download travel voucher PDF for a booking |
| 853 | 1177 | */ |
| 854 | 1178 | public function download_voucher(WP_REST_Request $request) |
| 855 | 1179 | { |
| @@ -899,13 +1223,27 @@ | ||
| 899 | 1223 | // Format dates |
| 900 | 1224 | $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : ''; |
| 901 | 1225 | $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : ''; |
| 902 | 1226 | |
| 903 | - // Calculate return date if duration is available | |
| 1227 | + // Return date. Prefer the booking's STORED end_date — that is the actual | |
| 1228 | + // booked return (it already accounts for a flexible window or a trip | |
| 1229 | + // duration that changed after the booking was made). Only when no end is | |
| 1230 | + // stored do we derive it from the trip duration: duration_days is | |
| 1231 | + // INCLUSIVE, so the offset is (days - 1) — matching | |
| 1232 | + // BookingRepository::calculateEndDate. A bare "+ duration_days" was one | |
| 1233 | + // day too far (see ItineraryPdfBuilder). | |
| 904 | 1234 | $returnDate = ''; |
| 905 | - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) { | |
| 906 | - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days'); | |
| 907 | - $returnDate = date_i18n(get_option('date_format'), $returnTimestamp); | |
| 1235 | + $storedEnd = isset($payment->booking_end_date) ? (string) $payment->booking_end_date : ''; | |
| 1236 | + $travelStart = (string) ($payment->travel_date ?? ''); | |
| 1237 | + if ($storedEnd !== '' && ($travelStart === '' || $storedEnd >= $travelStart)) { | |
| 1238 | + $returnDate = date_i18n(get_option('date_format'), strtotime($storedEnd)); | |
| 1239 | + } else { | |
| 1240 | + $durationDaysForReturn = (int) ($payment->trip_duration_days ?? ($trip->duration_days ?? 0)); | |
| 1241 | + if (!empty($payment->travel_date) && $durationDaysForReturn > 0) { | |
| 1242 | + $returnOffset = max(0, $durationDaysForReturn - 1); | |
| 1243 | + $returnTimestamp = strtotime($payment->travel_date . ' +' . $returnOffset . ' days'); | |
| 1244 | + $returnDate = date_i18n(get_option('date_format'), $returnTimestamp); | |
| 1245 | + } | |
| 908 | 1246 | } |
| 909 | 1247 | |
| 910 | 1248 | $bookingRef = (string) ($payment->booking_reference ?? $payment->booking_number ?? $payment->reference ?? (string) $paymentId); |
| 911 | 1249 | $filename = 'Travel Voucher #' . $bookingRef . '.pdf'; |
| @@ -921,12 +1259,14 @@ | ||
| 921 | 1259 | |
| 922 | 1260 | $templateData = [ |
| 923 | 1261 | 'company_name' => $companyName, |
| 924 | 1262 | 'company_address' => $companyAddress, |
| 1263 | + 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(), | |
| 925 | 1264 | 'company_email' => $companyEmail, |
| 926 | 1265 | 'company_phone' => $companyPhone, |
| 927 | 1266 | 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')), |
| 928 | 1267 | 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '', |
| 1268 | + 'customer_address_lines' => FormatHelper::customerAddressLines($payment), | |
| 929 | 1269 | 'booking_ref' => $bookingRef, |
| 930 | 1270 | 'booking_date' => $bookingDate, |
| 931 | 1271 | 'booking_status' => ucfirst($payment->status ?? 'confirmed'), |
| 932 | 1272 | 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' : |
| @@ -931,9 +1271,20 @@ | ||
| 931 | 1271 | 'booking_status' => ucfirst($payment->status ?? 'confirmed'), |
| 932 | 1272 | 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' : |
| 933 | 1273 | (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'), |
| 934 | 1274 | 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')), |
| 935 | - 'trip_duration' => $trip && $trip->duration ? sprintf(__('%d days', 'yatra'), (int) $trip->duration) : '', | |
| 1275 | + // Trip duration: prefer the duration columns joined onto the payment | |
| 1276 | + // row (always present, even if the trip was later soft-deleted), | |
| 1277 | + // falling back to the loaded trip. There is no `duration` column. | |
| 1278 | + 'trip_duration' => yatra_format_duration( | |
| 1279 | + (int) ($payment->trip_duration_days ?? ($trip->duration_days ?? 0)), | |
| 1280 | + isset($payment->trip_duration_nights) | |
| 1281 | + ? (int) $payment->trip_duration_nights | |
| 1282 | + : (isset($trip->duration_nights) ? (int) $trip->duration_nights : null), | |
| 1283 | + // Hour-based day tours: hours come from the loaded trip (the payment | |
| 1284 | + // join carries only days/nights); a soft-deleted trip falls back to days. | |
| 1285 | + (int) ($trip->duration_hours ?? 0) | |
| 1286 | + ), | |
| 936 | 1287 | 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '', |
| 937 | 1288 | 'departure_location' => $trip ? ($trip->departure_location ?? '') : '', |
| 938 | 1289 | 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''), |
| 939 | 1290 | 'travel_date' => $travelDate, |
| @@ -938,11 +1289,11 @@ | ||
| 938 | 1289 | 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''), |
| 939 | 1290 | 'travel_date' => $travelDate, |
| 940 | 1291 | 'return_date' => $returnDate, |
| 941 | 1292 | 'currency_symbol' => $currencySymbol, |
| 942 | - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2), | |
| 943 | - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2), | |
| 944 | - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2), | |
| 1293 | + 'total_amount' => yatra_format_price((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), $currency, false), | |
| 1294 | + 'amount_paid' => yatra_format_price((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), $currency, false), | |
| 1295 | + 'amount_due' => yatra_format_price((float) ($payment->booking_amount_due ?? 0), $currency, false), | |
| 945 | 1296 | 'traveler_count' => (int) ($payment->traveler_count ?? 1), |
| 946 | 1297 | ]; |
| 947 | 1298 | |
| 948 | 1299 | $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/voucher.php', $templateData, [ |
| @@ -998,90 +1349,137 @@ | ||
| 998 | 1349 | if ($bookingUserId && $currentUserId !== $bookingUserId && !current_user_can('manage_options')) { |
| 999 | 1350 | return new WP_Error('forbidden', __('You do not have permission to access this itinerary.', 'yatra'), ['status' => 403]); |
| 1000 | 1351 | } |
| 1001 | 1352 | |
| 1002 | - // Get trip details if available | |
| 1003 | - $trip = null; | |
| 1004 | - if (!empty($payment->trip_id)) { | |
| 1005 | - $trip = $this->tripRepository->find((int) $payment->trip_id); | |
| 1353 | + // Delegate all the template-data composition + PDF rendering to | |
| 1354 | + // the shared ItineraryPdfBuilder so the booking-side path | |
| 1355 | + // (BookingsController::renderItineraryFromBookingData) and this | |
| 1356 | + // payment-side path produce IDENTICAL PDFs from the same input. | |
| 1357 | + $builder = new \Yatra\Services\ItineraryPdfBuilder(); | |
| 1358 | + if (!$builder->pdfService()->isAvailable()) { | |
| 1359 | + return new WP_Error( | |
| 1360 | + 'pdf_engine_missing', | |
| 1361 | + __('Itinerary PDF generator is not installed. Please run composer install to install dompdf/dompdf.', 'yatra'), | |
| 1362 | + ['status' => 500] | |
| 1363 | + ); | |
| 1006 | 1364 | } |
| 1007 | 1365 | |
| 1008 | - // Get company settings | |
| 1009 | - $companyName = SettingsService::get('company_name', get_bloginfo('name')); | |
| 1010 | - $companyAddress = SettingsService::get('company_address', ''); | |
| 1011 | - $companyEmail = SettingsService::get('company_email', get_option('admin_email')); | |
| 1012 | - $companyPhone = SettingsService::get('company_phone', ''); | |
| 1013 | - $currency = SettingsService::getCurrency(); | |
| 1014 | - $currencySymbol = FormatHelper::getCurrencySymbol($currency); | |
| 1015 | - | |
| 1016 | - // Format dates | |
| 1017 | - $bookingDate = !empty($payment->created_at) ? date_i18n(get_option('date_format'), strtotime($payment->created_at)) : ''; | |
| 1018 | - $travelDate = !empty($payment->travel_date) ? date_i18n(get_option('date_format'), strtotime($payment->travel_date)) : ''; | |
| 1019 | - | |
| 1020 | - // Calculate return date if duration is available | |
| 1021 | - $returnDate = ''; | |
| 1022 | - if (!empty($payment->travel_date) && !empty($trip->duration ?? 0)) { | |
| 1023 | - $returnTimestamp = strtotime($payment->travel_date . ' +' . (int) ($trip->duration ?? 0) . ' days'); | |
| 1024 | - $returnDate = date_i18n(get_option('date_format'), $returnTimestamp); | |
| 1025 | - } | |
| 1026 | - | |
| 1027 | - // Generate booking reference | |
| 1028 | - $bookingRef = ''; | |
| 1029 | - if (!empty($payment->booking_id)) { | |
| 1030 | - $bookingRef = 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT)); | |
| 1031 | - } | |
| 1032 | - | |
| 1033 | - // Generate PDF using PDF service | |
| 1034 | - $pdfService = new PdfService(); | |
| 1366 | + $bookingRef = !empty($payment->booking_id) | |
| 1367 | + ? 'YTR-' . strtoupper(str_pad((string) $payment->booking_id, 8, '0', STR_PAD_LEFT)) | |
| 1368 | + : 'PENDING'; | |
| 1035 | 1369 | $filename = 'Travel-Itinerary-' . $bookingRef . '.pdf'; |
| 1036 | 1370 | |
| 1037 | - // Prepare template data with null-safe access | |
| 1038 | - $templateData = [ | |
| 1039 | - 'company_name' => $companyName, | |
| 1040 | - 'company_address' => $companyAddress, | |
| 1041 | - 'company_email' => $companyEmail, | |
| 1042 | - 'company_phone' => $companyPhone, | |
| 1043 | - 'customer_name' => trim(($payment->contact_first_name ?? '') . ' ' . ($payment->contact_last_name ?? '')) ?: ($payment->customer_name ?? __('Customer', 'yatra')), | |
| 1044 | - 'customer_email' => $payment->contact_email ?? $payment->customer_email ?? '', | |
| 1045 | - 'booking_ref' => $bookingRef, | |
| 1046 | - 'booking_date' => $bookingDate, | |
| 1047 | - 'booking_status' => ucfirst($payment->status ?? 'confirmed'), | |
| 1048 | - 'status_class' => in_array(strtolower((string) ($payment->status ?? '')), ['confirmed', 'completed', 'success'], true) ? 'confirmed' : | |
| 1049 | - (in_array(strtolower((string) ($payment->status ?? '')), ['cancelled'], true) ? 'cancelled' : 'pending'), | |
| 1050 | - 'trip_title' => $trip ? ($trip->title ?? $payment->trip_title ?? __('Trip Booking', 'yatra')) : ($payment->trip_title ?? __('Trip Booking', 'yatra')), | |
| 1051 | - 'trip_description' => $trip ? ($trip->description ?? $trip->content ?? '') : '', | |
| 1052 | - 'trip_duration' => $trip && $trip->duration ? sprintf(__('%d days', 'yatra'), (int) $trip->duration) : '', | |
| 1053 | - 'trip_difficulty' => $trip ? ($trip->difficulty_name ?? '') : '', | |
| 1054 | - 'trip_highlights' => $trip ? ($trip->highlights ?? $trip->trip_highlights ?? '') : '', | |
| 1055 | - 'trip_includes' => $trip ? ($trip->includes ?? $trip->trip_includes ?? '') : '', | |
| 1056 | - 'trip_excludes' => $trip ? ($trip->excludes ?? $trip->trip_excludes ?? '') : '', | |
| 1057 | - 'departure_location' => $trip ? ($trip->departure_location ?? '') : '', | |
| 1058 | - 'destination' => $trip ? ($trip->destination ?? $payment->destination ?? '') : ($payment->destination ?? ''), | |
| 1059 | - 'travel_date' => $travelDate, | |
| 1060 | - 'return_date' => $returnDate, | |
| 1061 | - 'currency_symbol' => $currencySymbol, | |
| 1062 | - 'total_amount' => number_format((float) ($payment->booking_total_amount ?? $payment->amount ?? 0), 2), | |
| 1063 | - 'amount_paid' => number_format((float) ($payment->booking_amount_paid ?? $payment->amount ?? 0), 2), | |
| 1064 | - 'amount_due' => number_format((float) ($payment->booking_amount_due ?? 0), 2), | |
| 1065 | - 'traveler_count' => (int) ($payment->traveler_count ?? 1), | |
| 1066 | - ]; | |
| 1371 | + $pdfBinary = $builder->buildFromPaymentRecord($payment); | |
| 1067 | 1372 | |
| 1068 | - $pdfBinary = $pdfService->renderTemplateToPdfSafely('pdf/itinerary.php', $templateData, [ | |
| 1069 | - 'paper' => 'A4', | |
| 1070 | - 'orientation' => 'portrait', | |
| 1071 | - 'default_font' => 'DejaVu Sans', | |
| 1072 | - ]); | |
| 1073 | - | |
| 1074 | 1373 | if ($isPreview) { |
| 1075 | - // For preview, return PDF as inline display | |
| 1076 | 1374 | return new WP_REST_Response([ |
| 1077 | 1375 | 'success' => true, |
| 1078 | 1376 | 'pdf_data' => base64_encode($pdfBinary), |
| 1079 | 1377 | 'filename' => $filename, |
| 1080 | 1378 | ]); |
| 1081 | - } else { | |
| 1082 | - // For download, output PDF as download | |
| 1083 | - $pdfService->outputPdfDownload($pdfBinary, $filename); | |
| 1084 | - exit; | |
| 1085 | 1379 | } |
| 1380 | + | |
| 1381 | + $builder->pdfService()->outputPdfDownload($pdfBinary, $filename); | |
| 1382 | + exit; | |
| 1383 | + } | |
| 1384 | + | |
| 1385 | + /** | |
| 1386 | + * Default invoice-token TTL — 1 year. Customers download invoices | |
| 1387 | + * for tax/expense reports months later, so a short TTL would hurt | |
| 1388 | + * legitimate use. The TTL is still meaningful as defense-in-depth: | |
| 1389 | + * a leaked link (forwarded email, posted in a help-desk ticket, | |
| 1390 | + * cached by a public mail relay) eventually expires. | |
| 1391 | + * | |
| 1392 | + * Filterable via `yatra_invoice_token_ttl_seconds` so operators | |
| 1393 | + * can tighten or loosen on a per-site basis. | |
| 1394 | + */ | |
| 1395 | + private const INVOICE_TOKEN_DEFAULT_TTL = 365 * 86400; | |
| 1396 | + | |
| 1397 | + /** | |
| 1398 | + * Issue a stateless, signed token that grants access to a single | |
| 1399 | + * payment's invoice. v2 format embeds an issued-at timestamp so | |
| 1400 | + * tokens have a defined expiry window — older v1 tokens (no | |
| 1401 | + * expiry component) are still honored by verifyInvoiceToken() so | |
| 1402 | + * pre-existing confirmation emails don't break. | |
| 1403 | + * | |
| 1404 | + * v2 format: `v2.<iat>.<hmac>` where hmac signs `paymentId|bookingId|iat`. | |
| 1405 | + * v1 format: bare `<hmac>` over `paymentId|bookingId` (legacy). | |
| 1406 | + * | |
| 1407 | + * The token is bound to the payment id + booking id and signed | |
| 1408 | + * with the WP auth salt, so it cannot be forged without the site | |
| 1409 | + * secret. It is safe to embed in the confirmation page link so | |
| 1410 | + * guests (or users who logged out after checkout) can still | |
| 1411 | + * download their invoice without a session. | |
| 1412 | + */ | |
| 1413 | + public static function issueInvoiceToken(int $paymentId, int $bookingId): string | |
| 1414 | + { | |
| 1415 | + // $paymentId === 0 denotes a booking-scoped (pro-forma) invoice token — | |
| 1416 | + // used for offline/unpaid bookings that have no payment row yet. | |
| 1417 | + if ($paymentId < 0 || $bookingId <= 0) { | |
| 1418 | + return ''; | |
| 1419 | + } | |
| 1420 | + $iat = time(); | |
| 1421 | + $hmac = hash_hmac( | |
| 1422 | + 'sha256', | |
| 1423 | + $paymentId . '|' . $bookingId . '|' . $iat, | |
| 1424 | + wp_salt('auth') . '|yatra_invoice' | |
| 1425 | + ); | |
| 1426 | + return 'v2.' . $iat . '.' . $hmac; | |
| 1427 | + } | |
| 1428 | + | |
| 1429 | + /** | |
| 1430 | + * Verify a token previously issued by self::issueInvoiceToken(). | |
| 1431 | + * | |
| 1432 | + * Accepts both formats: | |
| 1433 | + * - v2 (`v2.<iat>.<hmac>`): validates HMAC + checks token age | |
| 1434 | + * against the configured TTL. | |
| 1435 | + * - v1 (bare hmac, no expiry): legacy tokens already in the | |
| 1436 | + * wild via prior confirmation emails. We accept them | |
| 1437 | + * indefinitely — those URLs were already issued and revoking | |
| 1438 | + * them now would break existing customer bookmarks. | |
| 1439 | + */ | |
| 1440 | + public static function verifyInvoiceToken(string $token, int $paymentId, int $bookingId): bool | |
| 1441 | + { | |
| 1442 | + // $paymentId === 0 = booking-scoped (pro-forma) token; see issueInvoiceToken(). | |
| 1443 | + if ($token === '' || $paymentId < 0 || $bookingId <= 0) { | |
| 1444 | + return false; | |
| 1445 | + } | |
| 1446 | + | |
| 1447 | + // v2 path — token starts with the version prefix. | |
| 1448 | + if (strncmp($token, 'v2.', 3) === 0) { | |
| 1449 | + $parts = explode('.', $token); | |
| 1450 | + if (\count($parts) !== 3) return false; | |
| 1451 | + $iatStr = $parts[1]; | |
| 1452 | + $providedHmac = $parts[2]; | |
| 1453 | + if (!ctype_digit($iatStr)) return false; | |
| 1454 | + $iat = (int) $iatStr; | |
| 1455 | + | |
| 1456 | + $expectedHmac = hash_hmac( | |
| 1457 | + 'sha256', | |
| 1458 | + $paymentId . '|' . $bookingId . '|' . $iat, | |
| 1459 | + wp_salt('auth') . '|yatra_invoice' | |
| 1460 | + ); | |
| 1461 | + if (!hash_equals($expectedHmac, $providedHmac)) { | |
| 1462 | + return false; | |
| 1463 | + } | |
| 1464 | + | |
| 1465 | + $ttl = (int) apply_filters( | |
| 1466 | + 'yatra_invoice_token_ttl_seconds', | |
| 1467 | + self::INVOICE_TOKEN_DEFAULT_TTL | |
| 1468 | + ); | |
| 1469 | + if ($ttl > 0 && (time() - $iat) > $ttl) { | |
| 1470 | + return false; | |
| 1471 | + } | |
| 1472 | + return true; | |
| 1473 | + } | |
| 1474 | + | |
| 1475 | + // v1 legacy path — bare HMAC over (paymentId|bookingId). | |
| 1476 | + // Kept for confirmation emails already sent before the v2 | |
| 1477 | + // upgrade landed. New code paths always issue v2. | |
| 1478 | + $expectedLegacy = hash_hmac( | |
| 1479 | + 'sha256', | |
| 1480 | + $paymentId . '|' . $bookingId, | |
| 1481 | + wp_salt('auth') . '|yatra_invoice' | |
| 1482 | + ); | |
| 1483 | + return hash_equals($expectedLegacy, $token); | |
| 1086 | 1484 | } |
| 1087 | 1485 | } |