| @@ -84,8 +84,17 @@ | ||
| 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 | + | |
| 88 | 97 | // A payment recorded by hand carries no currency, and both the payments and |
| 89 | 98 | // bookings tables declare `currency char(3) DEFAULT 'USD'` — so on a Euro |
| 90 | 99 | // store a manual payment was saved as USD and listed with a dollar sign |
| 91 | 100 | // beside correctly-formatted euro rows. |
| @@ -105,12 +114,21 @@ | ||
| 105 | 114 | if (!$paymentId) { |
| 106 | 115 | return ['success' => false, 'message' => __('Failed to create payment.', 'yatra')]; |
| 107 | 116 | } |
| 108 | 117 | |
| 109 | - // 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). | |
| 110 | 120 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $data['booking_id']); |
| 111 | 121 | $this->bookingRepository->updateAmountPaid((int) $data['booking_id'], $totalPaid); |
| 112 | 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 | + | |
| 113 | 131 | return [ |
| 114 | 132 | 'success' => true, |
| 115 | 133 | 'payment_id' => $paymentId, |
| 116 | 134 | 'message' => __('Payment recorded successfully.', 'yatra'), |
| @@ -117,8 +135,78 @@ | ||
| 117 | 135 | ]; |
| 118 | 136 | } |
| 119 | 137 | |
| 120 | 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 | + /** | |
| 121 | 209 | * Update a payment |
| 122 | 210 | * |
| 123 | 211 | * @param int $id Payment ID |
| 124 | 212 | * @param array $data Payment data |
| @@ -131,8 +219,13 @@ | ||
| 131 | 219 | if (!$payment) { |
| 132 | 220 | return ['success' => false, 'message' => __('Payment not found.', 'yatra')]; |
| 133 | 221 | } |
| 134 | 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 | + | |
| 135 | 228 | $updated = $this->paymentRepository->update($id, $data); |
| 136 | 229 | |
| 137 | 230 | if (!$updated) { |
| 138 | 231 | return ['success' => false, 'message' => __('Failed to update payment.', 'yatra')]; |
| @@ -141,8 +234,17 @@ | ||
| 141 | 234 | // Recalculate booking amount paid |
| 142 | 235 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id); |
| 143 | 236 | $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid); |
| 144 | 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 | + | |
| 145 | 247 | return [ |
| 146 | 248 | 'success' => true, |
| 147 | 249 | 'message' => __('Payment updated successfully.', 'yatra'), |
| 148 | 250 | ]; |
| @@ -177,8 +279,13 @@ | ||
| 177 | 279 | |
| 178 | 280 | // Recalculate booking amount paid |
| 179 | 281 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id); |
| 180 | 282 | $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid); |
| 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 | + } | |
| 181 | 288 | |
| 182 | 289 | return [ |
| 183 | 290 | 'success' => true, |
| 184 | 291 | 'message' => sprintf( |