| @@ -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. |
| @@ -110,26 +119,14 @@ | ||
| 110 | 119 | // so the notification below picks the correct part/full template). |
| 111 | 120 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $data['booking_id']); |
| 112 | 121 | $this->bookingRepository->updateAmountPaid((int) $data['booking_id'], $totalPaid); |
| 113 | 122 | |
| 114 | - // Notify the customer AND the admin that a payment was received — mirrors | |
| 115 | - // the automated online-payment notifications, which this manual-entry | |
| 116 | - // path otherwise skips. Only when the entry represents money actually | |
| 117 | - // received (a completed payment); pending/failed/refunded records don't | |
| 118 | - // trigger a "payment received" email. Respects the payment-email template | |
| 119 | - // toggles (via sendIfEnabled) and is filterable so operators can opt out. | |
| 120 | - $status = strtolower(trim((string) ($data['status'] ?? ''))); | |
| 121 | - $isReceived = in_array($status, ['completed', 'paid', 'succeeded'], true); | |
| 122 | - if ( | |
| 123 | - $isReceived | |
| 124 | - && (bool) apply_filters('yatra_send_manual_payment_emails', true, (int) $data['booking_id'], $data) | |
| 125 | - ) { | |
| 126 | - NotificationService::sendPaymentCompletedNotification([ | |
| 127 | - 'booking_id' => (int) $data['booking_id'], | |
| 128 | - 'amount' => (float) ($data['amount'] ?? 0), | |
| 129 | - 'payment_method' => (string) ($data['gateway'] ?? ($data['payment_method'] ?? '')), | |
| 130 | - 'transaction_id' => (string) ($data['transaction_id'] ?? ''), | |
| 131 | - ]); | |
| 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); | |
| 132 | 129 | } |
| 133 | 130 | |
| 134 | 131 | return [ |
| 135 | 132 | 'success' => true, |
| @@ -138,8 +135,78 @@ | ||
| 138 | 135 | ]; |
| 139 | 136 | } |
| 140 | 137 | |
| 141 | 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 | + /** | |
| 142 | 209 | * Update a payment |
| 143 | 210 | * |
| 144 | 211 | * @param int $id Payment ID |
| 145 | 212 | * @param array $data Payment data |
| @@ -152,8 +219,13 @@ | ||
| 152 | 219 | if (!$payment) { |
| 153 | 220 | return ['success' => false, 'message' => __('Payment not found.', 'yatra')]; |
| 154 | 221 | } |
| 155 | 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 | + | |
| 156 | 228 | $updated = $this->paymentRepository->update($id, $data); |
| 157 | 229 | |
| 158 | 230 | if (!$updated) { |
| 159 | 231 | return ['success' => false, 'message' => __('Failed to update payment.', 'yatra')]; |
| @@ -162,8 +234,17 @@ | ||
| 162 | 234 | // Recalculate booking amount paid |
| 163 | 235 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id); |
| 164 | 236 | $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid); |
| 165 | 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 | + | |
| 166 | 247 | return [ |
| 167 | 248 | 'success' => true, |
| 168 | 249 | 'message' => __('Payment updated successfully.', 'yatra'), |
| 169 | 250 | ]; |
| @@ -198,8 +279,13 @@ | ||
| 198 | 279 | |
| 199 | 280 | // Recalculate booking amount paid |
| 200 | 281 | $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id); |
| 201 | 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 | + } | |
| 202 | 288 | |
| 203 | 289 | return [ |
| 204 | 290 | 'success' => true, |
| 205 | 291 | 'message' => sprintf( |