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

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

1,018 lines 46.4 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 if ($pricing_mode === 'per_group') {
446 if ($count > 0) {
447 $pre_dp_base += $pre_dp_price;
448 }
449 } else {
450 $pre_dp_base += $pre_dp_price * $count;
451 }
452 }
453 $dp_total_adjustment = $dp_was_suppressed ? 0.0 : ($base_amount - $pre_dp_base);
454 }
455 $price_breakdown = (array) apply_filters('yatra_price_breakdown', [], $trip_id, [
456 'price' => $unit_price,
457 'original_price' => $original_price,
458 'discounted_price' => $discounted_price,
459 'departure_date' => $travel_date,
460 'spots_remaining' => $spots_for_dp,
461 'availability_id' => $availability_id_for_dp,
462 'travelers_count' => $travelers_count,
463 'gross_total' => $gross_total,
464 ]);
465 $dynamic_pricing_breakdown = $price_breakdown['dynamic_pricing'] ?? null;
466
467 // ── Build result ────────────────────────────────────────────
468 $pricing_data = [
469 // Price info
470 'original_price' => $original_price,
471 'discounted_price' => $discounted_price,
472 'unit_price' => $unit_price,
473 'unit_price_before_dp' => $unit_price_before_dp,
474 'dp_per_unit_delta' => $dp_per_unit_delta,
475 'dp_total_adjustment' => $dp_total_adjustment,
476 'category_prices_post_dp' => $category_prices_post_dp,
477 'dynamic_pricing' => $dynamic_pricing_breakdown,
478 'pricing_type' => $pricing_type,
479
480 // Base amounts
481 'base_amount' => $base_amount,
482 'subtotal' => $subtotal,
483 'discounted_subtotal' => $discounted_subtotal,
484 'taxable_amount' => $taxable_amount,
485 'gross_total' => $gross_total,
486
487 // Discounts
488 'group_discount' => $group_discount_data,
489 'coupon_discount' => $coupon_discount_data,
490 'total_discount_amount' => $total_discount_amount,
491
492 // Taxes
493 'tax_calculation' => $tax_calculation,
494
495 // Final amounts
496 'final_total' => $final_total,
497 'amount_due' => $payment_amounts['amount_due'],
498 'amount_paid' => $payment_amounts['amount_paid'],
499
500 // Payment & currency
501 'payment_method' => $payment_method,
502 'currency' => $currency,
503
504 // Itinerary costs
505 // Additional services with `selected` / `calculated_price` flags
506 // — Checkout::getAdditionalServices() reads this; the sidebar's
507 // selected-services loop renders one row per ticked service.
508 'additional_services' => $additional_services_resolved,
509 'services_total' => $additional_services_total,
510
511 'itinerary_costs' => $itinerary_costs,
512 'itinerary_costs_total' => $itinerary_costs_total,
513
514 // Metadata
515 'travelers_count' => $travelers_count,
516 'traveler_counts' => $traveler_counts,
517 'travel_date' => $travel_date,
518 'trip_id' => $trip_id,
519 ];
520
521 return (array) apply_filters('yatra_after_calculation_result', $pricing_data, $params);
522 }
523
524 /**
525 * Resolve the discounted price from availability or trip
526 * Priority: availability discounted → availability original → trip discounted → trip sale → 0
527 */
528 private function resolveDiscountedPrice(object $trip, ?object $availability): float
529 {
530 if ($availability) {
531 if (!empty($availability->discounted_price) && (float) $availability->discounted_price > 0) {
532 return (float) $availability->discounted_price;
533 }
534 if (!empty($availability->original_price) && (float) $availability->original_price > 0) {
535 return (float) $availability->original_price;
536 }
537 }
538
539 if (!empty($trip->discounted_price) && (float) $trip->discounted_price > 0) {
540 return (float) $trip->discounted_price;
541 }
542 if (!empty($trip->sale_price) && (float) $trip->sale_price > 0) {
543 return (float) $trip->sale_price;
544 }
545
546 return 0.0;
547 }
548
549 /**
550 * Resolve pricing type from availability or trip
551 */
552 private function resolvePricingType(object $trip, ?object $availability): string
553 {
554 // Authoritative model matches {@see TripPricingService::resolvePricingType}:
555 // explicit "regular" must not be overridden by inherited/stale availability price_types.
556 $trip_model = TripPricingService::resolvePricingType($trip);
557
558 if ($trip_model !== 'traveler_based') {
559 return 'regular';
560 }
561
562 // Traveler-based trip: use per-date categories when that row defines them; otherwise caller
563 // falls back to trip-level price_types via {@see self::resolvePriceTypes()}.
564 if ($availability && !empty($availability->price_types)) {
565 $types = is_string($availability->price_types)
566 ? json_decode($availability->price_types, true)
567 : $availability->price_types;
568 if (!empty($types) && is_array($types)) {
569 return 'traveler_based';
570 }
571 }
572
573 return 'traveler_based';
574 }
575
576 /**
577 * Resolve price_types array from availability or trip
578 */
579 private function resolvePriceTypes(object $trip, ?object $availability): array
580 {
581 if (TripPricingService::resolvePricingType($trip) === 'regular') {
582 return [];
583 }
584
585 $types = [];
586
587 // Priority 1: Availability price_types
588 if ($availability && !empty($availability->price_types)) {
589 $types = is_string($availability->price_types)
590 ? json_decode($availability->price_types, true)
591 : $availability->price_types;
592 }
593
594 // Priority 2: Trip price_types
595 if (empty($types) && !empty($trip->price_types)) {
596 if (is_string($trip->price_types)) {
597 $types = json_decode($trip->price_types, true) ?: [];
598 } else {
599 $types = is_array($trip->price_types) ? $trip->price_types : [];
600 }
601 }
602
603 if (empty($types)) {
604 return [];
605 }
606
607 // Enrich with pricing_mode from category metadata if missing
608 $needs_enrichment = false;
609 foreach ($types as $pt) {
610 $pt = (array) $pt;
611 if (empty($pt['pricing_mode'])) {
612 $needs_enrichment = true;
613 break;
614 }
615 }
616
617 if ($needs_enrichment) {
618 $category_ids = array_filter(array_map(function($pt) {
619 $pt = (array) $pt;
620 return isset($pt['category_id']) ? (int) $pt['category_id'] : null;
621 }, $types));
622
623 if (!empty($category_ids)) {
624 $category_meta = $this->getCategoryMetadata($category_ids);
625 foreach ($types as &$pt) {
626 if (is_object($pt)) $pt = (array) $pt;
627 $cat_id = isset($pt['category_id']) ? (int) $pt['category_id'] : null;
628 if ($cat_id && isset($category_meta[$cat_id]) && empty($pt['pricing_mode'])) {
629 $pt['pricing_mode'] = $category_meta[$cat_id]['pricing_mode'] ?? 'per_person';
630 }
631 }
632 unset($pt);
633 }
634 }
635
636 return $types;
637 }
638
639 /**
640 * Get category metadata (pricing_mode, etc.) by IDs
641 */
642 private function getCategoryMetadata(array $category_ids): array
643 {
644 // Use repository instead of direct database query
645 $repository = new \Yatra\Repositories\TravelerCategoryRepository();
646 return $repository->getMetadataByIds($category_ids);
647 }
648
649 /**
650 * Resolve availability data
651 */
652 private function resolveAvailability(int $trip_id, string $travel_date, ?int $availability_id, string $departure_time = ''): ?object
653 {
654 if (!class_exists('\Yatra\Services\AvailabilityService')) {
655 return null;
656 }
657
658 try {
659 // Try by availability_id first via repository
660 if (!empty($availability_id) && class_exists('\Yatra\Repositories\AvailabilityRepository')) {
661 $repo = new \Yatra\Repositories\AvailabilityRepository();
662 if (method_exists($repo, 'find')) {
663 $result = $repo->find($availability_id);
664 if ($result) {
665 return $result;
666 }
667 }
668 }
669
670 // Fallback: resolve through centralized resolver so rule-generated slots
671 // (virtual, no numeric availability_id) use the exact same data shape as the UI.
672 if (!empty($travel_date)) {
673 $resolver = new \Yatra\Services\AvailabilityResolutionService();
674 return $resolver->resolveAvailabilityForDate(
675 $trip_id,
676 $travel_date,
677 $departure_time !== '' ? $departure_time : null
678 );
679 }
680 } catch (\Exception $e) {
681 // Availability lookup failed, continue with trip-level pricing
682 }
683
684 return null;
685 }
686
687 /**
688 * Calculate base amount (regular or traveler-based pricing)
689 *
690 * For traveler-based: uses per-category effective prices × counts
691 * For regular: uses unit_price × travelers_count
692 */
693 /**
694 * Compute the base amount for a trip WITHOUT firing the Dynamic
695 * Pricing filter, regardless of pricing type.
696 *
697 * Used by the Advanced Discount stacking enforcer to detect whether
698 * DP actually adjusted the booking (compare actual base_amount to
699 * the no-DP base_amount) and to compute the "discount-only" alt
700 * scenario where DP must be fully neutralized.
701 *
702 * For regular pricing this is just unit_price × travelers_count.
703 * For traveler-based pricing it walks the per-category prices via
704 * the canonical TripPricingService anchor (identical math to the
705 * breakdown's `$pre_dp_base` at lines ~384-415 of calculatePricing).
706 * Per-category prices come from saved trip data — DP filter is
707 * deliberately NOT applied.
708 */
709 public function computeBaseAmountWithoutDynamicPricing(
710 float $unit_price,
711 int $travelers_count,
712 array $traveler_counts,
713 string $pricing_type,
714 array $price_types
715 ): float {
716 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
717 $base_amount = 0.0;
718 foreach ($price_types as $pt) {
719 $pt = (array) $pt;
720 $category_id = $pt['category_id'] ?? 0;
721 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
722 $category_price = (float) TripPricingService::resolveCategoryEffectivePrice($pt);
723 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
724
725 if ($pricing_mode === 'per_group') {
726 if ($count > 0) {
727 $base_amount += $category_price;
728 }
729 } else {
730 $base_amount += $category_price * $count;
731 }
732 }
733 return round($base_amount, 2);
734 }
735
736 return round($unit_price * max(1, $travelers_count), 2);
737 }
738
739 /**
740 * Compute the base amount for a trip from a given per-unit price.
741 *
742 * Public so Pro extensions (e.g. the Advanced Discount stacking-
743 * enforcer) can re-derive the base when they need to recompute the
744 * downstream pipeline with a different unit price — for example,
745 * the "discount_only" stacking mode reverts the DP adjustment and
746 * has to recompute base/subtotal/discounts against the pre-DP price.
747 */
748 public function calculateBaseAmount(
749 float $unit_price,
750 int $travelers_count,
751 array $traveler_counts,
752 string $pricing_type,
753 array $price_types,
754 int $trip_id,
755 string $travel_date = '',
756 ?int $spots_remaining = null,
757 ?int $availability_id_for_dp = null
758 ): float {
759 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
760 $base_amount = 0.0;
761
762 foreach ($price_types as $pt) {
763 $pt = (array) $pt;
764 $category_id = $pt['category_id'] ?? 0;
765 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
766
767 $category_price = TripPricingService::resolveCategoryEffectivePrice($pt);
768
769 // Apply Dynamic Pricing filter per category
770 $category_price = (float) apply_filters('yatra_booking_trip_price', $category_price, $trip_id, [
771 'departure_date' => $travel_date,
772 'category_id' => $category_id,
773 'spots_remaining' => $spots_remaining,
774 'availability_id' => $availability_id_for_dp,
775 'original_price' => (float) ($pt['original_price'] ?? 0),
776 'discounted_price' => (float) ($pt['discounted_price'] ?? $pt['sale_price'] ?? 0),
777 ]);
778
779 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
780
781 if ($pricing_mode === 'per_group') {
782 // Per group: charge flat price once if any travelers in this category
783 if ($count > 0) {
784 $base_amount += $category_price;
785 }
786 } else {
787 // Per person: charge per traveler
788 $base_amount += $category_price * $count;
789 }
790 }
791
792 return round($base_amount, 2);
793 }
794
795 // Regular pricing
796 return round($unit_price * $travelers_count, 2);
797 }
798
799 /**
800 * Calculate group discount
801 */
802 public function calculateGroupDiscount(
803 int $trip_id,
804 float $subtotal,
805 int $travelers_count,
806 array $traveler_counts,
807 string $pricing_type,
808 array $price_types
809 ): array {
810 $group_discount_data = [
811 'amount' => 0,
812 'label' => __('Group Discount', 'yatra'),
813 'code' => null,
814 ];
815
816 // Try DiscountService (requires Pro AdvancedDiscount module to enable)
817 if (class_exists('\Yatra\Services\DiscountService') && method_exists('\Yatra\Services\DiscountService', 'calculateGroupDiscount')) {
818 try {
819 $discountService = new DiscountService();
820 $discountResult = $discountService->calculateGroupDiscount($trip_id, $traveler_counts, $price_types);
821
822 // Debug: Log the calculation result
823 if (WP_DEBUG && WP_DEBUG_LOG) {
824 $priceTypesDebug = [];
825 foreach ($price_types as $pt) {
826 $pt = (object) $pt;
827 $priceTypesDebug[] = [
828 'category_id' => $pt->category_id ?? null,
829 'price' => $pt->effective_price ?? $pt->sale_price ?? $pt->original_price ?? 0
830 ];
831 }
832
833 }
834
835 if ($discountResult && !empty($discountResult['amount'])) {
836 return [
837 'amount' => (float) ($discountResult['amount'] ?? 0),
838 'label' => $discountResult['label'] ?? __('Group Discount', 'yatra'),
839 'code' => $discountResult['code'] ?? null,
840 ];
841 }
842 } catch (\Exception $e) {
843
844 }
845 }
846
847 // Fall back to filter (Pro plugins can hook here)
848 return (array) apply_filters('yatra_calculate_group_discount', $group_discount_data, [
849 'subtotal' => $subtotal,
850 'travelers_count' => $travelers_count,
851 'traveler_counts' => $traveler_counts,
852 'pricing_type' => $pricing_type,
853 'price_types' => $price_types,
854 'trip_id' => $trip_id,
855 ]);
856 }
857
858 /**
859 * Calculate taxes
860 */
861 private function calculateTaxes(float $subtotal): array
862 {
863 $enable_tax = SettingsService::get('enable_tax', false);
864 $tax_rate = (float) SettingsService::get('tax_rate', 0);
865 $tax_label = SettingsService::get('tax_label', 'Tax');
866 $tax_inclusive = (bool) SettingsService::get('tax_inclusive', false);
867 $multiple_taxes_enabled = (bool) SettingsService::get('multiple_taxes_enabled', false);
868 $multiple_taxes = SettingsService::get('multiple_taxes', []);
869
870 if (!is_array($multiple_taxes)) {
871 $multiple_taxes = [];
872 }
873
874 // If tax is enabled and taxes are configured, honor them even if the boolean
875 // "multiple_taxes_enabled" setting wasn't toggled in the UI (common case).
876 if ($enable_tax && !empty($multiple_taxes)) {
877 $multiple_taxes_enabled = true;
878 } elseif (count($multiple_taxes) > 1) {
879 $multiple_taxes_enabled = true;
880 }
881
882 $tax_breakdown = [];
883 $total_tax_amount = 0.0;
884
885 if ($multiple_taxes_enabled && !empty($multiple_taxes)) {
886 foreach ($multiple_taxes as $tax) {
887 $rate = isset($tax['rate']) ? (float) $tax['rate'] : 0;
888 $name = $tax['name'] ?? 'Tax';
889
890 if ($rate > 0) {
891 $amount = $tax_inclusive
892 ? $subtotal * ($rate / (100 + $rate))
893 : $subtotal * ($rate / 100);
894 $amount = round($amount, 2);
895
896 $tax_breakdown[] = ['name' => $name, 'rate' => $rate, 'amount' => $amount];
897 $total_tax_amount += $amount;
898 }
899 }
900 } elseif ($enable_tax && $tax_rate > 0) {
901 $amount = $tax_inclusive
902 ? $subtotal * ($tax_rate / (100 + $tax_rate))
903 : $subtotal * ($tax_rate / 100);
904 $amount = round($amount, 2);
905
906 $tax_breakdown[] = ['name' => $tax_label, 'rate' => $tax_rate, 'amount' => $amount];
907 $total_tax_amount = $amount;
908 }
909
910 return [
911 'enable_tax' => $enable_tax,
912 'tax_rate' => $tax_rate,
913 'tax_label' => $tax_label,
914 'tax_inclusive' => $tax_inclusive,
915 'multiple_taxes_enabled' => $multiple_taxes_enabled,
916 'multiple_taxes' => $multiple_taxes,
917 'tax_breakdown' => $tax_breakdown,
918 'total_tax_amount' => $total_tax_amount,
919 'subtotal' => $subtotal,
920 'total_with_tax' => $tax_inclusive ? $subtotal : ($subtotal + $total_tax_amount),
921 ];
922 }
923
924 /**
925 * Calculate payment amounts
926 *
927 * Free: full payment only.
928 * Pro FlexiblePayments: deposit/partial via `yatra_calculate_amount_due`.
929 *
930 * $context carries `trip_id` (and may carry more later). It is forwarded
931 * to every payment-related filter so Pro can apply per-trip overrides
932 * (e.g. trip.deposit_amount, trip.deposit_percentage) instead of only
933 * the site-wide settings.
934 */
935 private function calculatePaymentAmounts(float $final_total, string $payment_method, array $context): array
936 {
937 $amount_due = $final_total;
938 $amount_paid = 0.0;
939
940 // Apply Pro FlexiblePayments filter for deposit/partial. $context lets
941 // Pro look up the trip and honour per-trip overrides.
942 $amount_due = (float) apply_filters('yatra_calculate_amount_due', $amount_due, $final_total, $payment_method, $context);
943
944 // Fallback: if no Pro module handled it, use basic logic (never use === on floats)
945 $flexible_enabled = apply_filters('yatra_flexible_payments_enabled', false);
946 $unchanged = abs($amount_due - $final_total) < 0.000001;
947 if ($unchanged && $payment_method !== 'full' && $flexible_enabled) {
948 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
949 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
950 if ($payment_method === 'deposit') {
951 $amount_due = round($final_total * ($deposit_percentage / 100), 2);
952 } elseif ($payment_method === 'partial') {
953 $amount_due = round($final_total * ($partial_percentage / 100), 2);
954 }
955 }
956
957 $payment_data = (array) apply_filters('yatra_calculate_payment_amounts', [
958 'amount_due' => $amount_due,
959 'amount_paid' => $amount_paid,
960 'payment_method' => $payment_method,
961 'final_total' => $final_total,
962 ], $context);
963
964 return [
965 'amount_due' => (float) ($payment_data['amount_due'] ?? $amount_due),
966 'amount_paid' => (float) ($payment_data['amount_paid'] ?? $amount_paid),
967 ];
968 }
969
970 /**
971 * Calculate pricing from session data
972 *
973 * Used by booking-content.php template for checkout summary
974 */
975 public function calculateFromSession(array $session_data, string $coupon_code = '', string $payment_method = 'full'): array
976 {
977 $trip_id = (int) ($session_data['trip_id'] ?? 0);
978
979 if (empty($trip_id)) {
980 throw new \InvalidArgumentException('Trip ID is required in session data');
981 }
982
983 $payment_method = strtolower(trim($payment_method));
984 if ($payment_method === '') {
985 $payment_method = 'full';
986 }
987
988 $params = [
989 'trip_id' => $trip_id,
990 'travelers_count' => (int) ($session_data['travelers'] ?? 1),
991 'traveler_counts' => $session_data['traveler_counts'] ?? [],
992 'travel_date' => $session_data['travel_date'] ?? '',
993 'departure_time' => $session_data['departure_time'] ?? '',
994 'coupon_code' => $coupon_code,
995 'payment_method' => $payment_method,
996 'selected_services' => $session_data['additional_services'] ?? [],
997 'availability_id' => !empty($session_data['availability_id']) ? (int) $session_data['availability_id'] : null,
998 ];
999
1000 $params = (array) apply_filters('yatra_session_calculation_params', $params, $session_data);
1001
1002 return $this->calculatePricing($params);
1003 }
1004
1005 /**
1006 * Quick pricing calculation for simple cases
1007 */
1008 public function quickCalculate(int $trip_id, int $travelers_count, string $travel_date = ''): array
1009 {
1010 return $this->calculatePricing([
1011 'trip_id' => $trip_id,
1012 'travelers_count' => $travelers_count,
1013 'travel_date' => $travel_date,
1014 'payment_method' => 'full',
1015 ]);
1016 }
1017 }
1018