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 +104 -18 3.0.14.1 → 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.
@@ -110,26 +119,14 @@
110 119 // so the notification below picks the correct part/full template).
111 120 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $data['booking_id']);
112 121 $this->bookingRepository->updateAmountPaid((int) $data['booking_id'], $totalPaid);
113 122
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 - ]);
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);
132 129 }
133 130
134 131 return [
135 132 'success' => true,
@@ -138,8 +135,78 @@
138 135 ];
139 136 }
140 137
141 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 + /**
142 209 * Update a payment
143 210 *
144 211 * @param int $id Payment ID
145 212 * @param array $data Payment data
@@ -152,8 +219,13 @@
152 219 if (!$payment) {
153 220 return ['success' => false, 'message' => __('Payment not found.', 'yatra')];
154 221 }
155 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 +
156 228 $updated = $this->paymentRepository->update($id, $data);
157 229
158 230 if (!$updated) {
159 231 return ['success' => false, 'message' => __('Failed to update payment.', 'yatra')];
@@ -162,8 +234,17 @@
162 234 // Recalculate booking amount paid
163 235 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id);
164 236 $this->bookingRepository->updateAmountPaid((int) $payment->booking_id, $totalPaid);
165 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 +
166 247 return [
167 248 'success' => true,
168 249 'message' => __('Payment updated successfully.', 'yatra'),
169 250 ];
@@ -198,8 +279,13 @@
198 279
199 280 // Recalculate booking amount paid
200 281 $totalPaid = $this->paymentRepository->getTotalPaidForBooking((int) $payment->booking_id);
201 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 + }
202 288
203 289 return [
204 290 'success' => true,
205 291 'message' => sprintf(