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

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