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 / CalculationService.php

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

998 lines 46.5 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 // This MUST stay identical to the Pro charge path
304 // (AdditionalServicesBookingHooks::calculateServicePrice) so the
305 // displayed line-item equals the amount folded into the subtotal.
306 if (($svc['price_type'] ?? 'fixed') === 'percentage') {
307 // Percentage = % of the WHOLE trip base price. `$base_amount`
308 // already accounts for travelers/categories, so Price Per is NOT
309 // applied (it would double-count). Matches the "Percentage of
310 // Trip Price" label and the free booking-services fallback.
311 $unit_price = round(($base_price / 100) * $base_amount, 2);
312 $calculated_price = $unit_price;
313 } else {
314 // Fixed: Price Per multiplies the entered flat amount.
315 $unit_price = $base_price;
316 switch ($price_per) {
317 case 'person':
318 $calculated_price = round($unit_price * max(1, $travelers_count), 2);
319 break;
320 case 'day':
321 $calculated_price = round($unit_price * max(1, $duration_days_for_services), 2);
322 break;
323 case 'booking':
324 default:
325 $calculated_price = round($unit_price, 2);
326 break;
327 }
328 }
329
330 $svc['selected'] = $is_selected;
331 $svc['unit_price'] = $unit_price;
332 $svc['calculated_price'] = $calculated_price;
333 $additional_services_resolved[] = $svc;
334
335 // Only paid (non-included) selected services contribute to the
336 // services subtotal. The taxable-amount line below will then
337 // include this naturally.
338 if ($is_selected && !$is_included) {
339 $additional_services_total += $calculated_price;
340 }
341 }
342 // Let Pro modules override the total (rounding, group rules, etc.).
343 $additional_services_total = (float) apply_filters(
344 'yatra_booking_services_total',
345 $additional_services_total,
346 $additional_services_resolved,
347 $trip_id,
348 $travelers_count,
349 $duration_days_for_services
350 );
351
352 // ── Taxable Amount ────────────────────────────────────────────
353 // We DON'T add `$additional_services_total` again here. The Pro
354 // AdditionalServicesModule hooks into `yatra_calculate_subtotal`
355 // (above), which already folded selected services into `$subtotal`
356 // → `$discounted_subtotal`. Adding them a second time produced the
357 // visible double-count bug ($159 + $112 services = $271 subtotal,
358 // then $271 + $112 services = $383 net amount). `$additional_services_total`
359 // stays available in the result payload so the sidebar can render
360 // each service as a row for transparency.
361 $taxable_amount = $discounted_subtotal + $itinerary_costs_total;
362
363 // ── Taxes ───────────────────────────────────────────────────────
364 $tax_calculation = $this->calculateTaxes($taxable_amount);
365
366 $final_total = $taxable_amount;
367 if (!$tax_calculation['tax_inclusive']) {
368 $final_total += $tax_calculation['total_tax_amount'];
369 }
370
371 $final_total = (float) apply_filters('yatra_calculate_final_total', $final_total, [
372 'discounted_subtotal' => $discounted_subtotal,
373 'itinerary_costs_total' => $itinerary_costs_total,
374 'taxable_amount' => $taxable_amount,
375 'tax_calculation' => $tax_calculation,
376 'trip_id' => $trip_id,
377 'payment_method' => $payment_method,
378 ]);
379
380 // ── Payment amounts (FlexiblePayments Pro module) ───────────────
381 $payment_amounts = $this->calculatePaymentAmounts($final_total, $payment_method, [
382 'trip_id' => $trip_id,
383 'travelers_count' => $travelers_count,
384 // Tour start → Pro can force full payment when the tour is within
385 // the balance-due window (tour-anchored scheduled payments).
386 'travel_date' => $travel_date,
387 ]);
388
389 // ── Currency ────────────────────────────────────────────────────
390 $currency = SettingsService::getCurrency();
391
392 // ── Gross total (subtotal before discounts/taxes, includes services) ───
393 $gross_total = $subtotal;
394
395 // ── Dynamic-pricing breakdown (for the pricing-summary template) ─
396 // The DP module already has an `addPricingBreakdown` callback wired to
397 // the `yatra_price_breakdown` filter — but that filter was previously
398 // never fired anywhere, so the template's `$dynamic_pricing` block was
399 // dead code. We fire it here with the same context the per-unit DP
400 // filter received, so Pro DP can populate the breakdown row that the
401 // template renders.
402 //
403 // dp_total_adjustment is the signed dollar impact DP has on the trip
404 // subtotal:
405 // - For regular pricing: per-unit delta × travelers (the line 120
406 // filter is the single DP entry point).
407 // - For traveler_based pricing: DP is applied per-category inside
408 // calculateBaseAmount, so we recompute a "pristine" base amount
409 // using the same TripPricingService::resolveCategoryEffectivePrice
410 // anchor that calculateBaseAmount starts from, and subtract from
411 // the real (post-DP) base_amount.
412 // If the discount-stacking filter reverted DP (the Pro
413 // "discount_only" or "best_for_customer→discount" path
414 // mutated $base_amount back to its pre-DP value), the
415 // breakdown should reflect that — otherwise the per-category
416 // line items would still display DP-adjusted prices that
417 // don't match the final total customers actually pay.
418 //
419 // Free-only sites (no filter listener) hit the else branch
420 // exactly as before: $base_amount tracks the post-DP value,
421 // $dp_was_suppressed stays false, and the existing breakdown
422 // math runs unchanged. So the pre-feature display contract
423 // is bit-for-bit preserved.
424 $dp_was_suppressed = $pre_dp_base_amount > 0
425 && abs($base_amount - $pre_dp_base_amount) < 0.005;
426 $dp_per_unit_delta = $dp_was_suppressed
427 ? 0.0
428 : $unit_price - $unit_price_before_dp;
429 $dp_total_adjustment = 0.0;
430 $category_prices_post_dp = [];
431 if ($pricing_type === 'regular') {
432 $dp_total_adjustment = $dp_per_unit_delta * max(1, $travelers_count);
433 } elseif ($pricing_type === 'traveler_based' && !empty($price_types)) {
434 $pre_dp_base = 0.0;
435 foreach ($price_types as $pt) {
436 $pt_arr = (array) $pt;
437 $category_id = $pt_arr['category_id'] ?? 0;
438 $pricing_mode = $pt_arr['pricing_mode'] ?? 'per_person';
439 $pre_dp_price = (float) \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr);
440 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
441
442 // Capture the DP-adjusted per-category price so the pricing-summary
443 // category row can show the price the customer is actually paying.
444 // When DP was suppressed by the stacking enforcer, skip the DP
445 // filter entirely and surface the pristine pre-DP price so the
446 // breakdown matches the final total.
447 if ($dp_was_suppressed) {
448 $post_dp_price = $pre_dp_price;
449 } else {
450 $post_dp_price = (float) apply_filters('yatra_booking_trip_price', $pre_dp_price, $trip_id, [
451 'departure_date' => $travel_date,
452 'spots_remaining' => $spots_for_dp,
453 'availability_id' => $availability_id_for_dp,
454 'category_id' => $category_id,
455 'original_price' => (float) ($pt_arr['original_price'] ?? 0),
456 'discounted_price' => (float) ($pt_arr['discounted_price'] ?? $pt_arr['sale_price'] ?? 0),
457 ]);
458 }
459 if ($category_id) {
460 $category_prices_post_dp[(string) $category_id] = $post_dp_price;
461 }
462
463 $pre_dp_base += TripPricingService::categoryLineSubtotal($pt_arr, $count, $pre_dp_price);
464 }
465 $dp_total_adjustment = $dp_was_suppressed ? 0.0 : ($base_amount - $pre_dp_base);
466 }
467 $price_breakdown = (array) apply_filters('yatra_price_breakdown', [], $trip_id, [
468 'price' => $unit_price,
469 'original_price' => $original_price,
470 'discounted_price' => $discounted_price,
471 'departure_date' => $travel_date,
472 'spots_remaining' => $spots_for_dp,
473 'availability_id' => $availability_id_for_dp,
474 'travelers_count' => $travelers_count,
475 'gross_total' => $gross_total,
476 ]);
477 $dynamic_pricing_breakdown = $price_breakdown['dynamic_pricing'] ?? null;
478
479 // ── Build result ────────────────────────────────────────────
480 $pricing_data = [
481 // Price info
482 'original_price' => $original_price,
483 'discounted_price' => $discounted_price,
484 'unit_price' => $unit_price,
485 'unit_price_before_dp' => $unit_price_before_dp,
486 'dp_per_unit_delta' => $dp_per_unit_delta,
487 'dp_total_adjustment' => $dp_total_adjustment,
488 'category_prices_post_dp' => $category_prices_post_dp,
489 'dynamic_pricing' => $dynamic_pricing_breakdown,
490 'pricing_type' => $pricing_type,
491
492 // Base amounts
493 'base_amount' => $base_amount,
494 'subtotal' => $subtotal,
495 'discounted_subtotal' => $discounted_subtotal,
496 'taxable_amount' => $taxable_amount,
497 'gross_total' => $gross_total,
498
499 // Discounts
500 'group_discount' => $group_discount_data,
501 'coupon_discount' => $coupon_discount_data,
502 'total_discount_amount' => $total_discount_amount,
503
504 // Taxes
505 'tax_calculation' => $tax_calculation,
506
507 // Final amounts
508 'final_total' => $final_total,
509 'amount_due' => $payment_amounts['amount_due'],
510 'amount_paid' => $payment_amounts['amount_paid'],
511
512 // Payment & currency
513 'payment_method' => $payment_method,
514 'currency' => $currency,
515
516 // Itinerary costs
517 // Additional services with `selected` / `calculated_price` flags
518 // — Checkout::getAdditionalServices() reads this; the sidebar's
519 // selected-services loop renders one row per ticked service.
520 'additional_services' => $additional_services_resolved,
521 'services_total' => $additional_services_total,
522
523 'itinerary_costs' => $itinerary_costs,
524 'itinerary_costs_total' => $itinerary_costs_total,
525
526 // Metadata
527 'travelers_count' => $travelers_count,
528 'traveler_counts' => $traveler_counts,
529 'travel_date' => $travel_date,
530 'trip_id' => $trip_id,
531 ];
532
533 return (array) apply_filters('yatra_after_calculation_result', $pricing_data, $params);
534 }
535
536 /**
537 * Resolve the discounted price from availability or trip
538 * Priority: availability discounted → availability original → trip discounted → trip sale → 0
539 */
540 private function resolveDiscountedPrice(object $trip, ?object $availability): float
541 {
542 if ($availability) {
543 if (!empty($availability->discounted_price) && (float) $availability->discounted_price > 0) {
544 return (float) $availability->discounted_price;
545 }
546 if (!empty($availability->original_price) && (float) $availability->original_price > 0) {
547 return (float) $availability->original_price;
548 }
549 }
550
551 if (!empty($trip->discounted_price) && (float) $trip->discounted_price > 0) {
552 return (float) $trip->discounted_price;
553 }
554 if (!empty($trip->sale_price) && (float) $trip->sale_price > 0) {
555 return (float) $trip->sale_price;
556 }
557
558 return 0.0;
559 }
560
561 /**
562 * Resolve pricing type from availability or trip
563 */
564 private function resolvePricingType(object $trip, ?object $availability): string
565 {
566 // Authoritative model matches {@see TripPricingService::resolvePricingType}:
567 // explicit "regular" must not be overridden by inherited/stale availability price_types.
568 $trip_model = TripPricingService::resolvePricingType($trip);
569
570 if ($trip_model !== 'traveler_based') {
571 return 'regular';
572 }
573
574 // Traveler-based trip: use per-date categories when that row defines them; otherwise caller
575 // falls back to trip-level price_types via {@see self::resolvePriceTypes()}.
576 if ($availability && !empty($availability->price_types)) {
577 $types = is_string($availability->price_types)
578 ? json_decode($availability->price_types, true)
579 : $availability->price_types;
580 if (!empty($types) && is_array($types)) {
581 return 'traveler_based';
582 }
583 }
584
585 return 'traveler_based';
586 }
587
588 /**
589 * Resolve price_types array from availability or trip
590 */
591 private function resolvePriceTypes(object $trip, ?object $availability): array
592 {
593 if (TripPricingService::resolvePricingType($trip) === 'regular') {
594 return [];
595 }
596
597 $types = [];
598
599 // Priority 1: Availability price_types
600 if ($availability && !empty($availability->price_types)) {
601 $types = is_string($availability->price_types)
602 ? json_decode($availability->price_types, true)
603 : $availability->price_types;
604 }
605
606 // Priority 2: Trip price_types
607 if (empty($types) && !empty($trip->price_types)) {
608 if (is_string($trip->price_types)) {
609 $types = json_decode($trip->price_types, true) ?: [];
610 } else {
611 $types = is_array($trip->price_types) ? $trip->price_types : [];
612 }
613 }
614
615 if (empty($types)) {
616 return [];
617 }
618
619 // Resolve pricing_mode / group-size limits authoritatively from the
620 // TravelerCategory. This must OVERRIDE (not just fill-when-empty): the
621 // price_types coming from a stored availability row or session can carry
622 // a literal 'per_person' placeholder that an empty() check would skip,
623 // which silently charged a per-group category by headcount. For
624 // per-person categories this resolves back to 'per_person' (a no-op), so
625 // the booking total for every existing trip is unchanged.
626 $types = TripPricingService::applyCategoryPricingMeta($types);
627
628 return $types;
629 }
630
631 /**
632 * Get category metadata (pricing_mode, etc.) by IDs
633 */
634 private function getCategoryMetadata(array $category_ids): array
635 {
636 // Use repository instead of direct database query
637 $repository = new \Yatra\Repositories\TravelerCategoryRepository();
638 return $repository->getMetadataByIds($category_ids);
639 }
640
641 /**
642 * Resolve availability data
643 */
644 private function resolveAvailability(int $trip_id, string $travel_date, ?int $availability_id, string $departure_time = ''): ?object
645 {
646 if (!class_exists('\Yatra\Services\AvailabilityService')) {
647 return null;
648 }
649
650 try {
651 // Try by availability_id first via repository
652 if (!empty($availability_id) && class_exists('\Yatra\Repositories\AvailabilityRepository')) {
653 $repo = new \Yatra\Repositories\AvailabilityRepository();
654 if (method_exists($repo, 'find')) {
655 $result = $repo->find($availability_id);
656 if ($result) {
657 return $result;
658 }
659 }
660 }
661
662 // Fallback: resolve through centralized resolver so rule-generated slots
663 // (virtual, no numeric availability_id) use the exact same data shape as the UI.
664 if (!empty($travel_date)) {
665 $resolver = new \Yatra\Services\AvailabilityResolutionService();
666 return $resolver->resolveAvailabilityForDate(
667 $trip_id,
668 $travel_date,
669 $departure_time !== '' ? $departure_time : null
670 );
671 }
672 } catch (\Exception $e) {
673 // Availability lookup failed, continue with trip-level pricing
674 }
675
676 return null;
677 }
678
679 /**
680 * Calculate base amount (regular or traveler-based pricing)
681 *
682 * For traveler-based: uses per-category effective prices × counts
683 * For regular: uses unit_price × travelers_count
684 */
685 /**
686 * Compute the base amount for a trip WITHOUT firing the Dynamic
687 * Pricing filter, regardless of pricing type.
688 *
689 * Used by the Advanced Discount stacking enforcer to detect whether
690 * DP actually adjusted the booking (compare actual base_amount to
691 * the no-DP base_amount) and to compute the "discount-only" alt
692 * scenario where DP must be fully neutralized.
693 *
694 * For regular pricing this is just unit_price × travelers_count.
695 * For traveler-based pricing it walks the per-category prices via
696 * the canonical TripPricingService anchor (identical math to the
697 * breakdown's `$pre_dp_base` at lines ~384-415 of calculatePricing).
698 * Per-category prices come from saved trip data — DP filter is
699 * deliberately NOT applied.
700 */
701 public function computeBaseAmountWithoutDynamicPricing(
702 float $unit_price,
703 int $travelers_count,
704 array $traveler_counts,
705 string $pricing_type,
706 array $price_types
707 ): float {
708 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
709 $base_amount = 0.0;
710 foreach ($price_types as $pt) {
711 $pt = (array) $pt;
712 $category_id = $pt['category_id'] ?? 0;
713 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
714 $category_price = (float) TripPricingService::resolveCategoryEffectivePrice($pt);
715 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
716
717 $base_amount += TripPricingService::categoryLineSubtotal($pt, $count, $category_price);
718 }
719 return round($base_amount, 2);
720 }
721
722 return round($unit_price * max(1, $travelers_count), 2);
723 }
724
725 /**
726 * Compute the base amount for a trip from a given per-unit price.
727 *
728 * Public so Pro extensions (e.g. the Advanced Discount stacking-
729 * enforcer) can re-derive the base when they need to recompute the
730 * downstream pipeline with a different unit price — for example,
731 * the "discount_only" stacking mode reverts the DP adjustment and
732 * has to recompute base/subtotal/discounts against the pre-DP price.
733 */
734 public function calculateBaseAmount(
735 float $unit_price,
736 int $travelers_count,
737 array $traveler_counts,
738 string $pricing_type,
739 array $price_types,
740 int $trip_id,
741 string $travel_date = '',
742 ?int $spots_remaining = null,
743 ?int $availability_id_for_dp = null
744 ): float {
745 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
746 $base_amount = 0.0;
747
748 foreach ($price_types as $pt) {
749 $pt = (array) $pt;
750 $category_id = $pt['category_id'] ?? 0;
751 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
752
753 $category_price = TripPricingService::resolveCategoryEffectivePrice($pt);
754
755 // Apply Dynamic Pricing filter per category
756 $category_price = (float) apply_filters('yatra_booking_trip_price', $category_price, $trip_id, [
757 'departure_date' => $travel_date,
758 'category_id' => $category_id,
759 'spots_remaining' => $spots_remaining,
760 'availability_id' => $availability_id_for_dp,
761 'original_price' => (float) ($pt['original_price'] ?? 0),
762 'discounted_price' => (float) ($pt['discounted_price'] ?? $pt['sale_price'] ?? 0),
763 ]);
764
765 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
766
767 // Single source of truth for the per-category line amount
768 // (per-person × count, flat per-group, or per-block group pricing).
769 $base_amount += TripPricingService::categoryLineSubtotal($pt, $count, $category_price);
770 }
771
772 return round($base_amount, 2);
773 }
774
775 // Regular pricing
776 return round($unit_price * $travelers_count, 2);
777 }
778
779 /**
780 * Calculate group discount
781 */
782 public function calculateGroupDiscount(
783 int $trip_id,
784 float $subtotal,
785 int $travelers_count,
786 array $traveler_counts,
787 string $pricing_type,
788 array $price_types
789 ): array {
790 $group_discount_data = [
791 'amount' => 0,
792 'label' => __('Group Discount', 'yatra'),
793 'code' => null,
794 ];
795
796 // Try DiscountService (requires Pro AdvancedDiscount module to enable)
797 if (class_exists('\Yatra\Services\DiscountService') && method_exists('\Yatra\Services\DiscountService', 'calculateGroupDiscount')) {
798 try {
799 $discountService = new DiscountService();
800 $discountResult = $discountService->calculateGroupDiscount($trip_id, $traveler_counts, $price_types);
801
802 // Debug: Log the calculation result
803 if (WP_DEBUG && WP_DEBUG_LOG) {
804 $priceTypesDebug = [];
805 foreach ($price_types as $pt) {
806 $pt = (object) $pt;
807 $priceTypesDebug[] = [
808 'category_id' => $pt->category_id ?? null,
809 'price' => $pt->effective_price ?? $pt->sale_price ?? $pt->original_price ?? 0
810 ];
811 }
812
813 }
814
815 if ($discountResult && !empty($discountResult['amount'])) {
816 return [
817 'amount' => (float) ($discountResult['amount'] ?? 0),
818 'label' => $discountResult['label'] ?? __('Group Discount', 'yatra'),
819 'code' => $discountResult['code'] ?? null,
820 ];
821 }
822 } catch (\Exception $e) {
823
824 }
825 }
826
827 // Fall back to filter (Pro plugins can hook here)
828 return (array) apply_filters('yatra_calculate_group_discount', $group_discount_data, [
829 'subtotal' => $subtotal,
830 'travelers_count' => $travelers_count,
831 'traveler_counts' => $traveler_counts,
832 'pricing_type' => $pricing_type,
833 'price_types' => $price_types,
834 'trip_id' => $trip_id,
835 ]);
836 }
837
838 /**
839 * Calculate taxes
840 */
841 private function calculateTaxes(float $subtotal): array
842 {
843 $enable_tax = SettingsService::get('enable_tax', false);
844 $tax_rate = (float) SettingsService::get('tax_rate', 0);
845 $tax_label = SettingsService::get('tax_label', 'Tax');
846 $tax_inclusive = (bool) SettingsService::get('tax_inclusive', false);
847 $multiple_taxes_enabled = (bool) SettingsService::get('multiple_taxes_enabled', false);
848 $multiple_taxes = SettingsService::get('multiple_taxes', []);
849
850 if (!is_array($multiple_taxes)) {
851 $multiple_taxes = [];
852 }
853
854 // If tax is enabled and taxes are configured, honor them even if the boolean
855 // "multiple_taxes_enabled" setting wasn't toggled in the UI (common case).
856 if ($enable_tax && !empty($multiple_taxes)) {
857 $multiple_taxes_enabled = true;
858 } elseif (count($multiple_taxes) > 1) {
859 $multiple_taxes_enabled = true;
860 }
861
862 $tax_breakdown = [];
863 $total_tax_amount = 0.0;
864
865 if ($multiple_taxes_enabled && !empty($multiple_taxes)) {
866 foreach ($multiple_taxes as $tax) {
867 $rate = isset($tax['rate']) ? (float) $tax['rate'] : 0;
868 $name = $tax['name'] ?? 'Tax';
869
870 if ($rate > 0) {
871 $amount = $tax_inclusive
872 ? $subtotal * ($rate / (100 + $rate))
873 : $subtotal * ($rate / 100);
874 $amount = round($amount, 2);
875
876 $tax_breakdown[] = ['name' => $name, 'rate' => $rate, 'amount' => $amount];
877 $total_tax_amount += $amount;
878 }
879 }
880 } elseif ($enable_tax && $tax_rate > 0) {
881 $amount = $tax_inclusive
882 ? $subtotal * ($tax_rate / (100 + $tax_rate))
883 : $subtotal * ($tax_rate / 100);
884 $amount = round($amount, 2);
885
886 $tax_breakdown[] = ['name' => $tax_label, 'rate' => $tax_rate, 'amount' => $amount];
887 $total_tax_amount = $amount;
888 }
889
890 return [
891 'enable_tax' => $enable_tax,
892 'tax_rate' => $tax_rate,
893 'tax_label' => $tax_label,
894 'tax_inclusive' => $tax_inclusive,
895 'multiple_taxes_enabled' => $multiple_taxes_enabled,
896 'multiple_taxes' => $multiple_taxes,
897 'tax_breakdown' => $tax_breakdown,
898 'total_tax_amount' => $total_tax_amount,
899 'subtotal' => $subtotal,
900 'total_with_tax' => $tax_inclusive ? $subtotal : ($subtotal + $total_tax_amount),
901 ];
902 }
903
904 /**
905 * Calculate payment amounts
906 *
907 * Free: full payment only.
908 * Pro FlexiblePayments: deposit/partial via `yatra_calculate_amount_due`.
909 *
910 * $context carries `trip_id` (and may carry more later). It is forwarded
911 * to every payment-related filter so Pro can apply per-trip overrides
912 * (e.g. trip.deposit_amount, trip.deposit_percentage) instead of only
913 * the site-wide settings.
914 */
915 private function calculatePaymentAmounts(float $final_total, string $payment_method, array $context): array
916 {
917 $amount_due = $final_total;
918 $amount_paid = 0.0;
919
920 // Apply Pro FlexiblePayments filter for deposit/partial. $context lets
921 // Pro look up the trip and honour per-trip overrides.
922 $amount_due = (float) apply_filters('yatra_calculate_amount_due', $amount_due, $final_total, $payment_method, $context);
923
924 // Fallback: if no Pro module handled it, use basic logic (never use === on floats)
925 $flexible_enabled = apply_filters('yatra_flexible_payments_enabled', false);
926 $unchanged = abs($amount_due - $final_total) < 0.000001;
927 if ($unchanged && $payment_method !== 'full' && $flexible_enabled) {
928 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
929 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
930 if ($payment_method === 'deposit') {
931 $amount_due = round($final_total * ($deposit_percentage / 100), 2);
932 } elseif ($payment_method === 'partial') {
933 $amount_due = round($final_total * ($partial_percentage / 100), 2);
934 }
935 }
936
937 $payment_data = (array) apply_filters('yatra_calculate_payment_amounts', [
938 'amount_due' => $amount_due,
939 'amount_paid' => $amount_paid,
940 'payment_method' => $payment_method,
941 'final_total' => $final_total,
942 ], $context);
943
944 return [
945 'amount_due' => (float) ($payment_data['amount_due'] ?? $amount_due),
946 'amount_paid' => (float) ($payment_data['amount_paid'] ?? $amount_paid),
947 ];
948 }
949
950 /**
951 * Calculate pricing from session data
952 *
953 * Used by booking-content.php template for checkout summary
954 */
955 public function calculateFromSession(array $session_data, string $coupon_code = '', string $payment_method = 'full'): array
956 {
957 $trip_id = (int) ($session_data['trip_id'] ?? 0);
958
959 if (empty($trip_id)) {
960 throw new \InvalidArgumentException('Trip ID is required in session data');
961 }
962
963 $payment_method = strtolower(trim($payment_method));
964 if ($payment_method === '') {
965 $payment_method = 'full';
966 }
967
968 $params = [
969 'trip_id' => $trip_id,
970 'travelers_count' => (int) ($session_data['travelers'] ?? 1),
971 'traveler_counts' => $session_data['traveler_counts'] ?? [],
972 'travel_date' => $session_data['travel_date'] ?? '',
973 'departure_time' => $session_data['departure_time'] ?? '',
974 'coupon_code' => $coupon_code,
975 'payment_method' => $payment_method,
976 'selected_services' => $session_data['additional_services'] ?? [],
977 'availability_id' => !empty($session_data['availability_id']) ? (int) $session_data['availability_id'] : null,
978 ];
979
980 $params = (array) apply_filters('yatra_session_calculation_params', $params, $session_data);
981
982 return $this->calculatePricing($params);
983 }
984
985 /**
986 * Quick pricing calculation for simple cases
987 */
988 public function quickCalculate(int $trip_id, int $travelers_count, string $travel_date = ''): array
989 {
990 return $this->calculatePricing([
991 'trip_id' => $trip_id,
992 'travelers_count' => $travelers_count,
993 'travel_date' => $travel_date,
994 'payment_method' => 'full',
995 ]);
996 }
997 }
998