| @@ -84,8 +84,31 @@ | ||
| 84 | 84 | if (!$booking) { |
| 85 | 85 | return ['success' => false, 'message' => __('Booking not found.', 'yatra')]; |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | + // The payments table's status column is an enum; a value outside it | |
| 89 | + // (the admin form used to offer "partial") was stored as '' on lenient | |
| 90 | + // MySQL and rejected outright on strict mode — either way the payment | |
| 91 | + // never counted towards the booking. Refuse it up front instead. | |
| 92 | + $data = self::normalizeStatus($data); | |
| 93 | + if (isset($data['status']) && !self::isValidStatus($data['status'])) { | |
| 94 | + return ['success' => false, 'message' => __('Invalid status.', 'yatra')]; | |
| 95 | + } | |
| 96 | + | |
| 97 | + // A payment recorded by hand carries no currency, and both the payments and | |
| 98 | + // bookings tables declare `currency char(3) DEFAULT 'USD'` — so on a Euro | |
| 99 | + // store a manual payment was saved as USD and listed with a dollar sign | |
| 100 | + // beside correctly-formatted euro rows. | |
| 101 | + // | |
| 102 | + // The store currency is the source of truth here, not the booking's own | |
| 103 | + // column: that column is only written when a caller passes it, so it silently | |
| 104 | + // inherits the same 'USD' default and would just propagate the wrong value. | |
| 105 | + // Multi-currency is not a shipped feature, so every amount is in the store | |
| 106 | + // currency by definition. | |
| 107 | + if (empty($data['currency'])) { | |
| 108 | + $data['currency'] = SettingsService::getCurrency(); | |
| 109 | + } | |
| 110 | + | |
| 88 | 111 | // Create payment |
| 89 | 112 | $paymentId = $this->paymentRepository->create($data); |
| 90 | 113 | |
| 91 | 114 | if (!$paymentId) { |
| @@ -91,12 +114,21 @@ | ||
| 91 | 114 | if (!$paymentId) { |
| 92 | 115 | return ['success' => false, 'message' => __('Failed to create payment.', 'yatra')]; |
| 93 | 116 | } |
| 94 | 117 | |
| 95 | - // Update booking amount paid | |
| 118 | + // Update booking amount paid (also refreshes amount_due + payment_status, | |
| 119 | + // so the notification below picks the correct part/full template). | |
| 96 | 120 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $data['booking_id']); |
| 97 | 121 | $this->bookingRepository->updateAmountPaid((int) $data['booking_id'], $totalPaid); |
| 98 | 122 | |
| 123 | + // A payment recorded as completed is money actually received: dispatch | |
| 124 | + // it exactly like a gateway capture (emails + payment.received / | |
| 125 | + // payment.partial_received automations, webhooks, …). Pending / failed / | |
| 126 | + // refunded records are bookkeeping only. | |
| 127 | + if (($data['status'] ?? '') === 'completed') { | |
| 128 | + $this->dispatchPaymentCompleted((int) $paymentId, $data); | |
| 129 | + } | |
| 130 | + | |
| 99 | 131 | return [ |
| 100 | 132 | 'success' => true, |
| 101 | 133 | 'payment_id' => $paymentId, |
| 102 | 134 | 'message' => __('Payment recorded successfully.', 'yatra'), |
| @@ -103,8 +135,78 @@ | ||
| 103 | 135 | ]; |
| 104 | 136 | } |
| 105 | 137 | |
| 106 | 138 | /** |
| 139 | + * Statuses the payments table accepts (its `status` enum). | |
| 140 | + */ | |
| 141 | + public static function isValidStatus(string $status): bool | |
| 142 | + { | |
| 143 | + return in_array($status, ['pending', 'completed', 'failed', 'refunded', 'cancelled'], true); | |
| 144 | + } | |
| 145 | + | |
| 146 | + /** | |
| 147 | + * A null / blank status means "not specified": drop the key so the table | |
| 148 | + * default (create) or the current value (update) applies, instead of the | |
| 149 | + * repository writing '' into the enum column. Otherwise lower-case it. | |
| 150 | + * | |
| 151 | + * @param array<string, mixed> $data | |
| 152 | + * @return array<string, mixed> | |
| 153 | + */ | |
| 154 | + private static function normalizeStatus(array $data): array | |
| 155 | + { | |
| 156 | + if (!array_key_exists('status', $data)) { | |
| 157 | + return $data; | |
| 158 | + } | |
| 159 | + $status = strtolower(trim((string) $data['status'])); | |
| 160 | + if ($status === '') { | |
| 161 | + unset($data['status']); | |
| 162 | + } else { | |
| 163 | + $data['status'] = $status; | |
| 164 | + } | |
| 165 | + | |
| 166 | + return $data; | |
| 167 | + } | |
| 168 | + | |
| 169 | + /** | |
| 170 | + * Fire `yatra_payment_completed` for a manually recorded payment. | |
| 171 | + * | |
| 172 | + * Same action and array payload the gateways fire after a capture, so the | |
| 173 | + * existing listeners do the rest: core sends the customer/admin "payment | |
| 174 | + * received" emails (NotificationHooks), Pro Email Automation fires | |
| 175 | + * `payment.partial_received` or `payment.received` from the booking's | |
| 176 | + * remaining balance, webhooks / WhatsApp deliver the matching event, and | |
| 177 | + * Scheduled Payments retires pending charges when this settles the balance. | |
| 178 | + * Called only when a payment BECOMES completed, never on a re-save, so | |
| 179 | + * nothing is sent twice. The pre-existing `yatra_send_manual_payment_emails` | |
| 180 | + * filter still lets an operator keep the emails off; the event itself is | |
| 181 | + * always fired. | |
| 182 | + * | |
| 183 | + * @param array<string, mixed> $data The payment row (or the create payload). | |
| 184 | + */ | |
| 185 | + private function dispatchPaymentCompleted(int $paymentId, array $data): void | |
| 186 | + { | |
| 187 | + $bookingId = (int) ($data['booking_id'] ?? 0); | |
| 188 | + if ($bookingId <= 0) { | |
| 189 | + return; | |
| 190 | + } | |
| 191 | + | |
| 192 | + $sendEmails = (bool) apply_filters('yatra_send_manual_payment_emails', true, $bookingId, $data); | |
| 193 | + $gateway = (string) ($data['gateway'] ?? ($data['payment_method'] ?? '')); | |
| 194 | + | |
| 195 | + do_action('yatra_payment_completed', [ | |
| 196 | + 'booking_id' => $bookingId, | |
| 197 | + 'payment_id' => $paymentId, | |
| 198 | + 'amount' => (float) ($data['amount'] ?? 0), | |
| 199 | + 'currency' => (string) ($data['currency'] ?? SettingsService::getCurrency()), | |
| 200 | + 'gateway' => $gateway, | |
| 201 | + 'payment_method' => $gateway, | |
| 202 | + 'transaction_id' => (string) ($data['transaction_id'] ?? ''), | |
| 203 | + 'source' => 'manual', | |
| 204 | + 'send_emails' => $sendEmails, | |
| 205 | + ]); | |
| 206 | + } | |
| 207 | + | |
| 208 | + /** | |
| 107 | 209 | * Update a payment |
| 108 | 210 | * |
| 109 | 211 | * @param int $id Payment ID |
| 110 | 212 | * @param array $data Payment data |
| @@ -117,8 +219,13 @@ | ||
| 117 | 219 | if (!$payment) { |
| 118 | 220 | return ['success' => false, 'message' => __('Payment not found.', 'yatra')]; |
| 119 | 221 | } |
| 120 | 222 | |
| 223 | + $data = self::normalizeStatus($data); | |
| 224 | + if (isset($data['status']) && !self::isValidStatus($data['status'])) { | |
| 225 | + return ['success' => false, 'message' => __('Invalid status.', 'yatra')]; | |
| 226 | + } | |
| 227 | + | |
| 121 | 228 | $updated = $this->paymentRepository->update($id, $data); |
| 122 | 229 | |
| 123 | 230 | if (!$updated) { |
| 124 | 231 | return ['success' => false, 'message' => __('Failed to update payment.', 'yatra')]; |
| @@ -127,8 +234,17 @@ | ||
| 127 | 234 | // Recalculate booking amount paid |
| 128 | 235 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id); |
| 129 | 236 | $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid); |
| 130 | 237 | |
| 238 | + // Edited from pending/failed/… to completed → the money has now been | |
| 239 | + // received; dispatch once, on that transition only. | |
| 240 | + $wasCompleted = (string) ($payment->status ?? '') === 'completed'; | |
| 241 | + $isCompleted = (string) ($data['status'] ?? $payment->status ?? '') === 'completed'; | |
| 242 | + if ($isCompleted && !$wasCompleted) { | |
| 243 | + $updatedRow = $this->paymentRepository->find($id); | |
| 244 | + $this->dispatchPaymentCompleted($id, $updatedRow ? (array) $updatedRow : array_merge((array) $payment, $data)); | |
| 245 | + } | |
| 246 | + | |
| 131 | 247 | return [ |
| 132 | 248 | 'success' => true, |
| 133 | 249 | 'message' => __('Payment updated successfully.', 'yatra'), |
| 134 | 250 | ]; |
| @@ -164,11 +280,20 @@ | ||
| 164 | 280 | // Recalculate booking amount paid |
| 165 | 281 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id); |
| 166 | 282 | $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid); |
| 167 | 283 | |
| 284 | + // "Mark as Completed" on a pending payment = the money arrived. | |
| 285 | + if ($status === 'completed' && (string) ($payment->status ?? '') !== 'completed') { | |
| 286 | + $this->dispatchPaymentCompleted($id, (array) $payment); | |
| 287 | + } | |
| 288 | + | |
| 168 | 289 | return [ |
| 169 | 290 | 'success' => true, |
| 170 | - 'message' => sprintf(__('Payment status updated to %s.', 'yatra'), $status), | |
| 291 | + 'message' => sprintf( | |
| 292 | + /* translators: %s: new payment status. */ | |
| 293 | + __('Payment status updated to %s.', 'yatra'), | |
| 294 | + $status | |
| 295 | + ), | |
| 171 | 296 | ]; |
| 172 | 297 | } |
| 173 | 298 | |
| 174 | 299 | /** |
| @@ -281,9 +406,18 @@ | ||
| 281 | 406 | * |
| 282 | 407 | * @param object $payment Raw payment data |
| 283 | 408 | * @return array |
| 284 | 409 | */ |
| 285 | - private function formatPayment(object $payment): array | |
| 410 | + /** | |
| 411 | + * Format a raw payment row for the REST API. | |
| 412 | + * | |
| 413 | + * `public` so other services (notably {@see \Yatra\Services\CustomerService::getPaymentsForBookingIds()}) | |
| 414 | + * can share the same formatter and we don't end up with two competing | |
| 415 | + * shapes — that's how the Account → Payments tab used to render | |
| 416 | + * blank/N/A for `date`, `method`, `reference`, and `type` even after the | |
| 417 | + * formatter here was updated. | |
| 418 | + */ | |
| 419 | + public function formatPayment(object $payment): array | |
| 286 | 420 | { |
| 287 | 421 | $contactName = isset($payment->contact_first_name) |
| 288 | 422 | ? trim($payment->contact_first_name . ' ' . ($payment->contact_last_name ?? '')) |
| 289 | 423 | : null; |
| @@ -294,8 +428,13 @@ | ||
| 294 | 428 | $processedAt = $payment->processed_at ?? null; |
| 295 | 429 | $createdAt = $payment->created_at ?? null; |
| 296 | 430 | $paymentDate = ($processedAt !== null && $processedAt !== '') ? $processedAt : ($createdAt ?? ''); |
| 297 | 431 | |
| 432 | + // Build a human-readable payment reference once, then expose it under | |
| 433 | + // both `payment_number` (canonical) and `reference` (what the React | |
| 434 | + // Payment type at resources/js/pages/account/types.ts expects). | |
| 435 | + $reference = sprintf('PAY-%06d', (int) $payment->id); | |
| 436 | + | |
| 298 | 437 | return [ |
| 299 | 438 | 'id' => (int) $payment->id, |
| 300 | 439 | 'booking_id' => (int) $payment->booking_id, |
| 301 | 440 | 'booking_reference' => $bookingRef, |
| @@ -309,8 +448,14 @@ | ||
| 309 | 448 | 'trip_title' => $payment->trip_title ?? null, |
| 310 | 449 | 'transaction_id' => $payment->transaction_id, |
| 311 | 450 | 'gateway' => $payment->gateway, |
| 312 | 451 | 'payment_method' => $gateway, |
| 452 | + // Display label resolved from the gateway registry. `payment_method` | |
| 453 | + // stays the raw stored value because the list filter posts it back as | |
| 454 | + // `gateway`; this is purely what the UI should print. Without it the | |
| 455 | + // list mixed registry slugs ("paypal") with whatever a manually added | |
| 456 | + // payment happened to store ("PayPal", "Credit Card"). | |
| 457 | + 'payment_method_label' => self::gatewayLabel($gateway), | |
| 313 | 458 | 'amount' => (float) $payment->amount, |
| 314 | 459 | 'currency' => $payment->currency, |
| 315 | 460 | 'status' => $payment->status, |
| 316 | 461 | 'payment_status' => $status, |
| @@ -318,9 +463,59 @@ | ||
| 318 | 463 | 'notes' => $payment->notes, |
| 319 | 464 | 'processed_at' => $payment->processed_at, |
| 320 | 465 | 'created_at' => $payment->created_at, |
| 321 | 466 | 'payment_date' => $paymentDate, |
| 322 | - 'payment_number' => sprintf('PAY-%06d', (int) $payment->id), | |
| 467 | + 'payment_number' => $reference, | |
| 468 | + // Aliases for the React Payment interface (account page). | |
| 469 | + // Without these, the payments tab rendered: | |
| 470 | + // - reference: undefined → blank line above "Booking:" label | |
| 471 | + // - method: undefined → blank under "Payment Method" | |
| 472 | + // - date: undefined → formatDate(undefined) → "N/A" | |
| 473 | + // - type: undefined → paymentTypeLabel(undefined) → empty | |
| 474 | + // Keeping the existing payment_* fields preserves any other | |
| 475 | + // consumer that reads them. | |
| 476 | + 'reference' => $reference, | |
| 477 | + 'method' => $gateway, | |
| 478 | + 'method_label' => self::gatewayLabel($gateway), | |
| 479 | + 'date' => $paymentDate, | |
| 480 | + 'type' => $payment->payment_type, | |
| 323 | 481 | ]; |
| 482 | + } | |
| 483 | + | |
| 484 | + /** | |
| 485 | + * Human label for a stored gateway value. | |
| 486 | + * | |
| 487 | + * Resolves through the gateway registry first so the wording matches what the | |
| 488 | + * customer saw at checkout (and what the payment emails print — see | |
| 489 | + * BookingEmailRichMergeTags::gatewayLabel). Values recorded by hand may already | |
| 490 | + * be human ("Credit Card"), so anything unregistered is title-cased rather than | |
| 491 | + * discarded. | |
| 492 | + * | |
| 493 | + * @param string $gateway | |
| 494 | + * @return string | |
| 495 | + */ | |
| 496 | + private static function gatewayLabel(string $gateway): string | |
| 497 | + { | |
| 498 | + $gateway = trim($gateway); | |
| 499 | + if ($gateway === '') { | |
| 500 | + return ''; | |
| 501 | + } | |
| 502 | + | |
| 503 | + if (class_exists(\Yatra\PaymentGateways\PaymentGatewayRegistry::class)) { | |
| 504 | + try { | |
| 505 | + $registered = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance()->get(strtolower($gateway)); | |
| 506 | + if ($registered !== null) { | |
| 507 | + $title = trim((string) $registered->getTitle()); | |
| 508 | + if ($title !== '') { | |
| 509 | + return $title; | |
| 510 | + } | |
| 511 | + } | |
| 512 | + } catch (\Throwable $e) { | |
| 513 | + // Registry unavailable (e.g. called before gateways register) — | |
| 514 | + // fall through to the humanized form rather than failing the list. | |
| 515 | + } | |
| 516 | + } | |
| 517 | + | |
| 518 | + return ucwords(str_replace(['_', '-'], ' ', $gateway)); | |
| 324 | 519 | } |
| 325 | 520 | } |
| 326 | 521 | |