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 +191 -75 3.0.4 → 3.0.16 View file →
@@ -197,11 +197,71 @@
197 197
198 198 // ── Total discounts ─────────────────────────────────────────────
199 199 $total_discount_amount = ($group_discount_data['amount'] ?? 0) + ($coupon_discount_data['calculated_amount'] ?? 0);
200 200 $total_discount_amount = min($total_discount_amount, $subtotal); // discount cannot exceed subtotal
201 -
201 +
202 202 $discounted_subtotal = max(0, $subtotal - $total_discount_amount);
203 -
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 +
204 264 // ── Itinerary Costs ─────────────────────────────────────────
205 265 $itinerary_costs = apply_filters('yatra_booking_itinerary_costs', [], $trip_id, $travelers_count, $traveler_counts, $travel_date);
206 266 $itinerary_costs_total = 0;
207 267 if (!empty($itinerary_costs) && is_array($itinerary_costs)) {
@@ -239,22 +299,37 @@
239 299 $is_selected = $is_required || $is_included || in_array($svc_id, $selected_service_ids, true);
240 300
241 301 $base_price = (float) ($svc['price'] ?? 0);
242 302 $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;
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 + }
254 328 }
255 329
256 330 $svc['selected'] = $is_selected;
331 + $svc['unit_price'] = $unit_price;
257 332 $svc['calculated_price'] = $calculated_price;
258 333 $additional_services_resolved[] = $svc;
259 334
260 335 // Only paid (non-included) selected services contribute to the
@@ -305,8 +380,11 @@
305 380 // ── Payment amounts (FlexiblePayments Pro module) ───────────────
306 381 $payment_amounts = $this->calculatePaymentAmounts($final_total, $payment_method, [
307 382 'trip_id' => $trip_id,
308 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,
309 387 ]);
310 388
311 389 // ── Currency ────────────────────────────────────────────────────
312 390 $currency = SettingsService::getCurrency();
@@ -330,9 +408,25 @@
330 408 // calculateBaseAmount, so we recompute a "pristine" base amount
331 409 // using the same TripPricingService::resolveCategoryEffectivePrice
332 410 // anchor that calculateBaseAmount starts from, and subtract from
333 411 // the real (post-DP) base_amount.
334 - $dp_per_unit_delta = $unit_price - $unit_price_before_dp;
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;
335 429 $dp_total_adjustment = 0.0;
336 430 $category_prices_post_dp = [];
337 431 if ($pricing_type === 'regular') {
338 432 $dp_total_adjustment = $dp_per_unit_delta * max(1, $travelers_count);
@@ -345,32 +439,31 @@
345 439 $pre_dp_price = (float) \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr);
346 440 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
347 441
348 442 // 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 - ]);
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 + }
360 459 if ($category_id) {
361 460 $category_prices_post_dp[(string) $category_id] = $post_dp_price;
362 461 }
363 462
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 - }
463 + $pre_dp_base += TripPricingService::categoryLineSubtotal($pt_arr, $count, $pre_dp_price);
371 464 }
372 - $dp_total_adjustment = $base_amount - $pre_dp_base;
465 + $dp_total_adjustment = $dp_was_suppressed ? 0.0 : ($base_amount - $pre_dp_base);
373 466 }
374 467 $price_breakdown = (array) apply_filters('yatra_price_breakdown', [], $trip_id, [
375 468 'price' => $unit_price,
376 469 'original_price' => $original_price,
@@ -522,37 +615,17 @@
522 615 if (empty($types)) {
523 616 return [];
524 617 }
525 618
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 -
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 +
555 628 return $types;
556 629 }
557 630
558 631 /**
@@ -608,13 +681,62 @@
608 681 *
609 682 * For traveler-based: uses per-category effective prices × counts
610 683 * For regular: uses unit_price × travelers_count
611 684 */
612 - 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(
613 702 float $unit_price,
614 703 int $travelers_count,
615 704 array $traveler_counts,
616 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,
617 739 array $price_types,
618 740 int $trip_id,
619 741 string $travel_date = '',
620 742 ?int $spots_remaining = null,
@@ -641,22 +763,16 @@
641 763 ]);
642 764
643 765 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
644 766
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 - }
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);
654 770 }
655 -
771 +
656 772 return round($base_amount, 2);
657 773 }
658 -
774 +
659 775 // Regular pricing
660 776 return round($unit_price * $travelers_count, 2);
661 777 }
662 778
@@ -662,9 +778,9 @@
662 778
663 779 /**
664 780 * Calculate group discount
665 781 */
666 - private function calculateGroupDiscount(
782 + public function calculateGroupDiscount(
667 783 int $trip_id,
668 784 float $subtotal,
669 785 int $travelers_count,
670 786 array $traveler_counts,