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 +199 -4 3.0.4 → 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,11 +280,20 @@
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 - 'message' => sprintf(__('Payment status updated to %s.', 'yatra'), $status),
291 + 'message' => sprintf(
292 + /* translators: %s: new payment status. */
293 + __('Payment status updated to %s.', 'yatra'),
294 + $status
295 + ),
171 296 ];
172 297 }
173 298
174 299 /**
@@ -281,9 +406,18 @@
281 406 *
282 407 * @param object $payment Raw payment data
283 408 * @return array
284 409 */
285 - private function formatPayment(object $payment): array
410 + /**
411 + * Format a raw payment row for the REST API.
412 + *
413 + * `public` so other services (notably {@see \Yatra\Services\CustomerService::getPaymentsForBookingIds()})
414 + * can share the same formatter and we don't end up with two competing
415 + * shapes — that's how the Account → Payments tab used to render
416 + * blank/N/A for `date`, `method`, `reference`, and `type` even after the
417 + * formatter here was updated.
418 + */
419 + public function formatPayment(object $payment): array
286 420 {
287 421 $contactName = isset($payment->contact_first_name)
288 422 ? trim($payment->contact_first_name . ' ' . ($payment->contact_last_name ?? ''))
289 423 : null;
@@ -294,8 +428,13 @@
294 428 $processedAt = $payment->processed_at ?? null;
295 429 $createdAt = $payment->created_at ?? null;
296 430 $paymentDate = ($processedAt !== null && $processedAt !== '') ? $processedAt : ($createdAt ?? '');
297 431
432 + // Build a human-readable payment reference once, then expose it under
433 + // both `payment_number` (canonical) and `reference` (what the React
434 + // Payment type at resources/js/pages/account/types.ts expects).
435 + $reference = sprintf('PAY-%06d', (int) $payment->id);
436 +
298 437 return [
299 438 'id' => (int) $payment->id,
300 439 'booking_id' => (int) $payment->booking_id,
301 440 'booking_reference' => $bookingRef,
@@ -309,8 +448,14 @@
309 448 'trip_title' => $payment->trip_title ?? null,
310 449 'transaction_id' => $payment->transaction_id,
311 450 'gateway' => $payment->gateway,
312 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),
313 458 'amount' => (float) $payment->amount,
314 459 'currency' => $payment->currency,
315 460 'status' => $payment->status,
316 461 'payment_status' => $status,
@@ -318,9 +463,59 @@
318 463 'notes' => $payment->notes,
319 464 'processed_at' => $payment->processed_at,
320 465 'created_at' => $payment->created_at,
321 466 'payment_date' => $paymentDate,
322 - 'payment_number' => sprintf('PAY-%06d', (int) $payment->id),
467 + 'payment_number' => $reference,
468 + // Aliases for the React Payment interface (account page).
469 + // Without these, the payments tab rendered:
470 + // - reference: undefined → blank line above "Booking:" label
471 + // - method: undefined → blank under "Payment Method"
472 + // - date: undefined → formatDate(undefined) → "N/A"
473 + // - type: undefined → paymentTypeLabel(undefined) → empty
474 + // Keeping the existing payment_* fields preserves any other
475 + // consumer that reads them.
476 + 'reference' => $reference,
477 + 'method' => $gateway,
478 + 'method_label' => self::gatewayLabel($gateway),
479 + 'date' => $paymentDate,
480 + 'type' => $payment->payment_type,
323 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));
324 519 }
325 520 }
326 521