PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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 / CalculationService.php

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

980 lines 45.3 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\Services\SettingsService;
8 use Yatra\Services\DiscountService;
9 use Yatra\Repositories\TripRepository;
10
11 /**
12 * Core Pricing Calculation Service
13 *
14 * Single source of truth for ALL pricing calculations.
15 * Both booking session and checkout MUST go through this service.
16 *
17 * PRICING LOGIC:
18 * - Regular pricing: discounted_price → sale_price → original_price
19 * - Traveler-based pricing: per-category discounted → sale → original prices
20 *
21 * FREE PLUGIN FEATURES (handled here):
22 * - Base pricing (regular & traveler-based)
23 * - Tax calculations (single & multiple)
24 * - Coupon discount (via DiscountService)
25 * - Group discount (via DiscountService, requires Pro AdvancedDiscount module)
26 * - Payment amounts (full only, deposit/partial via Pro)
27 *
28 * PRO MODULE INTEGRATION (via filter hooks):
29 * - Dynamic Pricing → yatra_booking_trip_price (modifies per-unit price)
30 * - Additional Services → yatra_booking_services_total (adds services cost)
31 * - Advanced Discount → yatra_advanced_discount_enabled (enables group discounts)
32 * - Flexible Payments → yatra_calculate_amount_due (deposit/partial payments)
33 *
34 * FILTER HOOKS (in execution order):
35 * 1. yatra_before_calculation_params - Modify input params
36 * 2. yatra_booking_trip_price - Dynamic pricing per-unit price modification
37 * 3. yatra_calculate_base_amount - Modify calculated base amount
38 * 4. yatra_booking_services_total - Additional services cost (Pro)
39 * 5. yatra_calculate_subtotal - Modify subtotal (base + services)
40 * 6. yatra_calculate_group_discount - Group discount (Pro AdvancedDiscount)
41 * 7. yatra_calculate_coupon_discount - Coupon discount
42 * 8. yatra_calculate_final_total - Modify final total after tax
43 * 9. yatra_calculate_amount_due - Flexible payment amounts (Pro)
44 * 10. yatra_after_calculation_result - Modify complete result
45 *
46 * @package Yatra\Services
47 */
48 class CalculationService
49 {
50 /**
51 * Core pricing calculation (fetches trip data from repository)
52 *
53 * @param array $params Calculation parameters
54 * @return array Complete pricing breakdown
55 */
56 public function calculatePricing(array $params): array
57 {
58 $params = apply_filters('yatra_before_calculation_params', $params);
59
60 // Extract parameters
61 $trip_id = (int) ($params['trip_id'] ?? 0);
62 $travelers_count = (int) ($params['travelers_count'] ?? 1);
63 $traveler_counts = $params['traveler_counts'] ?? [];
64 $travel_date = $params['travel_date'] ?? '';
65 $departure_time = $params['departure_time'] ?? '';
66 $coupon_code = $params['coupon_code'] ?? '';
67 $payment_method = strtolower(trim((string) ($params['payment_method'] ?? 'full')));
68 if ($payment_method === '') {
69 $payment_method = 'full';
70 }
71 $selected_services = $params['selected_services'] ?? [];
72 $availability_id = $params['availability_id'] ?? null;
73
74 if (empty($trip_id)) {
75 throw new \InvalidArgumentException('Trip ID is required for calculation');
76 }
77
78 // Fetch trip data
79 $tripRepository = new TripRepository();
80 $trip = $tripRepository->find($trip_id);
81
82 if (!$trip) {
83 throw new \InvalidArgumentException("Trip with ID {$trip_id} not found");
84 }
85
86 // ── Resolve pricing type & price data ───────────────────────────
87 // Availability can override trip-level pricing
88 $availability = null;
89 if (!empty($availability_id) || !empty($travel_date)) {
90 $availability = $this->resolveAvailability($trip_id, $travel_date, $availability_id, $departure_time);
91 }
92
93 $pricing_type = $this->resolvePricingType($trip, $availability);
94 $price_types = $this->resolvePriceTypes($trip, $availability);
95
96 // Context for Pro dynamic pricing (inventory rules, etc.): prefer departure row seats, not trip max only.
97 $spots_for_dp = null;
98 if ($availability !== null) {
99 if (isset($availability->seats_available)) {
100 $spots_for_dp = (int) $availability->seats_available;
101 } elseif (isset($availability->spots_remaining)) {
102 $spots_for_dp = (int) $availability->spots_remaining;
103 }
104 }
105 if ($spots_for_dp === null && isset($trip->max_travelers)) {
106 $spots_for_dp = (int) $trip->max_travelers;
107 }
108 $availability_id_for_dp = $availability_id;
109 if ($availability_id_for_dp === null && $availability !== null && isset($availability->id)) {
110 $availability_id_for_dp = (int) $availability->id;
111 }
112
113 // ── Resolve per-unit price (Regular pricing) ────────────────────
114 // Priority: discounted_price → sale_price → original_price
115 $original_price = (float) ($trip->original_price ?? 0);
116 $discounted_price = $this->resolveDiscountedPrice($trip, $availability);
117 $unit_price = $discounted_price > 0 ? $discounted_price : $original_price;
118
119 // Snapshot the pre-DP per-unit price so the pricing-summary template
120 // can render the dynamic-pricing impact as its own line item rather
121 // than silently absorbing it into the Gross Total. Without this, an
122 // admin/customer looking at the summary cannot tell what the trip
123 // would have cost without DP rules.
124 $unit_price_before_dp = $unit_price;
125
126 // Apply Dynamic Pricing filter (Pro DynamicPricingModule hooks here)
127 $unit_price = (float) apply_filters('yatra_booking_trip_price', $unit_price, $trip_id, [
128 'departure_date' => $travel_date,
129 'spots_remaining' => $spots_for_dp,
130 'availability_id' => $availability_id_for_dp,
131 'original_price' => $original_price,
132 'discounted_price' => $discounted_price,
133 ]);
134
135 // ── Calculate base amount ───────────────────────────────────────
136 $base_amount = $this->calculateBaseAmount(
137 $unit_price,
138 $travelers_count,
139 $traveler_counts,
140 $pricing_type,
141 $price_types,
142 $trip_id,
143 $travel_date,
144 $spots_for_dp,
145 $availability_id_for_dp
146 );
147
148 $base_amount = (float) apply_filters('yatra_calculate_base_amount', $base_amount, [
149 'unit_price' => $unit_price,
150 'original_price' => $original_price,
151 'discounted_price' => $discounted_price,
152 'travelers_count' => $travelers_count,
153 'traveler_counts' => $traveler_counts,
154 'pricing_type' => $pricing_type,
155 'price_types' => $price_types,
156 'trip_id' => $trip_id,
157 ]);
158
159 // ── Subtotal (Pro modules can add services cost via filter) ─────
160 // Free plugin: subtotal = base_amount only
161 // Pro AdditionalServicesModule: hooks into yatra_calculate_subtotal to add services
162 $subtotal = $base_amount;
163 $subtotal = (float) apply_filters('yatra_calculate_subtotal', $subtotal, [
164 'base_amount' => $base_amount,
165 'trip_id' => $trip_id,
166 'travelers_count' => $travelers_count,
167 'traveler_counts' => $traveler_counts,
168 'travel_date' => $travel_date,
169 'selected_services' => $selected_services,
170 ]);
171
172 // ── Group Discount (AdvancedDiscount Pro module enables this) ───
173 $group_discount_data = $this->calculateGroupDiscount(
174 $trip_id, $subtotal, $travelers_count, $traveler_counts, $pricing_type, $price_types
175 );
176
177 // ── Coupon Discount ─────────────────────────────────────────────
178 // Use DiscountService for coupon calculation
179 $discountService = new \Yatra\Services\DiscountService();
180 $subtotal_after_group = $subtotal - ($group_discount_data['amount'] ?? 0);
181
182 $coupon_discount_data = $discountService->calculateCouponDiscount(
183 $coupon_code,
184 $subtotal_after_group,
185 $trip_id,
186 $travelers_count,
187 $traveler_counts
188 );
189
190 // Allow plugins to modify coupon discount
191 $coupon_discount_data = (array) apply_filters('yatra_calculate_coupon_discount', $coupon_discount_data, [
192 'subtotal' => $subtotal,
193 'group_discount_amount' => $group_discount_data['amount'] ?? 0,
194 'trip_id' => $trip_id,
195 'travelers_count' => $travelers_count,
196 ]);
197
198 // ── Total discounts ─────────────────────────────────────────────
199 $total_discount_amount = ($group_discount_data['amount'] ?? 0) + ($coupon_discount_data['calculated_amount'] ?? 0);
200 $total_discount_amount = min($total_discount_amount, $subtotal); // discount cannot exceed subtotal
201
202 $discounted_subtotal = max(0, $subtotal - $total_discount_amount);
203
204 // ── Discount stacking hook ───────────────────────────────────────
205 // Premium-only enforcement point. The Pro Advanced Discount module
206 // listens here when paired with Dynamic Pricing and rewrites the
207 // discount/DP combination according to the operator's stacking
208 // mode. Free sites (and sites missing either module) receive no
209 // listener and the snapshot passes through unchanged — pricing
210 // stays bit-for-bit identical to the pre-feature behavior.
211 //
212 // `pre_dp_base_amount` is the base before DP fires — works for
213 // BOTH regular pricing (where DP modifies $unit_price) and
214 // traveler-based pricing (where DP fires per-category inside
215 // calculateBaseAmount and would otherwise be invisible to a
216 // post-hoc listener doing `$unit_price < $unit_price_before_dp`).
217 // Cheap to compute (no DB hits, just arithmetic) so we always
218 // pay the cost — far cheaper than asking listeners to recompute.
219 $pre_dp_base_amount = $this->computeBaseAmountWithoutDynamicPricing(
220 $unit_price_before_dp,
221 $travelers_count,
222 $traveler_counts,
223 $pricing_type,
224 $price_types
225 );
226 $stackingSnapshot = (array) apply_filters('yatra_pricing_after_discount_stack', [
227 'unit_price' => $unit_price,
228 'unit_price_before_dp' => $unit_price_before_dp,
229 'base_amount' => $base_amount,
230 'pre_dp_base_amount' => $pre_dp_base_amount,
231 'subtotal' => $subtotal,
232 'group_discount_data' => $group_discount_data,
233 'coupon_discount_data' => $coupon_discount_data,
234 'total_discount_amount' => $total_discount_amount,
235 'discounted_subtotal' => $discounted_subtotal,
236 ], [
237 'trip_id' => $trip_id,
238 'travelers_count' => $travelers_count,
239 'traveler_counts' => $traveler_counts,
240 'pricing_type' => $pricing_type,
241 'price_types' => $price_types,
242 'travel_date' => $travel_date,
243 'spots_for_dp' => $spots_for_dp,
244 'availability_id_for_dp' => $availability_id_for_dp,
245 'coupon_code' => $coupon_code,
246 'selected_services' => $selected_services,
247 'original_price' => $original_price,
248 'discounted_price' => $discounted_price,
249 'calculation_service' => $this,
250 'discount_service' => $discountService,
251 ]);
252 if (isset($stackingSnapshot['unit_price'])) { $unit_price = (float) $stackingSnapshot['unit_price']; }
253 if (isset($stackingSnapshot['base_amount'])) { $base_amount = (float) $stackingSnapshot['base_amount']; }
254 if (isset($stackingSnapshot['subtotal'])) { $subtotal = (float) $stackingSnapshot['subtotal']; }
255 if (isset($stackingSnapshot['group_discount_data']) && is_array($stackingSnapshot['group_discount_data'])) {
256 $group_discount_data = $stackingSnapshot['group_discount_data'];
257 }
258 if (isset($stackingSnapshot['coupon_discount_data']) && is_array($stackingSnapshot['coupon_discount_data'])) {
259 $coupon_discount_data = $stackingSnapshot['coupon_discount_data'];
260 }
261 if (isset($stackingSnapshot['total_discount_amount'])) { $total_discount_amount = (float) $stackingSnapshot['total_discount_amount']; }
262 if (isset($stackingSnapshot['discounted_subtotal'])) { $discounted_subtotal = (float) $stackingSnapshot['discounted_subtotal']; }
263
264 // ── Itinerary Costs ─────────────────────────────────────────
265 $itinerary_costs = apply_filters('yatra_booking_itinerary_costs', [], $trip_id, $travelers_count, $traveler_counts, $travel_date);
266 $itinerary_costs_total = 0;
267 if (!empty($itinerary_costs) && is_array($itinerary_costs)) {
268 foreach ($itinerary_costs as $cost) {
269 $itinerary_costs_total += (float) ($cost['total_cost'] ?? 0);
270 }
271 }
272
273 // ── Additional Services (if any) ─────────────────────────────
274 // Pull the available services for this trip via the Pro filter and
275 // mark which ones are selected (or required/included). Pricing data
276 // ships back to the template scope so the Pricing Summary partial's
277 // selected-services loop has something to render — previously
278 // `pricing_calculation['additional_services']` was never populated
279 // here, so the per-row services list in the sidebar always rendered
280 // empty even when the standalone Additional Services card showed
281 // ticked checkboxes.
282 $available_services = (array) apply_filters(
283 'yatra_booking_additional_services',
284 [],
285 $trip_id,
286 $travelers_count,
287 $traveler_counts,
288 $travel_date
289 );
290 $selected_service_ids = array_map('intval', (array) $selected_services);
291 $duration_days_for_services = (int) ($trip->duration_days ?? 1);
292 $additional_services_total = 0.0;
293 $additional_services_resolved = [];
294 foreach ($available_services as $svc) {
295 $svc = (array) $svc;
296 $svc_id = (int) ($svc['id'] ?? 0);
297 $is_required = !empty($svc['is_required']);
298 $is_included = !empty($svc['is_included']);
299 $is_selected = $is_required || $is_included || in_array($svc_id, $selected_service_ids, true);
300
301 $base_price = (float) ($svc['price'] ?? 0);
302 $price_per = $svc['price_per'] ?? 'person';
303 switch ($price_per) {
304 case 'person':
305 $calculated_price = $base_price * max(1, $travelers_count);
306 break;
307 case 'day':
308 $calculated_price = $base_price * max(1, $duration_days_for_services);
309 break;
310 case 'booking':
311 default:
312 $calculated_price = $base_price;
313 break;
314 }
315
316 $svc['selected'] = $is_selected;
317 $svc['calculated_price'] = $calculated_price;
318 $additional_services_resolved[] = $svc;
319
320 // Only paid (non-included) selected services contribute to the
321 // services subtotal. The taxable-amount line below will then
322 // include this naturally.
323 if ($is_selected && !$is_included) {
324 $additional_services_total += $calculated_price;
325 }
326 }
327 // Let Pro modules override the total (rounding, group rules, etc.).
328 $additional_services_total = (float) apply_filters(
329 'yatra_booking_services_total',
330 $additional_services_total,
331 $additional_services_resolved,
332 $trip_id,
333 $travelers_count,
334 $duration_days_for_services
335 );
336
337 // ── Taxable Amount ────────────────────────────────────────────
338 // We DON'T add `$additional_services_total` again here. The Pro
339 // AdditionalServicesModule hooks into `yatra_calculate_subtotal`
340 // (above), which already folded selected services into `$subtotal`
341 // → `$discounted_subtotal`. Adding them a second time produced the
342 // visible double-count bug ($159 + $112 services = $271 subtotal,
343 // then $271 + $112 services = $383 net amount). `$additional_services_total`
344 // stays available in the result payload so the sidebar can render
345 // each service as a row for transparency.
346 $taxable_amount = $discounted_subtotal + $itinerary_costs_total;
347
348 // ── Taxes ───────────────────────────────────────────────────────
349 $tax_calculation = $this->calculateTaxes($taxable_amount);
350
351 $final_total = $taxable_amount;
352 if (!$tax_calculation['tax_inclusive']) {
353 $final_total += $tax_calculation['total_tax_amount'];
354 }
355
356 $final_total = (float) apply_filters('yatra_calculate_final_total', $final_total, [
357 'discounted_subtotal' => $discounted_subtotal,
358 'itinerary_costs_total' => $itinerary_costs_total,
359 'taxable_amount' => $taxable_amount,
360 'tax_calculation' => $tax_calculation,
361 'trip_id' => $trip_id,
362 'payment_method' => $payment_method,
363 ]);
364
365 // ── Payment amounts (FlexiblePayments Pro module) ───────────────
366 $payment_amounts = $this->calculatePaymentAmounts($final_total, $payment_method, [
367 'trip_id' => $trip_id,
368 'travelers_count' => $travelers_count,
369 ]);
370
371 // ── Currency ────────────────────────────────────────────────────
372 $currency = SettingsService::getCurrency();
373
374 // ── Gross total (subtotal before discounts/taxes, includes services) ───
375 $gross_total = $subtotal;
376
377 // ── Dynamic-pricing breakdown (for the pricing-summary template) ─
378 // The DP module already has an `addPricingBreakdown` callback wired to
379 // the `yatra_price_breakdown` filter — but that filter was previously
380 // never fired anywhere, so the template's `$dynamic_pricing` block was
381 // dead code. We fire it here with the same context the per-unit DP
382 // filter received, so Pro DP can populate the breakdown row that the
383 // template renders.
384 //
385 // dp_total_adjustment is the signed dollar impact DP has on the trip
386 // subtotal:
387 // - For regular pricing: per-unit delta × travelers (the line 120
388 // filter is the single DP entry point).
389 // - For traveler_based pricing: DP is applied per-category inside
390 // calculateBaseAmount, so we recompute a "pristine" base amount
391 // using the same TripPricingService::resolveCategoryEffectivePrice
392 // anchor that calculateBaseAmount starts from, and subtract from
393 // the real (post-DP) base_amount.
394 // If the discount-stacking filter reverted DP (the Pro
395 // "discount_only" or "best_for_customer→discount" path
396 // mutated $base_amount back to its pre-DP value), the
397 // breakdown should reflect that — otherwise the per-category
398 // line items would still display DP-adjusted prices that
399 // don't match the final total customers actually pay.
400 //
401 // Free-only sites (no filter listener) hit the else branch
402 // exactly as before: $base_amount tracks the post-DP value,
403 // $dp_was_suppressed stays false, and the existing breakdown
404 // math runs unchanged. So the pre-feature display contract
405 // is bit-for-bit preserved.
406 $dp_was_suppressed = $pre_dp_base_amount > 0
407 && abs($base_amount - $pre_dp_base_amount) < 0.005;
408 $dp_per_unit_delta = $dp_was_suppressed
409 ? 0.0
410 : $unit_price - $unit_price_before_dp;
411 $dp_total_adjustment = 0.0;
412 $category_prices_post_dp = [];
413 if ($pricing_type === 'regular') {
414 $dp_total_adjustment = $dp_per_unit_delta * max(1, $travelers_count);
415 } elseif ($pricing_type === 'traveler_based' && !empty($price_types)) {
416 $pre_dp_base = 0.0;
417 foreach ($price_types as $pt) {
418 $pt_arr = (array) $pt;
419 $category_id = $pt_arr['category_id'] ?? 0;
420 $pricing_mode = $pt_arr['pricing_mode'] ?? 'per_person';
421 $pre_dp_price = (float) \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr);
422 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
423
424 // Capture the DP-adjusted per-category price so the pricing-summary
425 // category row can show the price the customer is actually paying.
426 // When DP was suppressed by the stacking enforcer, skip the DP
427 // filter entirely and surface the pristine pre-DP price so the
428 // breakdown matches the final total.
429 if ($dp_was_suppressed) {
430 $post_dp_price = $pre_dp_price;
431 } else {
432 $post_dp_price = (float) apply_filters('yatra_booking_trip_price', $pre_dp_price, $trip_id, [
433 'departure_date' => $travel_date,
434 'spots_remaining' => $spots_for_dp,
435 'availability_id' => $availability_id_for_dp,
436 'category_id' => $category_id,
437 'original_price' => (float) ($pt_arr['original_price'] ?? 0),
438 'discounted_price' => (float) ($pt_arr['discounted_price'] ?? $pt_arr['sale_price'] ?? 0),
439 ]);
440 }
441 if ($category_id) {
442 $category_prices_post_dp[(string) $category_id] = $post_dp_price;
443 }
444
445 $pre_dp_base += TripPricingService::categoryLineSubtotal($pt_arr, $count, $pre_dp_price);
446 }
447 $dp_total_adjustment = $dp_was_suppressed ? 0.0 : ($base_amount - $pre_dp_base);
448 }
449 $price_breakdown = (array) apply_filters('yatra_price_breakdown', [], $trip_id, [
450 'price' => $unit_price,
451 'original_price' => $original_price,
452 'discounted_price' => $discounted_price,
453 'departure_date' => $travel_date,
454 'spots_remaining' => $spots_for_dp,
455 'availability_id' => $availability_id_for_dp,
456 'travelers_count' => $travelers_count,
457 'gross_total' => $gross_total,
458 ]);
459 $dynamic_pricing_breakdown = $price_breakdown['dynamic_pricing'] ?? null;
460
461 // ── Build result ────────────────────────────────────────────
462 $pricing_data = [
463 // Price info
464 'original_price' => $original_price,
465 'discounted_price' => $discounted_price,
466 'unit_price' => $unit_price,
467 'unit_price_before_dp' => $unit_price_before_dp,
468 'dp_per_unit_delta' => $dp_per_unit_delta,
469 'dp_total_adjustment' => $dp_total_adjustment,
470 'category_prices_post_dp' => $category_prices_post_dp,
471 'dynamic_pricing' => $dynamic_pricing_breakdown,
472 'pricing_type' => $pricing_type,
473
474 // Base amounts
475 'base_amount' => $base_amount,
476 'subtotal' => $subtotal,
477 'discounted_subtotal' => $discounted_subtotal,
478 'taxable_amount' => $taxable_amount,
479 'gross_total' => $gross_total,
480
481 // Discounts
482 'group_discount' => $group_discount_data,
483 'coupon_discount' => $coupon_discount_data,
484 'total_discount_amount' => $total_discount_amount,
485
486 // Taxes
487 'tax_calculation' => $tax_calculation,
488
489 // Final amounts
490 'final_total' => $final_total,
491 'amount_due' => $payment_amounts['amount_due'],
492 'amount_paid' => $payment_amounts['amount_paid'],
493
494 // Payment & currency
495 'payment_method' => $payment_method,
496 'currency' => $currency,
497
498 // Itinerary costs
499 // Additional services with `selected` / `calculated_price` flags
500 // — Checkout::getAdditionalServices() reads this; the sidebar's
501 // selected-services loop renders one row per ticked service.
502 'additional_services' => $additional_services_resolved,
503 'services_total' => $additional_services_total,
504
505 'itinerary_costs' => $itinerary_costs,
506 'itinerary_costs_total' => $itinerary_costs_total,
507
508 // Metadata
509 'travelers_count' => $travelers_count,
510 'traveler_counts' => $traveler_counts,
511 'travel_date' => $travel_date,
512 'trip_id' => $trip_id,
513 ];
514
515 return (array) apply_filters('yatra_after_calculation_result', $pricing_data, $params);
516 }
517
518 /**
519 * Resolve the discounted price from availability or trip
520 * Priority: availability discounted → availability original → trip discounted → trip sale → 0
521 */
522 private function resolveDiscountedPrice(object $trip, ?object $availability): float
523 {
524 if ($availability) {
525 if (!empty($availability->discounted_price) && (float) $availability->discounted_price > 0) {
526 return (float) $availability->discounted_price;
527 }
528 if (!empty($availability->original_price) && (float) $availability->original_price > 0) {
529 return (float) $availability->original_price;
530 }
531 }
532
533 if (!empty($trip->discounted_price) && (float) $trip->discounted_price > 0) {
534 return (float) $trip->discounted_price;
535 }
536 if (!empty($trip->sale_price) && (float) $trip->sale_price > 0) {
537 return (float) $trip->sale_price;
538 }
539
540 return 0.0;
541 }
542
543 /**
544 * Resolve pricing type from availability or trip
545 */
546 private function resolvePricingType(object $trip, ?object $availability): string
547 {
548 // Authoritative model matches {@see TripPricingService::resolvePricingType}:
549 // explicit "regular" must not be overridden by inherited/stale availability price_types.
550 $trip_model = TripPricingService::resolvePricingType($trip);
551
552 if ($trip_model !== 'traveler_based') {
553 return 'regular';
554 }
555
556 // Traveler-based trip: use per-date categories when that row defines them; otherwise caller
557 // falls back to trip-level price_types via {@see self::resolvePriceTypes()}.
558 if ($availability && !empty($availability->price_types)) {
559 $types = is_string($availability->price_types)
560 ? json_decode($availability->price_types, true)
561 : $availability->price_types;
562 if (!empty($types) && is_array($types)) {
563 return 'traveler_based';
564 }
565 }
566
567 return 'traveler_based';
568 }
569
570 /**
571 * Resolve price_types array from availability or trip
572 */
573 private function resolvePriceTypes(object $trip, ?object $availability): array
574 {
575 if (TripPricingService::resolvePricingType($trip) === 'regular') {
576 return [];
577 }
578
579 $types = [];
580
581 // Priority 1: Availability price_types
582 if ($availability && !empty($availability->price_types)) {
583 $types = is_string($availability->price_types)
584 ? json_decode($availability->price_types, true)
585 : $availability->price_types;
586 }
587
588 // Priority 2: Trip price_types
589 if (empty($types) && !empty($trip->price_types)) {
590 if (is_string($trip->price_types)) {
591 $types = json_decode($trip->price_types, true) ?: [];
592 } else {
593 $types = is_array($trip->price_types) ? $trip->price_types : [];
594 }
595 }
596
597 if (empty($types)) {
598 return [];
599 }
600
601 // Resolve pricing_mode / group-size limits authoritatively from the
602 // TravelerCategory. This must OVERRIDE (not just fill-when-empty): the
603 // price_types coming from a stored availability row or session can carry
604 // a literal 'per_person' placeholder that an empty() check would skip,
605 // which silently charged a per-group category by headcount. For
606 // per-person categories this resolves back to 'per_person' (a no-op), so
607 // the booking total for every existing trip is unchanged.
608 $types = TripPricingService::applyCategoryPricingMeta($types);
609
610 return $types;
611 }
612
613 /**
614 * Get category metadata (pricing_mode, etc.) by IDs
615 */
616 private function getCategoryMetadata(array $category_ids): array
617 {
618 // Use repository instead of direct database query
619 $repository = new \Yatra\Repositories\TravelerCategoryRepository();
620 return $repository->getMetadataByIds($category_ids);
621 }
622
623 /**
624 * Resolve availability data
625 */
626 private function resolveAvailability(int $trip_id, string $travel_date, ?int $availability_id, string $departure_time = ''): ?object
627 {
628 if (!class_exists('\Yatra\Services\AvailabilityService')) {
629 return null;
630 }
631
632 try {
633 // Try by availability_id first via repository
634 if (!empty($availability_id) && class_exists('\Yatra\Repositories\AvailabilityRepository')) {
635 $repo = new \Yatra\Repositories\AvailabilityRepository();
636 if (method_exists($repo, 'find')) {
637 $result = $repo->find($availability_id);
638 if ($result) {
639 return $result;
640 }
641 }
642 }
643
644 // Fallback: resolve through centralized resolver so rule-generated slots
645 // (virtual, no numeric availability_id) use the exact same data shape as the UI.
646 if (!empty($travel_date)) {
647 $resolver = new \Yatra\Services\AvailabilityResolutionService();
648 return $resolver->resolveAvailabilityForDate(
649 $trip_id,
650 $travel_date,
651 $departure_time !== '' ? $departure_time : null
652 );
653 }
654 } catch (\Exception $e) {
655 // Availability lookup failed, continue with trip-level pricing
656 }
657
658 return null;
659 }
660
661 /**
662 * Calculate base amount (regular or traveler-based pricing)
663 *
664 * For traveler-based: uses per-category effective prices × counts
665 * For regular: uses unit_price × travelers_count
666 */
667 /**
668 * Compute the base amount for a trip WITHOUT firing the Dynamic
669 * Pricing filter, regardless of pricing type.
670 *
671 * Used by the Advanced Discount stacking enforcer to detect whether
672 * DP actually adjusted the booking (compare actual base_amount to
673 * the no-DP base_amount) and to compute the "discount-only" alt
674 * scenario where DP must be fully neutralized.
675 *
676 * For regular pricing this is just unit_price × travelers_count.
677 * For traveler-based pricing it walks the per-category prices via
678 * the canonical TripPricingService anchor (identical math to the
679 * breakdown's `$pre_dp_base` at lines ~384-415 of calculatePricing).
680 * Per-category prices come from saved trip data — DP filter is
681 * deliberately NOT applied.
682 */
683 public function computeBaseAmountWithoutDynamicPricing(
684 float $unit_price,
685 int $travelers_count,
686 array $traveler_counts,
687 string $pricing_type,
688 array $price_types
689 ): float {
690 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
691 $base_amount = 0.0;
692 foreach ($price_types as $pt) {
693 $pt = (array) $pt;
694 $category_id = $pt['category_id'] ?? 0;
695 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
696 $category_price = (float) TripPricingService::resolveCategoryEffectivePrice($pt);
697 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
698
699 $base_amount += TripPricingService::categoryLineSubtotal($pt, $count, $category_price);
700 }
701 return round($base_amount, 2);
702 }
703
704 return round($unit_price * max(1, $travelers_count), 2);
705 }
706
707 /**
708 * Compute the base amount for a trip from a given per-unit price.
709 *
710 * Public so Pro extensions (e.g. the Advanced Discount stacking-
711 * enforcer) can re-derive the base when they need to recompute the
712 * downstream pipeline with a different unit price — for example,
713 * the "discount_only" stacking mode reverts the DP adjustment and
714 * has to recompute base/subtotal/discounts against the pre-DP price.
715 */
716 public function calculateBaseAmount(
717 float $unit_price,
718 int $travelers_count,
719 array $traveler_counts,
720 string $pricing_type,
721 array $price_types,
722 int $trip_id,
723 string $travel_date = '',
724 ?int $spots_remaining = null,
725 ?int $availability_id_for_dp = null
726 ): float {
727 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
728 $base_amount = 0.0;
729
730 foreach ($price_types as $pt) {
731 $pt = (array) $pt;
732 $category_id = $pt['category_id'] ?? 0;
733 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
734
735 $category_price = TripPricingService::resolveCategoryEffectivePrice($pt);
736
737 // Apply Dynamic Pricing filter per category
738 $category_price = (float) apply_filters('yatra_booking_trip_price', $category_price, $trip_id, [
739 'departure_date' => $travel_date,
740 'category_id' => $category_id,
741 'spots_remaining' => $spots_remaining,
742 'availability_id' => $availability_id_for_dp,
743 'original_price' => (float) ($pt['original_price'] ?? 0),
744 'discounted_price' => (float) ($pt['discounted_price'] ?? $pt['sale_price'] ?? 0),
745 ]);
746
747 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
748
749 // Single source of truth for the per-category line amount
750 // (per-person × count, flat per-group, or per-block group pricing).
751 $base_amount += TripPricingService::categoryLineSubtotal($pt, $count, $category_price);
752 }
753
754 return round($base_amount, 2);
755 }
756
757 // Regular pricing
758 return round($unit_price * $travelers_count, 2);
759 }
760
761 /**
762 * Calculate group discount
763 */
764 public function calculateGroupDiscount(
765 int $trip_id,
766 float $subtotal,
767 int $travelers_count,
768 array $traveler_counts,
769 string $pricing_type,
770 array $price_types
771 ): array {
772 $group_discount_data = [
773 'amount' => 0,
774 'label' => __('Group Discount', 'yatra'),
775 'code' => null,
776 ];
777
778 // Try DiscountService (requires Pro AdvancedDiscount module to enable)
779 if (class_exists('\Yatra\Services\DiscountService') && method_exists('\Yatra\Services\DiscountService', 'calculateGroupDiscount')) {
780 try {
781 $discountService = new DiscountService();
782 $discountResult = $discountService->calculateGroupDiscount($trip_id, $traveler_counts, $price_types);
783
784 // Debug: Log the calculation result
785 if (WP_DEBUG && WP_DEBUG_LOG) {
786 $priceTypesDebug = [];
787 foreach ($price_types as $pt) {
788 $pt = (object) $pt;
789 $priceTypesDebug[] = [
790 'category_id' => $pt->category_id ?? null,
791 'price' => $pt->effective_price ?? $pt->sale_price ?? $pt->original_price ?? 0
792 ];
793 }
794
795 }
796
797 if ($discountResult && !empty($discountResult['amount'])) {
798 return [
799 'amount' => (float) ($discountResult['amount'] ?? 0),
800 'label' => $discountResult['label'] ?? __('Group Discount', 'yatra'),
801 'code' => $discountResult['code'] ?? null,
802 ];
803 }
804 } catch (\Exception $e) {
805
806 }
807 }
808
809 // Fall back to filter (Pro plugins can hook here)
810 return (array) apply_filters('yatra_calculate_group_discount', $group_discount_data, [
811 'subtotal' => $subtotal,
812 'travelers_count' => $travelers_count,
813 'traveler_counts' => $traveler_counts,
814 'pricing_type' => $pricing_type,
815 'price_types' => $price_types,
816 'trip_id' => $trip_id,
817 ]);
818 }
819
820 /**
821 * Calculate taxes
822 */
823 private function calculateTaxes(float $subtotal): array
824 {
825 $enable_tax = SettingsService::get('enable_tax', false);
826 $tax_rate = (float) SettingsService::get('tax_rate', 0);
827 $tax_label = SettingsService::get('tax_label', 'Tax');
828 $tax_inclusive = (bool) SettingsService::get('tax_inclusive', false);
829 $multiple_taxes_enabled = (bool) SettingsService::get('multiple_taxes_enabled', false);
830 $multiple_taxes = SettingsService::get('multiple_taxes', []);
831
832 if (!is_array($multiple_taxes)) {
833 $multiple_taxes = [];
834 }
835
836 // If tax is enabled and taxes are configured, honor them even if the boolean
837 // "multiple_taxes_enabled" setting wasn't toggled in the UI (common case).
838 if ($enable_tax && !empty($multiple_taxes)) {
839 $multiple_taxes_enabled = true;
840 } elseif (count($multiple_taxes) > 1) {
841 $multiple_taxes_enabled = true;
842 }
843
844 $tax_breakdown = [];
845 $total_tax_amount = 0.0;
846
847 if ($multiple_taxes_enabled && !empty($multiple_taxes)) {
848 foreach ($multiple_taxes as $tax) {
849 $rate = isset($tax['rate']) ? (float) $tax['rate'] : 0;
850 $name = $tax['name'] ?? 'Tax';
851
852 if ($rate > 0) {
853 $amount = $tax_inclusive
854 ? $subtotal * ($rate / (100 + $rate))
855 : $subtotal * ($rate / 100);
856 $amount = round($amount, 2);
857
858 $tax_breakdown[] = ['name' => $name, 'rate' => $rate, 'amount' => $amount];
859 $total_tax_amount += $amount;
860 }
861 }
862 } elseif ($enable_tax && $tax_rate > 0) {
863 $amount = $tax_inclusive
864 ? $subtotal * ($tax_rate / (100 + $tax_rate))
865 : $subtotal * ($tax_rate / 100);
866 $amount = round($amount, 2);
867
868 $tax_breakdown[] = ['name' => $tax_label, 'rate' => $tax_rate, 'amount' => $amount];
869 $total_tax_amount = $amount;
870 }
871
872 return [
873 'enable_tax' => $enable_tax,
874 'tax_rate' => $tax_rate,
875 'tax_label' => $tax_label,
876 'tax_inclusive' => $tax_inclusive,
877 'multiple_taxes_enabled' => $multiple_taxes_enabled,
878 'multiple_taxes' => $multiple_taxes,
879 'tax_breakdown' => $tax_breakdown,
880 'total_tax_amount' => $total_tax_amount,
881 'subtotal' => $subtotal,
882 'total_with_tax' => $tax_inclusive ? $subtotal : ($subtotal + $total_tax_amount),
883 ];
884 }
885
886 /**
887 * Calculate payment amounts
888 *
889 * Free: full payment only.
890 * Pro FlexiblePayments: deposit/partial via `yatra_calculate_amount_due`.
891 *
892 * $context carries `trip_id` (and may carry more later). It is forwarded
893 * to every payment-related filter so Pro can apply per-trip overrides
894 * (e.g. trip.deposit_amount, trip.deposit_percentage) instead of only
895 * the site-wide settings.
896 */
897 private function calculatePaymentAmounts(float $final_total, string $payment_method, array $context): array
898 {
899 $amount_due = $final_total;
900 $amount_paid = 0.0;
901
902 // Apply Pro FlexiblePayments filter for deposit/partial. $context lets
903 // Pro look up the trip and honour per-trip overrides.
904 $amount_due = (float) apply_filters('yatra_calculate_amount_due', $amount_due, $final_total, $payment_method, $context);
905
906 // Fallback: if no Pro module handled it, use basic logic (never use === on floats)
907 $flexible_enabled = apply_filters('yatra_flexible_payments_enabled', false);
908 $unchanged = abs($amount_due - $final_total) < 0.000001;
909 if ($unchanged && $payment_method !== 'full' && $flexible_enabled) {
910 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
911 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
912 if ($payment_method === 'deposit') {
913 $amount_due = round($final_total * ($deposit_percentage / 100), 2);
914 } elseif ($payment_method === 'partial') {
915 $amount_due = round($final_total * ($partial_percentage / 100), 2);
916 }
917 }
918
919 $payment_data = (array) apply_filters('yatra_calculate_payment_amounts', [
920 'amount_due' => $amount_due,
921 'amount_paid' => $amount_paid,
922 'payment_method' => $payment_method,
923 'final_total' => $final_total,
924 ], $context);
925
926 return [
927 'amount_due' => (float) ($payment_data['amount_due'] ?? $amount_due),
928 'amount_paid' => (float) ($payment_data['amount_paid'] ?? $amount_paid),
929 ];
930 }
931
932 /**
933 * Calculate pricing from session data
934 *
935 * Used by booking-content.php template for checkout summary
936 */
937 public function calculateFromSession(array $session_data, string $coupon_code = '', string $payment_method = 'full'): array
938 {
939 $trip_id = (int) ($session_data['trip_id'] ?? 0);
940
941 if (empty($trip_id)) {
942 throw new \InvalidArgumentException('Trip ID is required in session data');
943 }
944
945 $payment_method = strtolower(trim($payment_method));
946 if ($payment_method === '') {
947 $payment_method = 'full';
948 }
949
950 $params = [
951 'trip_id' => $trip_id,
952 'travelers_count' => (int) ($session_data['travelers'] ?? 1),
953 'traveler_counts' => $session_data['traveler_counts'] ?? [],
954 'travel_date' => $session_data['travel_date'] ?? '',
955 'departure_time' => $session_data['departure_time'] ?? '',
956 'coupon_code' => $coupon_code,
957 'payment_method' => $payment_method,
958 'selected_services' => $session_data['additional_services'] ?? [],
959 'availability_id' => !empty($session_data['availability_id']) ? (int) $session_data['availability_id'] : null,
960 ];
961
962 $params = (array) apply_filters('yatra_session_calculation_params', $params, $session_data);
963
964 return $this->calculatePricing($params);
965 }
966
967 /**
968 * Quick pricing calculation for simple cases
969 */
970 public function quickCalculate(int $trip_id, int $travelers_count, string $travel_date = ''): array
971 {
972 return $this->calculatePricing([
973 'trip_id' => $trip_id,
974 'travelers_count' => $travelers_count,
975 'travel_date' => $travel_date,
976 'payment_method' => 'full',
977 ]);
978 }
979 }
980