| @@ -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,8 +280,13 @@ | ||
| 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 | 291 | 'message' => sprintf( |
| 171 | 292 | /* translators: %s: new payment status. */ |
| @@ -327,8 +448,14 @@ | ||
| 327 | 448 | 'trip_title' => $payment->trip_title ?? null, |
| 328 | 449 | 'transaction_id' => $payment->transaction_id, |
| 329 | 450 | 'gateway' => $payment->gateway, |
| 330 | 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), | |
| 331 | 458 | 'amount' => (float) $payment->amount, |
| 332 | 459 | 'currency' => $payment->currency, |
| 333 | 460 | 'status' => $payment->status, |
| 334 | 461 | 'payment_status' => $status, |
| @@ -347,10 +474,48 @@ | ||
| 347 | 474 | // Keeping the existing payment_* fields preserves any other |
| 348 | 475 | // consumer that reads them. |
| 349 | 476 | 'reference' => $reference, |
| 350 | 477 | 'method' => $gateway, |
| 478 | + 'method_label' => self::gatewayLabel($gateway), | |
| 351 | 479 | 'date' => $paymentDate, |
| 352 | 480 | 'type' => $payment->payment_type, |
| 353 | 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)); | |
| 354 | 519 | } |
| 355 | 520 | } |
| 356 | 521 | |