PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 2.0.10 2.0.11 All 82 releases
yatra / app / Services / PaymentService.php

PaymentService.php in Yatra – Travel Booking & Tour Operator Software trunk, at app/Services/PaymentService.php

522 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Repositories\PaymentRepository;
8 use Yatra\Repositories\BookingRepository;
9
10 /**
11 * Payment Service
12 *
13 * Contains business logic for payments.
14 *
15 * @package Yatra\Services
16 */
17 class PaymentService
18 {
19 private PaymentRepository $paymentRepository;
20 private BookingRepository $bookingRepository;
21
22 public function __construct()
23 {
24 $this->paymentRepository = new PaymentRepository();
25 $this->bookingRepository = new BookingRepository();
26 }
27
28 /**
29 * Get paginated payments
30 *
31 * @param array $filters Filters
32 * @return array
33 */
34 public function getPayments(array $filters = []): array
35 {
36 $result = $this->paymentRepository->paginate($filters);
37
38 $result['data'] = array_map([$this, 'formatPayment'], $result['data']);
39
40 return $result;
41 }
42
43 /**
44 * Get single payment
45 *
46 * @param int $id Payment ID
47 * @return array|null
48 */
49 public function getPayment(int $id): ?array
50 {
51 $payment = $this->paymentRepository->findWithBooking($id);
52
53 if (!$payment) {
54 return null;
55 }
56
57 return $this->formatPayment($payment);
58 }
59
60 /**
61 * Get payments for a booking
62 *
63 * @param int $bookingId Booking ID
64 * @return array
65 */
66 public function getBookingPayments(int $bookingId): array
67 {
68 $payments = $this->paymentRepository->findByBookingId($bookingId);
69
70 return array_map([$this, 'formatPayment'], $payments);
71 }
72
73 /**
74 * Create a new payment
75 *
76 * @param array $data Payment data
77 * @return array {success: bool, payment_id?: int, message: string}
78 */
79 public function createPayment(array $data): array
80 {
81 // Validate booking exists
82 $booking = $this->bookingRepository->find((int) $data['booking_id']);
83
84 if (!$booking) {
85 return ['success' => false, 'message' => __('Booking not found.', 'yatra')];
86 }
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
111 // Create payment
112 $paymentId = $this->paymentRepository->create($data);
113
114 if (!$paymentId) {
115 return ['success' => false, 'message' => __('Failed to create payment.', 'yatra')];
116 }
117
118 // Update booking amount paid (also refreshes amount_due + payment_status,
119 // so the notification below picks the correct part/full template).
120 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $data['booking_id']);
121 $this->bookingRepository->updateAmountPaid((int) $data['booking_id'], $totalPaid);
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
131 return [
132 'success' => true,
133 'payment_id' => $paymentId,
134 'message' => __('Payment recorded successfully.', 'yatra'),
135 ];
136 }
137
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 /**
209 * Update a payment
210 *
211 * @param int $id Payment ID
212 * @param array $data Payment data
213 * @return array {success: bool, message: string}
214 */
215 public function updatePayment(int $id, array $data): array
216 {
217 $payment = $this->paymentRepository->find($id);
218
219 if (!$payment) {
220 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
221 }
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
228 $updated = $this->paymentRepository->update($id, $data);
229
230 if (!$updated) {
231 return ['success' => false, 'message' => __('Failed to update payment.', 'yatra')];
232 }
233
234 // Recalculate booking amount paid
235 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id);
236 $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid);
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
247 return [
248 'success' => true,
249 'message' => __('Payment updated successfully.', 'yatra'),
250 ];
251 }
252
253 /**
254 * Update payment status
255 *
256 * @param int $id Payment ID
257 * @param string $status New status
258 * @return array {success: bool, message: string}
259 */
260 public function updateStatus(int $id, string $status): array
261 {
262 $validStatuses = ['pending', 'completed', 'failed', 'refunded', 'cancelled'];
263
264 if (!in_array($status, $validStatuses, true)) {
265 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
266 }
267
268 $payment = $this->paymentRepository->find($id);
269
270 if (!$payment) {
271 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
272 }
273
274 $updated = $this->paymentRepository->updateStatus($id, $status);
275
276 if (!$updated) {
277 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
278 }
279
280 // Recalculate booking amount paid
281 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id);
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 }
288
289 return [
290 'success' => true,
291 'message' => sprintf(
292 /* translators: %s: new payment status. */
293 __('Payment status updated to %s.', 'yatra'),
294 $status
295 ),
296 ];
297 }
298
299 /**
300 * Delete a payment
301 *
302 * @param int $id Payment ID
303 * @return array {success: bool, message: string}
304 */
305 public function deletePayment(int $id): array
306 {
307 $payment = $this->paymentRepository->find($id);
308
309 if (!$payment) {
310 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
311 }
312
313 $bookingId = (int) $payment->booking_id;
314
315 $deleted = $this->paymentRepository->delete($id);
316
317 if (!$deleted) {
318 return ['success' => false, 'message' => __('Failed to delete payment.', 'yatra')];
319 }
320
321 // Recalculate booking amount paid
322 $totalPaid = $this->paymentRepository->getTotalPaidForBooking($bookingId);
323 $this->bookingRepository->updateAmountPaid($bookingId, $totalPaid);
324
325 return [
326 'success' => true,
327 'message' => __('Payment deleted successfully.', 'yatra'),
328 ];
329 }
330
331 /**
332 * Get payment statistics
333 *
334 * @return array
335 */
336 public function getStats(): array
337 {
338 return $this->paymentRepository->getStats();
339 }
340
341 /**
342 * Admin toolbar: counts per payment status.
343 */
344 public function getAdminStatusCounts(): array
345 {
346 return $this->paymentRepository->getAdminStatusCounts();
347 }
348
349 /**
350 * Process refund
351 *
352 * @param int $paymentId Payment ID to refund
353 * @param float $amount Refund amount (optional, full refund if not provided)
354 * @param string $reason Refund reason
355 * @return array {success: bool, refund_id?: int, message: string}
356 */
357 public function processRefund(int $paymentId, ?float $amount = null, string $reason = ''): array
358 {
359 $payment = $this->paymentRepository->find($paymentId);
360
361 if (!$payment) {
362 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
363 }
364
365 if ($payment->status !== 'completed') {
366 return ['success' => false, 'message' => __('Only completed payments can be refunded.', 'yatra')];
367 }
368
369 $refundAmount = $amount ?? (float) $payment->amount;
370
371 if ($refundAmount > (float) $payment->amount) {
372 return ['success' => false, 'message' => __('Refund amount exceeds payment amount.', 'yatra')];
373 }
374
375 // Create refund record
376 $refundId = $this->paymentRepository->create([
377 'booking_id' => $payment->booking_id,
378 'gateway' => $payment->gateway,
379 'amount' => -$refundAmount, // Negative for refund
380 'currency' => $payment->currency,
381 'status' => 'completed',
382 'payment_type' => 'refund',
383 'notes' => $reason,
384 ]);
385
386 if (!$refundId) {
387 return ['success' => false, 'message' => __('Failed to process refund.', 'yatra')];
388 }
389
390 // Update original payment status
391 $this->paymentRepository->updateStatus($paymentId, 'refunded');
392
393 // Recalculate booking amount paid
394 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id);
395 $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid);
396
397 return [
398 'success' => true,
399 'refund_id' => $refundId,
400 'message' => __('Refund processed successfully.', 'yatra'),
401 ];
402 }
403
404 /**
405 * Format payment for API response
406 *
407 * @param object $payment Raw payment data
408 * @return array
409 */
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
420 {
421 $contactName = isset($payment->contact_first_name)
422 ? trim($payment->contact_first_name . ' ' . ($payment->contact_last_name ?? ''))
423 : null;
424
425 $status = (string) ($payment->status ?? 'pending');
426 $gateway = (string) ($payment->gateway ?? '');
427 $bookingRef = $payment->booking_reference ?? null;
428 $processedAt = $payment->processed_at ?? null;
429 $createdAt = $payment->created_at ?? null;
430 $paymentDate = ($processedAt !== null && $processedAt !== '') ? $processedAt : ($createdAt ?? '');
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
437 return [
438 'id' => (int) $payment->id,
439 'booking_id' => (int) $payment->booking_id,
440 'booking_reference' => $bookingRef,
441 'booking_number' => ($bookingRef !== null && $bookingRef !== '')
442 ? (string) $bookingRef
443 : '#' . (int) ($payment->booking_id ?? 0),
444 'contact_email' => $payment->contact_email ?? null,
445 'contact_name' => $contactName,
446 'customer_name' => $contactName,
447 'customer_email' => $payment->contact_email ?? null,
448 'trip_title' => $payment->trip_title ?? null,
449 'transaction_id' => $payment->transaction_id,
450 'gateway' => $payment->gateway,
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),
458 'amount' => (float) $payment->amount,
459 'currency' => $payment->currency,
460 'status' => $payment->status,
461 'payment_status' => $status,
462 'payment_type' => $payment->payment_type,
463 'notes' => $payment->notes,
464 'processed_at' => $payment->processed_at,
465 'created_at' => $payment->created_at,
466 'payment_date' => $paymentDate,
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,
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));
519 }
520 }
521
522