PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
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 2.0.10 All 83 releases
yatra / app / Services / PaymentService.php

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

436 lines 15.6 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 // A payment recorded by hand carries no currency, and both the payments and
89 // bookings tables declare `currency char(3) DEFAULT 'USD'` — so on a Euro
90 // store a manual payment was saved as USD and listed with a dollar sign
91 // beside correctly-formatted euro rows.
92 //
93 // The store currency is the source of truth here, not the booking's own
94 // column: that column is only written when a caller passes it, so it silently
95 // inherits the same 'USD' default and would just propagate the wrong value.
96 // Multi-currency is not a shipped feature, so every amount is in the store
97 // currency by definition.
98 if (empty($data['currency'])) {
99 $data['currency'] = SettingsService::getCurrency();
100 }
101
102 // Create payment
103 $paymentId = $this->paymentRepository->create($data);
104
105 if (!$paymentId) {
106 return ['success' => false, 'message' => __('Failed to create payment.', 'yatra')];
107 }
108
109 // Update booking amount paid (also refreshes amount_due + payment_status,
110 // so the notification below picks the correct part/full template).
111 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $data['booking_id']);
112 $this->bookingRepository->updateAmountPaid((int) $data['booking_id'], $totalPaid);
113
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 ]);
132 }
133
134 return [
135 'success' => true,
136 'payment_id' => $paymentId,
137 'message' => __('Payment recorded successfully.', 'yatra'),
138 ];
139 }
140
141 /**
142 * Update a payment
143 *
144 * @param int $id Payment ID
145 * @param array $data Payment data
146 * @return array {success: bool, message: string}
147 */
148 public function updatePayment(int $id, array $data): array
149 {
150 $payment = $this->paymentRepository->find($id);
151
152 if (!$payment) {
153 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
154 }
155
156 $updated = $this->paymentRepository->update($id, $data);
157
158 if (!$updated) {
159 return ['success' => false, 'message' => __('Failed to update payment.', 'yatra')];
160 }
161
162 // Recalculate booking amount paid
163 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id);
164 $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid);
165
166 return [
167 'success' => true,
168 'message' => __('Payment updated successfully.', 'yatra'),
169 ];
170 }
171
172 /**
173 * Update payment status
174 *
175 * @param int $id Payment ID
176 * @param string $status New status
177 * @return array {success: bool, message: string}
178 */
179 public function updateStatus(int $id, string $status): array
180 {
181 $validStatuses = ['pending', 'completed', 'failed', 'refunded', 'cancelled'];
182
183 if (!in_array($status, $validStatuses, true)) {
184 return ['success' => false, 'message' => __('Invalid status.', 'yatra')];
185 }
186
187 $payment = $this->paymentRepository->find($id);
188
189 if (!$payment) {
190 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
191 }
192
193 $updated = $this->paymentRepository->updateStatus($id, $status);
194
195 if (!$updated) {
196 return ['success' => false, 'message' => __('Failed to update status.', 'yatra')];
197 }
198
199 // Recalculate booking amount paid
200 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id);
201 $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid);
202
203 return [
204 'success' => true,
205 'message' => sprintf(
206 /* translators: %s: new payment status. */
207 __('Payment status updated to %s.', 'yatra'),
208 $status
209 ),
210 ];
211 }
212
213 /**
214 * Delete a payment
215 *
216 * @param int $id Payment ID
217 * @return array {success: bool, message: string}
218 */
219 public function deletePayment(int $id): array
220 {
221 $payment = $this->paymentRepository->find($id);
222
223 if (!$payment) {
224 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
225 }
226
227 $bookingId = (int) $payment->booking_id;
228
229 $deleted = $this->paymentRepository->delete($id);
230
231 if (!$deleted) {
232 return ['success' => false, 'message' => __('Failed to delete payment.', 'yatra')];
233 }
234
235 // Recalculate booking amount paid
236 $totalPaid = $this->paymentRepository->getTotalPaidForBooking($bookingId);
237 $this->bookingRepository->updateAmountPaid($bookingId, $totalPaid);
238
239 return [
240 'success' => true,
241 'message' => __('Payment deleted successfully.', 'yatra'),
242 ];
243 }
244
245 /**
246 * Get payment statistics
247 *
248 * @return array
249 */
250 public function getStats(): array
251 {
252 return $this->paymentRepository->getStats();
253 }
254
255 /**
256 * Admin toolbar: counts per payment status.
257 */
258 public function getAdminStatusCounts(): array
259 {
260 return $this->paymentRepository->getAdminStatusCounts();
261 }
262
263 /**
264 * Process refund
265 *
266 * @param int $paymentId Payment ID to refund
267 * @param float $amount Refund amount (optional, full refund if not provided)
268 * @param string $reason Refund reason
269 * @return array {success: bool, refund_id?: int, message: string}
270 */
271 public function processRefund(int $paymentId, ?float $amount = null, string $reason = ''): array
272 {
273 $payment = $this->paymentRepository->find($paymentId);
274
275 if (!$payment) {
276 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
277 }
278
279 if ($payment->status !== 'completed') {
280 return ['success' => false, 'message' => __('Only completed payments can be refunded.', 'yatra')];
281 }
282
283 $refundAmount = $amount ?? (float) $payment->amount;
284
285 if ($refundAmount > (float) $payment->amount) {
286 return ['success' => false, 'message' => __('Refund amount exceeds payment amount.', 'yatra')];
287 }
288
289 // Create refund record
290 $refundId = $this->paymentRepository->create([
291 'booking_id' => $payment->booking_id,
292 'gateway' => $payment->gateway,
293 'amount' => -$refundAmount, // Negative for refund
294 'currency' => $payment->currency,
295 'status' => 'completed',
296 'payment_type' => 'refund',
297 'notes' => $reason,
298 ]);
299
300 if (!$refundId) {
301 return ['success' => false, 'message' => __('Failed to process refund.', 'yatra')];
302 }
303
304 // Update original payment status
305 $this->paymentRepository->updateStatus($paymentId, 'refunded');
306
307 // Recalculate booking amount paid
308 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id);
309 $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid);
310
311 return [
312 'success' => true,
313 'refund_id' => $refundId,
314 'message' => __('Refund processed successfully.', 'yatra'),
315 ];
316 }
317
318 /**
319 * Format payment for API response
320 *
321 * @param object $payment Raw payment data
322 * @return array
323 */
324 /**
325 * Format a raw payment row for the REST API.
326 *
327 * `public` so other services (notably {@see \Yatra\Services\CustomerService::getPaymentsForBookingIds()})
328 * can share the same formatter and we don't end up with two competing
329 * shapes — that's how the Account → Payments tab used to render
330 * blank/N/A for `date`, `method`, `reference`, and `type` even after the
331 * formatter here was updated.
332 */
333 public function formatPayment(object $payment): array
334 {
335 $contactName = isset($payment->contact_first_name)
336 ? trim($payment->contact_first_name . ' ' . ($payment->contact_last_name ?? ''))
337 : null;
338
339 $status = (string) ($payment->status ?? 'pending');
340 $gateway = (string) ($payment->gateway ?? '');
341 $bookingRef = $payment->booking_reference ?? null;
342 $processedAt = $payment->processed_at ?? null;
343 $createdAt = $payment->created_at ?? null;
344 $paymentDate = ($processedAt !== null && $processedAt !== '') ? $processedAt : ($createdAt ?? '');
345
346 // Build a human-readable payment reference once, then expose it under
347 // both `payment_number` (canonical) and `reference` (what the React
348 // Payment type at resources/js/pages/account/types.ts expects).
349 $reference = sprintf('PAY-%06d', (int) $payment->id);
350
351 return [
352 'id' => (int) $payment->id,
353 'booking_id' => (int) $payment->booking_id,
354 'booking_reference' => $bookingRef,
355 'booking_number' => ($bookingRef !== null && $bookingRef !== '')
356 ? (string) $bookingRef
357 : '#' . (int) ($payment->booking_id ?? 0),
358 'contact_email' => $payment->contact_email ?? null,
359 'contact_name' => $contactName,
360 'customer_name' => $contactName,
361 'customer_email' => $payment->contact_email ?? null,
362 'trip_title' => $payment->trip_title ?? null,
363 'transaction_id' => $payment->transaction_id,
364 'gateway' => $payment->gateway,
365 'payment_method' => $gateway,
366 // Display label resolved from the gateway registry. `payment_method`
367 // stays the raw stored value because the list filter posts it back as
368 // `gateway`; this is purely what the UI should print. Without it the
369 // list mixed registry slugs ("paypal") with whatever a manually added
370 // payment happened to store ("PayPal", "Credit Card").
371 'payment_method_label' => self::gatewayLabel($gateway),
372 'amount' => (float) $payment->amount,
373 'currency' => $payment->currency,
374 'status' => $payment->status,
375 'payment_status' => $status,
376 'payment_type' => $payment->payment_type,
377 'notes' => $payment->notes,
378 'processed_at' => $payment->processed_at,
379 'created_at' => $payment->created_at,
380 'payment_date' => $paymentDate,
381 'payment_number' => $reference,
382 // Aliases for the React Payment interface (account page).
383 // Without these, the payments tab rendered:
384 // - reference: undefined → blank line above "Booking:" label
385 // - method: undefined → blank under "Payment Method"
386 // - date: undefined → formatDate(undefined) → "N/A"
387 // - type: undefined → paymentTypeLabel(undefined) → empty
388 // Keeping the existing payment_* fields preserves any other
389 // consumer that reads them.
390 'reference' => $reference,
391 'method' => $gateway,
392 'method_label' => self::gatewayLabel($gateway),
393 'date' => $paymentDate,
394 'type' => $payment->payment_type,
395 ];
396 }
397
398 /**
399 * Human label for a stored gateway value.
400 *
401 * Resolves through the gateway registry first so the wording matches what the
402 * customer saw at checkout (and what the payment emails print — see
403 * BookingEmailRichMergeTags::gatewayLabel). Values recorded by hand may already
404 * be human ("Credit Card"), so anything unregistered is title-cased rather than
405 * discarded.
406 *
407 * @param string $gateway
408 * @return string
409 */
410 private static function gatewayLabel(string $gateway): string
411 {
412 $gateway = trim($gateway);
413 if ($gateway === '') {
414 return '';
415 }
416
417 if (class_exists(\Yatra\PaymentGateways\PaymentGatewayRegistry::class)) {
418 try {
419 $registered = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance()->get(strtolower($gateway));
420 if ($registered !== null) {
421 $title = trim((string) $registered->getTitle());
422 if ($title !== '') {
423 return $title;
424 }
425 }
426 } catch (\Throwable $e) {
427 // Registry unavailable (e.g. called before gateways register) —
428 // fall through to the humanized form rather than failing the list.
429 }
430 }
431
432 return ucwords(str_replace(['_', '-'], ' ', $gateway));
433 }
434 }
435
436