PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 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 All 84 releases
← All changes | app/Services/CalculationService.php +389 -84 3.0.2.9 → 3.0.16 View file →
@@ -91,8 +91,25 @@
91 91 }
92 92
93 93 $pricing_type = $this->resolvePricingType($trip, $availability);
94 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 + }
95 112
96 113 // ── Resolve per-unit price (Regular pricing) ────────────────────
97 114 // Priority: discounted_price → sale_price → original_price
98 115 $original_price = (float) ($trip->original_price ?? 0);
@@ -97,13 +114,21 @@
97 114 // Priority: discounted_price → sale_price → original_price
98 115 $original_price = (float) ($trip->original_price ?? 0);
99 116 $discounted_price = $this->resolveDiscountedPrice($trip, $availability);
100 117 $unit_price = $discounted_price > 0 ? $discounted_price : $original_price;
101 -
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 +
102 126 // Apply Dynamic Pricing filter (Pro DynamicPricingModule hooks here)
103 127 $unit_price = (float) apply_filters('yatra_booking_trip_price', $unit_price, $trip_id, [
104 128 'departure_date' => $travel_date,
105 - 'spots_remaining' => $trip->max_travelers ?? null,
129 + 'spots_remaining' => $spots_for_dp,
130 + 'availability_id' => $availability_id_for_dp,
106 131 'original_price' => $original_price,
107 132 'discounted_price' => $discounted_price,
108 133 ]);
109 134
@@ -114,9 +139,11 @@
114 139 $traveler_counts,
115 140 $pricing_type,
116 141 $price_types,
117 142 $trip_id,
118 - $travel_date
143 + $travel_date,
144 + $spots_for_dp,
145 + $availability_id_for_dp
119 146 );
120 147
121 148 $base_amount = (float) apply_filters('yatra_calculate_base_amount', $base_amount, [
122 149 'unit_price' => $unit_price,
@@ -170,11 +197,71 @@
170 197
171 198 // ── Total discounts ─────────────────────────────────────────────
172 199 $total_discount_amount = ($group_discount_data['amount'] ?? 0) + ($coupon_discount_data['calculated_amount'] ?? 0);
173 200 $total_discount_amount = min($total_discount_amount, $subtotal); // discount cannot exceed subtotal
174 -
201 +
175 202 $discounted_subtotal = max(0, $subtotal - $total_discount_amount);
176 -
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 +
177 264 // ── Itinerary Costs ─────────────────────────────────────────
178 265 $itinerary_costs = apply_filters('yatra_booking_itinerary_costs', [], $trip_id, $travelers_count, $traveler_counts, $travel_date);
179 266 $itinerary_costs_total = 0;
180 267 if (!empty($itinerary_costs) && is_array($itinerary_costs)) {
@@ -183,13 +270,96 @@
183 270 }
184 271 }
185 272
186 273 // ── Additional Services (if any) ─────────────────────────────
187 - $additional_services_total = 0;
188 - // Note: Additional services can be added here via filters
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 + );
189 351
190 - // ── Taxable Amount (includes itinerary costs and services) ────
191 - $taxable_amount = $discounted_subtotal + $itinerary_costs_total + $additional_services_total;
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;
192 362
193 363 // ── Taxes ───────────────────────────────────────────────────────
194 364 $tax_calculation = $this->calculateTaxes($taxable_amount);
195 365
@@ -210,8 +380,11 @@
210 380 // ── Payment amounts (FlexiblePayments Pro module) ───────────────
211 381 $payment_amounts = $this->calculatePaymentAmounts($final_total, $payment_method, [
212 382 'trip_id' => $trip_id,
213 383 'travelers_count' => $travelers_count,
384 + // Tour start → Pro can force full payment when the tour is within
385 + // the balance-due window (tour-anchored scheduled payments).
386 + 'travel_date' => $travel_date,
214 387 ]);
215 388
216 389 // ── Currency ────────────────────────────────────────────────────
217 390 $currency = SettingsService::getCurrency();
@@ -218,8 +391,92 @@
218 391
219 392 // ── Gross total (subtotal before discounts/taxes, includes services) ───
220 393 $gross_total = $subtotal;
221 394
395 + // ── Dynamic-pricing breakdown (for the pricing-summary template) ─
396 + // The DP module already has an `addPricingBreakdown` callback wired to
397 + // the `yatra_price_breakdown` filter — but that filter was previously
398 + // never fired anywhere, so the template's `$dynamic_pricing` block was
399 + // dead code. We fire it here with the same context the per-unit DP
400 + // filter received, so Pro DP can populate the breakdown row that the
401 + // template renders.
402 + //
403 + // dp_total_adjustment is the signed dollar impact DP has on the trip
404 + // subtotal:
405 + // - For regular pricing: per-unit delta × travelers (the line 120
406 + // filter is the single DP entry point).
407 + // - For traveler_based pricing: DP is applied per-category inside
408 + // calculateBaseAmount, so we recompute a "pristine" base amount
409 + // using the same TripPricingService::resolveCategoryEffectivePrice
410 + // anchor that calculateBaseAmount starts from, and subtract from
411 + // the real (post-DP) base_amount.
412 + // If the discount-stacking filter reverted DP (the Pro
413 + // "discount_only" or "best_for_customer→discount" path
414 + // mutated $base_amount back to its pre-DP value), the
415 + // breakdown should reflect that — otherwise the per-category
416 + // line items would still display DP-adjusted prices that
417 + // don't match the final total customers actually pay.
418 + //
419 + // Free-only sites (no filter listener) hit the else branch
420 + // exactly as before: $base_amount tracks the post-DP value,
421 + // $dp_was_suppressed stays false, and the existing breakdown
422 + // math runs unchanged. So the pre-feature display contract
423 + // is bit-for-bit preserved.
424 + $dp_was_suppressed = $pre_dp_base_amount > 0
425 + && abs($base_amount - $pre_dp_base_amount) < 0.005;
426 + $dp_per_unit_delta = $dp_was_suppressed
427 + ? 0.0
428 + : $unit_price - $unit_price_before_dp;
429 + $dp_total_adjustment = 0.0;
430 + $category_prices_post_dp = [];
431 + if ($pricing_type === 'regular') {
432 + $dp_total_adjustment = $dp_per_unit_delta * max(1, $travelers_count);
433 + } elseif ($pricing_type === 'traveler_based' && !empty($price_types)) {
434 + $pre_dp_base = 0.0;
435 + foreach ($price_types as $pt) {
436 + $pt_arr = (array) $pt;
437 + $category_id = $pt_arr['category_id'] ?? 0;
438 + $pricing_mode = $pt_arr['pricing_mode'] ?? 'per_person';
439 + $pre_dp_price = (float) \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr);
440 + $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
441 +
442 + // Capture the DP-adjusted per-category price so the pricing-summary
443 + // category row can show the price the customer is actually paying.
444 + // When DP was suppressed by the stacking enforcer, skip the DP
445 + // filter entirely and surface the pristine pre-DP price so the
446 + // breakdown matches the final total.
447 + if ($dp_was_suppressed) {
448 + $post_dp_price = $pre_dp_price;
449 + } else {
450 + $post_dp_price = (float) apply_filters('yatra_booking_trip_price', $pre_dp_price, $trip_id, [
451 + 'departure_date' => $travel_date,
452 + 'spots_remaining' => $spots_for_dp,
453 + 'availability_id' => $availability_id_for_dp,
454 + 'category_id' => $category_id,
455 + 'original_price' => (float) ($pt_arr['original_price'] ?? 0),
456 + 'discounted_price' => (float) ($pt_arr['discounted_price'] ?? $pt_arr['sale_price'] ?? 0),
457 + ]);
458 + }
459 + if ($category_id) {
460 + $category_prices_post_dp[(string) $category_id] = $post_dp_price;
461 + }
462 +
463 + $pre_dp_base += TripPricingService::categoryLineSubtotal($pt_arr, $count, $pre_dp_price);
464 + }
465 + $dp_total_adjustment = $dp_was_suppressed ? 0.0 : ($base_amount - $pre_dp_base);
466 + }
467 + $price_breakdown = (array) apply_filters('yatra_price_breakdown', [], $trip_id, [
468 + 'price' => $unit_price,
469 + 'original_price' => $original_price,
470 + 'discounted_price' => $discounted_price,
471 + 'departure_date' => $travel_date,
472 + 'spots_remaining' => $spots_for_dp,
473 + 'availability_id' => $availability_id_for_dp,
474 + 'travelers_count' => $travelers_count,
475 + 'gross_total' => $gross_total,
476 + ]);
477 + $dynamic_pricing_breakdown = $price_breakdown['dynamic_pricing'] ?? null;
478 +
222 479 // ── Build result ────────────────────────────────────────────
223 480 $pricing_data = [
224 481 // Price info
225 482 'original_price' => $original_price,
@@ -224,10 +481,15 @@
224 481 // Price info
225 482 'original_price' => $original_price,
226 483 'discounted_price' => $discounted_price,
227 484 'unit_price' => $unit_price,
485 + 'unit_price_before_dp' => $unit_price_before_dp,
486 + 'dp_per_unit_delta' => $dp_per_unit_delta,
487 + 'dp_total_adjustment' => $dp_total_adjustment,
488 + 'category_prices_post_dp' => $category_prices_post_dp,
489 + 'dynamic_pricing' => $dynamic_pricing_breakdown,
228 490 'pricing_type' => $pricing_type,
229 -
491 +
230 492 // Base amounts
231 493 'base_amount' => $base_amount,
232 494 'subtotal' => $subtotal,
233 495 'discounted_subtotal' => $discounted_subtotal,
@@ -251,8 +513,14 @@
251 513 'payment_method' => $payment_method,
252 514 'currency' => $currency,
253 515
254 516 // Itinerary costs
517 + // Additional services with `selected` / `calculated_price` flags
518 + // — Checkout::getAdditionalServices() reads this; the sidebar's
519 + // selected-services loop renders one row per ticked service.
520 + 'additional_services' => $additional_services_resolved,
521 + 'services_total' => $additional_services_total,
522 +
255 523 'itinerary_costs' => $itinerary_costs,
256 524 'itinerary_costs_total' => $itinerary_costs_total,
257 525
258 526 // Metadata
@@ -294,24 +562,28 @@
294 562 * Resolve pricing type from availability or trip
295 563 */
296 564 private function resolvePricingType(object $trip, ?object $availability): string
297 565 {
298 - // The trip's pricing_type defines the pricing MODEL (regular vs traveler_based)
299 - // Note: availability date's pricing_type enum ('regular','discounted','special') is
300 - // about price STATE, not pricing model — do NOT use it here.
301 - $pricing_type = $trip->pricing_type ?? 'regular';
302 -
303 - // Override to traveler_based if availability has its own price_types
566 + // Authoritative model matches {@see TripPricingService::resolvePricingType}:
567 + // explicit "regular" must not be overridden by inherited/stale availability price_types.
568 + $trip_model = TripPricingService::resolvePricingType($trip);
569 +
570 + if ($trip_model !== 'traveler_based') {
571 + return 'regular';
572 + }
573 +
574 + // Traveler-based trip: use per-date categories when that row defines them; otherwise caller
575 + // falls back to trip-level price_types via {@see self::resolvePriceTypes()}.
304 576 if ($availability && !empty($availability->price_types)) {
305 577 $types = is_string($availability->price_types)
306 578 ? json_decode($availability->price_types, true)
307 579 : $availability->price_types;
308 580 if (!empty($types) && is_array($types)) {
309 - $pricing_type = 'traveler_based';
581 + return 'traveler_based';
310 582 }
311 583 }
312 -
313 - return $pricing_type;
584 +
585 + return 'traveler_based';
314 586 }
315 587
316 588 /**
317 589 * Resolve price_types array from availability or trip
@@ -317,8 +589,12 @@
317 589 * Resolve price_types array from availability or trip
318 590 */
319 591 private function resolvePriceTypes(object $trip, ?object $availability): array
320 592 {
593 + if (TripPricingService::resolvePricingType($trip) === 'regular') {
594 + return [];
595 + }
596 +
321 597 $types = [];
322 598
323 599 // Priority 1: Availability price_types
324 600 if ($availability && !empty($availability->price_types)) {
@@ -339,37 +615,17 @@
339 615 if (empty($types)) {
340 616 return [];
341 617 }
342 618
343 - // Enrich with pricing_mode from category metadata if missing
344 - $needs_enrichment = false;
345 - foreach ($types as $pt) {
346 - $pt = (array) $pt;
347 - if (empty($pt['pricing_mode'])) {
348 - $needs_enrichment = true;
349 - break;
350 - }
351 - }
352 -
353 - if ($needs_enrichment) {
354 - $category_ids = array_filter(array_map(function($pt) {
355 - $pt = (array) $pt;
356 - return isset($pt['category_id']) ? (int) $pt['category_id'] : null;
357 - }, $types));
358 -
359 - if (!empty($category_ids)) {
360 - $category_meta = $this->getCategoryMetadata($category_ids);
361 - foreach ($types as &$pt) {
362 - if (is_object($pt)) $pt = (array) $pt;
363 - $cat_id = isset($pt['category_id']) ? (int) $pt['category_id'] : null;
364 - if ($cat_id && isset($category_meta[$cat_id]) && empty($pt['pricing_mode'])) {
365 - $pt['pricing_mode'] = $category_meta[$cat_id]['pricing_mode'] ?? 'per_person';
366 - }
367 - }
368 - unset($pt);
369 - }
370 - }
371 -
619 + // Resolve pricing_mode / group-size limits authoritatively from the
620 + // TravelerCategory. This must OVERRIDE (not just fill-when-empty): the
621 + // price_types coming from a stored availability row or session can carry
622 + // a literal 'per_person' placeholder that an empty() check would skip,
623 + // which silently charged a per-group category by headcount. For
624 + // per-person categories this resolves back to 'per_person' (a no-op), so
625 + // the booking total for every existing trip is unchanged.
626 + $types = TripPricingService::applyCategoryPricingMeta($types);
627 +
372 628 return $types;
373 629 }
374 630
375 631 /**
@@ -402,13 +658,17 @@
402 658 }
403 659 }
404 660 }
405 661
406 - // Fallback: lookup by trip + date + time (time-aware for day tours)
662 + // Fallback: resolve through centralized resolver so rule-generated slots
663 + // (virtual, no numeric availability_id) use the exact same data shape as the UI.
407 664 if (!empty($travel_date)) {
408 - $repo = new \Yatra\Repositories\AvailabilityRepository();
409 - $availabilityService = new \Yatra\Services\AvailabilityService($repo);
410 - return $availabilityService->getByTripAndDateTime($trip_id, $travel_date, $departure_time ?: null);
665 + $resolver = new \Yatra\Services\AvailabilityResolutionService();
666 + return $resolver->resolveAvailabilityForDate(
667 + $trip_id,
668 + $travel_date,
669 + $departure_time !== '' ? $departure_time : null
670 + );
411 671 }
412 672 } catch (\Exception $e) {
413 673 // Availability lookup failed, continue with trip-level pricing
414 674 }
@@ -421,16 +681,67 @@
421 681 *
422 682 * For traveler-based: uses per-category effective prices × counts
423 683 * For regular: uses unit_price × travelers_count
424 684 */
425 - private function calculateBaseAmount(
685 + /**
686 + * Compute the base amount for a trip WITHOUT firing the Dynamic
687 + * Pricing filter, regardless of pricing type.
688 + *
689 + * Used by the Advanced Discount stacking enforcer to detect whether
690 + * DP actually adjusted the booking (compare actual base_amount to
691 + * the no-DP base_amount) and to compute the "discount-only" alt
692 + * scenario where DP must be fully neutralized.
693 + *
694 + * For regular pricing this is just unit_price × travelers_count.
695 + * For traveler-based pricing it walks the per-category prices via
696 + * the canonical TripPricingService anchor (identical math to the
697 + * breakdown's `$pre_dp_base` at lines ~384-415 of calculatePricing).
698 + * Per-category prices come from saved trip data — DP filter is
699 + * deliberately NOT applied.
700 + */
701 + public function computeBaseAmountWithoutDynamicPricing(
426 702 float $unit_price,
427 703 int $travelers_count,
428 704 array $traveler_counts,
429 705 string $pricing_type,
706 + array $price_types
707 + ): float {
708 + if ($pricing_type === 'traveler_based' && !empty($price_types)) {
709 + $base_amount = 0.0;
710 + foreach ($price_types as $pt) {
711 + $pt = (array) $pt;
712 + $category_id = $pt['category_id'] ?? 0;
713 + $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
714 + $category_price = (float) TripPricingService::resolveCategoryEffectivePrice($pt);
715 + $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
716 +
717 + $base_amount += TripPricingService::categoryLineSubtotal($pt, $count, $category_price);
718 + }
719 + return round($base_amount, 2);
720 + }
721 +
722 + return round($unit_price * max(1, $travelers_count), 2);
723 + }
724 +
725 + /**
726 + * Compute the base amount for a trip from a given per-unit price.
727 + *
728 + * Public so Pro extensions (e.g. the Advanced Discount stacking-
729 + * enforcer) can re-derive the base when they need to recompute the
730 + * downstream pipeline with a different unit price — for example,
731 + * the "discount_only" stacking mode reverts the DP adjustment and
732 + * has to recompute base/subtotal/discounts against the pre-DP price.
733 + */
734 + public function calculateBaseAmount(
735 + float $unit_price,
736 + int $travelers_count,
737 + array $traveler_counts,
738 + string $pricing_type,
430 739 array $price_types,
431 740 int $trip_id,
432 - string $travel_date = ''
741 + string $travel_date = '',
742 + ?int $spots_remaining = null,
743 + ?int $availability_id_for_dp = null
433 744 ): float {
434 745 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
435 746 $base_amount = 0.0;
436 747
@@ -438,22 +749,16 @@
438 749 $pt = (array) $pt;
439 750 $category_id = $pt['category_id'] ?? 0;
440 751 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
441 752
442 - // Priority: discounted_price → sale_price → original_price
443 - $category_price = 0.0;
444 - if (!empty($pt['discounted_price']) && (float) $pt['discounted_price'] > 0) {
445 - $category_price = (float) $pt['discounted_price'];
446 - } elseif (!empty($pt['sale_price']) && (float) $pt['sale_price'] > 0) {
447 - $category_price = (float) $pt['sale_price'];
448 - } elseif (!empty($pt['original_price']) && (float) $pt['original_price'] > 0) {
449 - $category_price = (float) $pt['original_price'];
450 - }
753 + $category_price = TripPricingService::resolveCategoryEffectivePrice($pt);
451 754
452 755 // Apply Dynamic Pricing filter per category
453 756 $category_price = (float) apply_filters('yatra_booking_trip_price', $category_price, $trip_id, [
454 757 'departure_date' => $travel_date,
455 758 'category_id' => $category_id,
759 + 'spots_remaining' => $spots_remaining,
760 + 'availability_id' => $availability_id_for_dp,
456 761 'original_price' => (float) ($pt['original_price'] ?? 0),
457 762 'discounted_price' => (float) ($pt['discounted_price'] ?? $pt['sale_price'] ?? 0),
458 763 ]);
459 764
@@ -458,22 +763,16 @@
458 763 ]);
459 764
460 765 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
461 766
462 - if ($pricing_mode === 'per_group') {
463 - // Per group: charge flat price once if any travelers in this category
464 - if ($count > 0) {
465 - $base_amount += $category_price;
466 - }
467 - } else {
468 - // Per person: charge per traveler
469 - $base_amount += $category_price * $count;
470 - }
767 + // Single source of truth for the per-category line amount
768 + // (per-person × count, flat per-group, or per-block group pricing).
769 + $base_amount += TripPricingService::categoryLineSubtotal($pt, $count, $category_price);
471 770 }
472 -
771 +
473 772 return round($base_amount, 2);
474 773 }
475 -
774 +
476 775 // Regular pricing
477 776 return round($unit_price * $travelers_count, 2);
478 777 }
479 778
@@ -479,9 +778,9 @@
479 778
480 779 /**
481 780 * Calculate group discount
482 781 */
483 - private function calculateGroupDiscount(
782 + public function calculateGroupDiscount(
484 783 int $trip_id,
485 784 float $subtotal,
486 785 int $travelers_count,
487 786 array $traveler_counts,
@@ -603,26 +902,32 @@
603 902 }
604 903
605 904 /**
606 905 * Calculate payment amounts
607 - *
608 - * Free: full payment only
609 - * Pro FlexiblePayments: deposit/partial via yatra_calculate_amount_due filter
906 + *
907 + * Free: full payment only.
908 + * Pro FlexiblePayments: deposit/partial via `yatra_calculate_amount_due`.
909 + *
910 + * $context carries `trip_id` (and may carry more later). It is forwarded
911 + * to every payment-related filter so Pro can apply per-trip overrides
912 + * (e.g. trip.deposit_amount, trip.deposit_percentage) instead of only
913 + * the site-wide settings.
610 914 */
611 915 private function calculatePaymentAmounts(float $final_total, string $payment_method, array $context): array
612 916 {
613 917 $amount_due = $final_total;
614 918 $amount_paid = 0.0;
615 -
616 - // Apply Pro FlexiblePayments filter for deposit/partial
617 - $amount_due = (float) apply_filters('yatra_calculate_amount_due', $amount_due, $final_total, $payment_method);
618 -
919 +
920 + // Apply Pro FlexiblePayments filter for deposit/partial. $context lets
921 + // Pro look up the trip and honour per-trip overrides.
922 + $amount_due = (float) apply_filters('yatra_calculate_amount_due', $amount_due, $final_total, $payment_method, $context);
923 +
619 924 // Fallback: if no Pro module handled it, use basic logic (never use === on floats)
620 925 $flexible_enabled = apply_filters('yatra_flexible_payments_enabled', false);
621 926 $unchanged = abs($amount_due - $final_total) < 0.000001;
622 927 if ($unchanged && $payment_method !== 'full' && $flexible_enabled) {
623 - $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20);
624 - $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30);
928 + $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
929 + $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
625 930 if ($payment_method === 'deposit') {
626 931 $amount_due = round($final_total * ($deposit_percentage / 100), 2);
627 932 } elseif ($payment_method === 'partial') {
628 933 $amount_due = round($final_total * ($partial_percentage / 100), 2);