PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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.4, at app/Services/CalculationService.php

882 lines 39.2 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 // ── Itinerary Costs ─────────────────────────────────────────
205 $itinerary_costs = apply_filters('yatra_booking_itinerary_costs', [], $trip_id, $travelers_count, $traveler_counts, $travel_date);
206 $itinerary_costs_total = 0;
207 if (!empty($itinerary_costs) && is_array($itinerary_costs)) {
208 foreach ($itinerary_costs as $cost) {
209 $itinerary_costs_total += (float) ($cost['total_cost'] ?? 0);
210 }
211 }
212
213 // ── Additional Services (if any) ─────────────────────────────
214 // Pull the available services for this trip via the Pro filter and
215 // mark which ones are selected (or required/included). Pricing data
216 // ships back to the template scope so the Pricing Summary partial's
217 // selected-services loop has something to render — previously
218 // `pricing_calculation['additional_services']` was never populated
219 // here, so the per-row services list in the sidebar always rendered
220 // empty even when the standalone Additional Services card showed
221 // ticked checkboxes.
222 $available_services = (array) apply_filters(
223 'yatra_booking_additional_services',
224 [],
225 $trip_id,
226 $travelers_count,
227 $traveler_counts,
228 $travel_date
229 );
230 $selected_service_ids = array_map('intval', (array) $selected_services);
231 $duration_days_for_services = (int) ($trip->duration_days ?? 1);
232 $additional_services_total = 0.0;
233 $additional_services_resolved = [];
234 foreach ($available_services as $svc) {
235 $svc = (array) $svc;
236 $svc_id = (int) ($svc['id'] ?? 0);
237 $is_required = !empty($svc['is_required']);
238 $is_included = !empty($svc['is_included']);
239 $is_selected = $is_required || $is_included || in_array($svc_id, $selected_service_ids, true);
240
241 $base_price = (float) ($svc['price'] ?? 0);
242 $price_per = $svc['price_per'] ?? 'person';
243 switch ($price_per) {
244 case 'person':
245 $calculated_price = $base_price * max(1, $travelers_count);
246 break;
247 case 'day':
248 $calculated_price = $base_price * max(1, $duration_days_for_services);
249 break;
250 case 'booking':
251 default:
252 $calculated_price = $base_price;
253 break;
254 }
255
256 $svc['selected'] = $is_selected;
257 $svc['calculated_price'] = $calculated_price;
258 $additional_services_resolved[] = $svc;
259
260 // Only paid (non-included) selected services contribute to the
261 // services subtotal. The taxable-amount line below will then
262 // include this naturally.
263 if ($is_selected && !$is_included) {
264 $additional_services_total += $calculated_price;
265 }
266 }
267 // Let Pro modules override the total (rounding, group rules, etc.).
268 $additional_services_total = (float) apply_filters(
269 'yatra_booking_services_total',
270 $additional_services_total,
271 $additional_services_resolved,
272 $trip_id,
273 $travelers_count,
274 $duration_days_for_services
275 );
276
277 // ── Taxable Amount ────────────────────────────────────────────
278 // We DON'T add `$additional_services_total` again here. The Pro
279 // AdditionalServicesModule hooks into `yatra_calculate_subtotal`
280 // (above), which already folded selected services into `$subtotal`
281 // → `$discounted_subtotal`. Adding them a second time produced the
282 // visible double-count bug ($159 + $112 services = $271 subtotal,
283 // then $271 + $112 services = $383 net amount). `$additional_services_total`
284 // stays available in the result payload so the sidebar can render
285 // each service as a row for transparency.
286 $taxable_amount = $discounted_subtotal + $itinerary_costs_total;
287
288 // ── Taxes ───────────────────────────────────────────────────────
289 $tax_calculation = $this->calculateTaxes($taxable_amount);
290
291 $final_total = $taxable_amount;
292 if (!$tax_calculation['tax_inclusive']) {
293 $final_total += $tax_calculation['total_tax_amount'];
294 }
295
296 $final_total = (float) apply_filters('yatra_calculate_final_total', $final_total, [
297 'discounted_subtotal' => $discounted_subtotal,
298 'itinerary_costs_total' => $itinerary_costs_total,
299 'taxable_amount' => $taxable_amount,
300 'tax_calculation' => $tax_calculation,
301 'trip_id' => $trip_id,
302 'payment_method' => $payment_method,
303 ]);
304
305 // ── Payment amounts (FlexiblePayments Pro module) ───────────────
306 $payment_amounts = $this->calculatePaymentAmounts($final_total, $payment_method, [
307 'trip_id' => $trip_id,
308 'travelers_count' => $travelers_count,
309 ]);
310
311 // ── Currency ────────────────────────────────────────────────────
312 $currency = SettingsService::getCurrency();
313
314 // ── Gross total (subtotal before discounts/taxes, includes services) ───
315 $gross_total = $subtotal;
316
317 // ── Dynamic-pricing breakdown (for the pricing-summary template) ─
318 // The DP module already has an `addPricingBreakdown` callback wired to
319 // the `yatra_price_breakdown` filter — but that filter was previously
320 // never fired anywhere, so the template's `$dynamic_pricing` block was
321 // dead code. We fire it here with the same context the per-unit DP
322 // filter received, so Pro DP can populate the breakdown row that the
323 // template renders.
324 //
325 // dp_total_adjustment is the signed dollar impact DP has on the trip
326 // subtotal:
327 // - For regular pricing: per-unit delta × travelers (the line 120
328 // filter is the single DP entry point).
329 // - For traveler_based pricing: DP is applied per-category inside
330 // calculateBaseAmount, so we recompute a "pristine" base amount
331 // using the same TripPricingService::resolveCategoryEffectivePrice
332 // anchor that calculateBaseAmount starts from, and subtract from
333 // the real (post-DP) base_amount.
334 $dp_per_unit_delta = $unit_price - $unit_price_before_dp;
335 $dp_total_adjustment = 0.0;
336 $category_prices_post_dp = [];
337 if ($pricing_type === 'regular') {
338 $dp_total_adjustment = $dp_per_unit_delta * max(1, $travelers_count);
339 } elseif ($pricing_type === 'traveler_based' && !empty($price_types)) {
340 $pre_dp_base = 0.0;
341 foreach ($price_types as $pt) {
342 $pt_arr = (array) $pt;
343 $category_id = $pt_arr['category_id'] ?? 0;
344 $pricing_mode = $pt_arr['pricing_mode'] ?? 'per_person';
345 $pre_dp_price = (float) \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr);
346 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
347
348 // Capture the DP-adjusted per-category price so the pricing-summary
349 // category row can show the price the customer is actually paying
350 // — admins asked for one consolidated row (post-DP) instead of a
351 // pre-DP row plus a separate "Dynamic Pricing" subtraction line.
352 $post_dp_price = (float) apply_filters('yatra_booking_trip_price', $pre_dp_price, $trip_id, [
353 'departure_date' => $travel_date,
354 'spots_remaining' => $spots_for_dp,
355 'availability_id' => $availability_id_for_dp,
356 'category_id' => $category_id,
357 'original_price' => (float) ($pt_arr['original_price'] ?? 0),
358 'discounted_price' => (float) ($pt_arr['discounted_price'] ?? $pt_arr['sale_price'] ?? 0),
359 ]);
360 if ($category_id) {
361 $category_prices_post_dp[(string) $category_id] = $post_dp_price;
362 }
363
364 if ($pricing_mode === 'per_group') {
365 if ($count > 0) {
366 $pre_dp_base += $pre_dp_price;
367 }
368 } else {
369 $pre_dp_base += $pre_dp_price * $count;
370 }
371 }
372 $dp_total_adjustment = $base_amount - $pre_dp_base;
373 }
374 $price_breakdown = (array) apply_filters('yatra_price_breakdown', [], $trip_id, [
375 'price' => $unit_price,
376 'original_price' => $original_price,
377 'discounted_price' => $discounted_price,
378 'departure_date' => $travel_date,
379 'spots_remaining' => $spots_for_dp,
380 'availability_id' => $availability_id_for_dp,
381 'travelers_count' => $travelers_count,
382 'gross_total' => $gross_total,
383 ]);
384 $dynamic_pricing_breakdown = $price_breakdown['dynamic_pricing'] ?? null;
385
386 // ── Build result ────────────────────────────────────────────
387 $pricing_data = [
388 // Price info
389 'original_price' => $original_price,
390 'discounted_price' => $discounted_price,
391 'unit_price' => $unit_price,
392 'unit_price_before_dp' => $unit_price_before_dp,
393 'dp_per_unit_delta' => $dp_per_unit_delta,
394 'dp_total_adjustment' => $dp_total_adjustment,
395 'category_prices_post_dp' => $category_prices_post_dp,
396 'dynamic_pricing' => $dynamic_pricing_breakdown,
397 'pricing_type' => $pricing_type,
398
399 // Base amounts
400 'base_amount' => $base_amount,
401 'subtotal' => $subtotal,
402 'discounted_subtotal' => $discounted_subtotal,
403 'taxable_amount' => $taxable_amount,
404 'gross_total' => $gross_total,
405
406 // Discounts
407 'group_discount' => $group_discount_data,
408 'coupon_discount' => $coupon_discount_data,
409 'total_discount_amount' => $total_discount_amount,
410
411 // Taxes
412 'tax_calculation' => $tax_calculation,
413
414 // Final amounts
415 'final_total' => $final_total,
416 'amount_due' => $payment_amounts['amount_due'],
417 'amount_paid' => $payment_amounts['amount_paid'],
418
419 // Payment & currency
420 'payment_method' => $payment_method,
421 'currency' => $currency,
422
423 // Itinerary costs
424 // Additional services with `selected` / `calculated_price` flags
425 // — Checkout::getAdditionalServices() reads this; the sidebar's
426 // selected-services loop renders one row per ticked service.
427 'additional_services' => $additional_services_resolved,
428 'services_total' => $additional_services_total,
429
430 'itinerary_costs' => $itinerary_costs,
431 'itinerary_costs_total' => $itinerary_costs_total,
432
433 // Metadata
434 'travelers_count' => $travelers_count,
435 'traveler_counts' => $traveler_counts,
436 'travel_date' => $travel_date,
437 'trip_id' => $trip_id,
438 ];
439
440 return (array) apply_filters('yatra_after_calculation_result', $pricing_data, $params);
441 }
442
443 /**
444 * Resolve the discounted price from availability or trip
445 * Priority: availability discounted → availability original → trip discounted → trip sale → 0
446 */
447 private function resolveDiscountedPrice(object $trip, ?object $availability): float
448 {
449 if ($availability) {
450 if (!empty($availability->discounted_price) && (float) $availability->discounted_price > 0) {
451 return (float) $availability->discounted_price;
452 }
453 if (!empty($availability->original_price) && (float) $availability->original_price > 0) {
454 return (float) $availability->original_price;
455 }
456 }
457
458 if (!empty($trip->discounted_price) && (float) $trip->discounted_price > 0) {
459 return (float) $trip->discounted_price;
460 }
461 if (!empty($trip->sale_price) && (float) $trip->sale_price > 0) {
462 return (float) $trip->sale_price;
463 }
464
465 return 0.0;
466 }
467
468 /**
469 * Resolve pricing type from availability or trip
470 */
471 private function resolvePricingType(object $trip, ?object $availability): string
472 {
473 // Authoritative model matches {@see TripPricingService::resolvePricingType}:
474 // explicit "regular" must not be overridden by inherited/stale availability price_types.
475 $trip_model = TripPricingService::resolvePricingType($trip);
476
477 if ($trip_model !== 'traveler_based') {
478 return 'regular';
479 }
480
481 // Traveler-based trip: use per-date categories when that row defines them; otherwise caller
482 // falls back to trip-level price_types via {@see self::resolvePriceTypes()}.
483 if ($availability && !empty($availability->price_types)) {
484 $types = is_string($availability->price_types)
485 ? json_decode($availability->price_types, true)
486 : $availability->price_types;
487 if (!empty($types) && is_array($types)) {
488 return 'traveler_based';
489 }
490 }
491
492 return 'traveler_based';
493 }
494
495 /**
496 * Resolve price_types array from availability or trip
497 */
498 private function resolvePriceTypes(object $trip, ?object $availability): array
499 {
500 if (TripPricingService::resolvePricingType($trip) === 'regular') {
501 return [];
502 }
503
504 $types = [];
505
506 // Priority 1: Availability price_types
507 if ($availability && !empty($availability->price_types)) {
508 $types = is_string($availability->price_types)
509 ? json_decode($availability->price_types, true)
510 : $availability->price_types;
511 }
512
513 // Priority 2: Trip price_types
514 if (empty($types) && !empty($trip->price_types)) {
515 if (is_string($trip->price_types)) {
516 $types = json_decode($trip->price_types, true) ?: [];
517 } else {
518 $types = is_array($trip->price_types) ? $trip->price_types : [];
519 }
520 }
521
522 if (empty($types)) {
523 return [];
524 }
525
526 // Enrich with pricing_mode from category metadata if missing
527 $needs_enrichment = false;
528 foreach ($types as $pt) {
529 $pt = (array) $pt;
530 if (empty($pt['pricing_mode'])) {
531 $needs_enrichment = true;
532 break;
533 }
534 }
535
536 if ($needs_enrichment) {
537 $category_ids = array_filter(array_map(function($pt) {
538 $pt = (array) $pt;
539 return isset($pt['category_id']) ? (int) $pt['category_id'] : null;
540 }, $types));
541
542 if (!empty($category_ids)) {
543 $category_meta = $this->getCategoryMetadata($category_ids);
544 foreach ($types as &$pt) {
545 if (is_object($pt)) $pt = (array) $pt;
546 $cat_id = isset($pt['category_id']) ? (int) $pt['category_id'] : null;
547 if ($cat_id && isset($category_meta[$cat_id]) && empty($pt['pricing_mode'])) {
548 $pt['pricing_mode'] = $category_meta[$cat_id]['pricing_mode'] ?? 'per_person';
549 }
550 }
551 unset($pt);
552 }
553 }
554
555 return $types;
556 }
557
558 /**
559 * Get category metadata (pricing_mode, etc.) by IDs
560 */
561 private function getCategoryMetadata(array $category_ids): array
562 {
563 // Use repository instead of direct database query
564 $repository = new \Yatra\Repositories\TravelerCategoryRepository();
565 return $repository->getMetadataByIds($category_ids);
566 }
567
568 /**
569 * Resolve availability data
570 */
571 private function resolveAvailability(int $trip_id, string $travel_date, ?int $availability_id, string $departure_time = ''): ?object
572 {
573 if (!class_exists('\Yatra\Services\AvailabilityService')) {
574 return null;
575 }
576
577 try {
578 // Try by availability_id first via repository
579 if (!empty($availability_id) && class_exists('\Yatra\Repositories\AvailabilityRepository')) {
580 $repo = new \Yatra\Repositories\AvailabilityRepository();
581 if (method_exists($repo, 'find')) {
582 $result = $repo->find($availability_id);
583 if ($result) {
584 return $result;
585 }
586 }
587 }
588
589 // Fallback: resolve through centralized resolver so rule-generated slots
590 // (virtual, no numeric availability_id) use the exact same data shape as the UI.
591 if (!empty($travel_date)) {
592 $resolver = new \Yatra\Services\AvailabilityResolutionService();
593 return $resolver->resolveAvailabilityForDate(
594 $trip_id,
595 $travel_date,
596 $departure_time !== '' ? $departure_time : null
597 );
598 }
599 } catch (\Exception $e) {
600 // Availability lookup failed, continue with trip-level pricing
601 }
602
603 return null;
604 }
605
606 /**
607 * Calculate base amount (regular or traveler-based pricing)
608 *
609 * For traveler-based: uses per-category effective prices × counts
610 * For regular: uses unit_price × travelers_count
611 */
612 private function calculateBaseAmount(
613 float $unit_price,
614 int $travelers_count,
615 array $traveler_counts,
616 string $pricing_type,
617 array $price_types,
618 int $trip_id,
619 string $travel_date = '',
620 ?int $spots_remaining = null,
621 ?int $availability_id_for_dp = null
622 ): float {
623 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
624 $base_amount = 0.0;
625
626 foreach ($price_types as $pt) {
627 $pt = (array) $pt;
628 $category_id = $pt['category_id'] ?? 0;
629 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
630
631 $category_price = TripPricingService::resolveCategoryEffectivePrice($pt);
632
633 // Apply Dynamic Pricing filter per category
634 $category_price = (float) apply_filters('yatra_booking_trip_price', $category_price, $trip_id, [
635 'departure_date' => $travel_date,
636 'category_id' => $category_id,
637 'spots_remaining' => $spots_remaining,
638 'availability_id' => $availability_id_for_dp,
639 'original_price' => (float) ($pt['original_price'] ?? 0),
640 'discounted_price' => (float) ($pt['discounted_price'] ?? $pt['sale_price'] ?? 0),
641 ]);
642
643 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
644
645 if ($pricing_mode === 'per_group') {
646 // Per group: charge flat price once if any travelers in this category
647 if ($count > 0) {
648 $base_amount += $category_price;
649 }
650 } else {
651 // Per person: charge per traveler
652 $base_amount += $category_price * $count;
653 }
654 }
655
656 return round($base_amount, 2);
657 }
658
659 // Regular pricing
660 return round($unit_price * $travelers_count, 2);
661 }
662
663 /**
664 * Calculate group discount
665 */
666 private function calculateGroupDiscount(
667 int $trip_id,
668 float $subtotal,
669 int $travelers_count,
670 array $traveler_counts,
671 string $pricing_type,
672 array $price_types
673 ): array {
674 $group_discount_data = [
675 'amount' => 0,
676 'label' => __('Group Discount', 'yatra'),
677 'code' => null,
678 ];
679
680 // Try DiscountService (requires Pro AdvancedDiscount module to enable)
681 if (class_exists('\Yatra\Services\DiscountService') && method_exists('\Yatra\Services\DiscountService', 'calculateGroupDiscount')) {
682 try {
683 $discountService = new DiscountService();
684 $discountResult = $discountService->calculateGroupDiscount($trip_id, $traveler_counts, $price_types);
685
686 // Debug: Log the calculation result
687 if (WP_DEBUG && WP_DEBUG_LOG) {
688 $priceTypesDebug = [];
689 foreach ($price_types as $pt) {
690 $pt = (object) $pt;
691 $priceTypesDebug[] = [
692 'category_id' => $pt->category_id ?? null,
693 'price' => $pt->effective_price ?? $pt->sale_price ?? $pt->original_price ?? 0
694 ];
695 }
696
697 }
698
699 if ($discountResult && !empty($discountResult['amount'])) {
700 return [
701 'amount' => (float) ($discountResult['amount'] ?? 0),
702 'label' => $discountResult['label'] ?? __('Group Discount', 'yatra'),
703 'code' => $discountResult['code'] ?? null,
704 ];
705 }
706 } catch (\Exception $e) {
707
708 }
709 }
710
711 // Fall back to filter (Pro plugins can hook here)
712 return (array) apply_filters('yatra_calculate_group_discount', $group_discount_data, [
713 'subtotal' => $subtotal,
714 'travelers_count' => $travelers_count,
715 'traveler_counts' => $traveler_counts,
716 'pricing_type' => $pricing_type,
717 'price_types' => $price_types,
718 'trip_id' => $trip_id,
719 ]);
720 }
721
722 /**
723 * Calculate taxes
724 */
725 private function calculateTaxes(float $subtotal): array
726 {
727 $enable_tax = SettingsService::get('enable_tax', false);
728 $tax_rate = (float) SettingsService::get('tax_rate', 0);
729 $tax_label = SettingsService::get('tax_label', 'Tax');
730 $tax_inclusive = (bool) SettingsService::get('tax_inclusive', false);
731 $multiple_taxes_enabled = (bool) SettingsService::get('multiple_taxes_enabled', false);
732 $multiple_taxes = SettingsService::get('multiple_taxes', []);
733
734 if (!is_array($multiple_taxes)) {
735 $multiple_taxes = [];
736 }
737
738 // If tax is enabled and taxes are configured, honor them even if the boolean
739 // "multiple_taxes_enabled" setting wasn't toggled in the UI (common case).
740 if ($enable_tax && !empty($multiple_taxes)) {
741 $multiple_taxes_enabled = true;
742 } elseif (count($multiple_taxes) > 1) {
743 $multiple_taxes_enabled = true;
744 }
745
746 $tax_breakdown = [];
747 $total_tax_amount = 0.0;
748
749 if ($multiple_taxes_enabled && !empty($multiple_taxes)) {
750 foreach ($multiple_taxes as $tax) {
751 $rate = isset($tax['rate']) ? (float) $tax['rate'] : 0;
752 $name = $tax['name'] ?? 'Tax';
753
754 if ($rate > 0) {
755 $amount = $tax_inclusive
756 ? $subtotal * ($rate / (100 + $rate))
757 : $subtotal * ($rate / 100);
758 $amount = round($amount, 2);
759
760 $tax_breakdown[] = ['name' => $name, 'rate' => $rate, 'amount' => $amount];
761 $total_tax_amount += $amount;
762 }
763 }
764 } elseif ($enable_tax && $tax_rate > 0) {
765 $amount = $tax_inclusive
766 ? $subtotal * ($tax_rate / (100 + $tax_rate))
767 : $subtotal * ($tax_rate / 100);
768 $amount = round($amount, 2);
769
770 $tax_breakdown[] = ['name' => $tax_label, 'rate' => $tax_rate, 'amount' => $amount];
771 $total_tax_amount = $amount;
772 }
773
774 return [
775 'enable_tax' => $enable_tax,
776 'tax_rate' => $tax_rate,
777 'tax_label' => $tax_label,
778 'tax_inclusive' => $tax_inclusive,
779 'multiple_taxes_enabled' => $multiple_taxes_enabled,
780 'multiple_taxes' => $multiple_taxes,
781 'tax_breakdown' => $tax_breakdown,
782 'total_tax_amount' => $total_tax_amount,
783 'subtotal' => $subtotal,
784 'total_with_tax' => $tax_inclusive ? $subtotal : ($subtotal + $total_tax_amount),
785 ];
786 }
787
788 /**
789 * Calculate payment amounts
790 *
791 * Free: full payment only.
792 * Pro FlexiblePayments: deposit/partial via `yatra_calculate_amount_due`.
793 *
794 * $context carries `trip_id` (and may carry more later). It is forwarded
795 * to every payment-related filter so Pro can apply per-trip overrides
796 * (e.g. trip.deposit_amount, trip.deposit_percentage) instead of only
797 * the site-wide settings.
798 */
799 private function calculatePaymentAmounts(float $final_total, string $payment_method, array $context): array
800 {
801 $amount_due = $final_total;
802 $amount_paid = 0.0;
803
804 // Apply Pro FlexiblePayments filter for deposit/partial. $context lets
805 // Pro look up the trip and honour per-trip overrides.
806 $amount_due = (float) apply_filters('yatra_calculate_amount_due', $amount_due, $final_total, $payment_method, $context);
807
808 // Fallback: if no Pro module handled it, use basic logic (never use === on floats)
809 $flexible_enabled = apply_filters('yatra_flexible_payments_enabled', false);
810 $unchanged = abs($amount_due - $final_total) < 0.000001;
811 if ($unchanged && $payment_method !== 'full' && $flexible_enabled) {
812 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
813 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
814 if ($payment_method === 'deposit') {
815 $amount_due = round($final_total * ($deposit_percentage / 100), 2);
816 } elseif ($payment_method === 'partial') {
817 $amount_due = round($final_total * ($partial_percentage / 100), 2);
818 }
819 }
820
821 $payment_data = (array) apply_filters('yatra_calculate_payment_amounts', [
822 'amount_due' => $amount_due,
823 'amount_paid' => $amount_paid,
824 'payment_method' => $payment_method,
825 'final_total' => $final_total,
826 ], $context);
827
828 return [
829 'amount_due' => (float) ($payment_data['amount_due'] ?? $amount_due),
830 'amount_paid' => (float) ($payment_data['amount_paid'] ?? $amount_paid),
831 ];
832 }
833
834 /**
835 * Calculate pricing from session data
836 *
837 * Used by booking-content.php template for checkout summary
838 */
839 public function calculateFromSession(array $session_data, string $coupon_code = '', string $payment_method = 'full'): array
840 {
841 $trip_id = (int) ($session_data['trip_id'] ?? 0);
842
843 if (empty($trip_id)) {
844 throw new \InvalidArgumentException('Trip ID is required in session data');
845 }
846
847 $payment_method = strtolower(trim($payment_method));
848 if ($payment_method === '') {
849 $payment_method = 'full';
850 }
851
852 $params = [
853 'trip_id' => $trip_id,
854 'travelers_count' => (int) ($session_data['travelers'] ?? 1),
855 'traveler_counts' => $session_data['traveler_counts'] ?? [],
856 'travel_date' => $session_data['travel_date'] ?? '',
857 'departure_time' => $session_data['departure_time'] ?? '',
858 'coupon_code' => $coupon_code,
859 'payment_method' => $payment_method,
860 'selected_services' => $session_data['additional_services'] ?? [],
861 'availability_id' => !empty($session_data['availability_id']) ? (int) $session_data['availability_id'] : null,
862 ];
863
864 $params = (array) apply_filters('yatra_session_calculation_params', $params, $session_data);
865
866 return $this->calculatePricing($params);
867 }
868
869 /**
870 * Quick pricing calculation for simple cases
871 */
872 public function quickCalculate(int $trip_id, int $travelers_count, string $travel_date = ''): array
873 {
874 return $this->calculatePricing([
875 'trip_id' => $trip_id,
876 'travelers_count' => $travelers_count,
877 'travel_date' => $travel_date,
878 'payment_method' => 'full',
879 ]);
880 }
881 }
882