| @@ -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, |