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 +166 -1 3.0.9 → 3.0.16 View file →
@@ -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