PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Services/PaymentService.php +108 -1 3.0.13 → 3.0.16 View file →
@@ -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(