Skip to content

Voucher Discount Logic

Overview

Price calculation lives in PriceService::createPrice() (hlf/modules/shop/src/Services/PriceService.php). Voucher consumption (burn) lives in SubmitChallengeService::submitOrder() (hlf/model/Ordering/Service/SubmitChallengeService.php).

Voucher Types

A special voucher is one where both conditions are true: - isAutoApply = true - leverageTypeRestriction = Standard

Any other voucher is treated as a normal voucher (always applied when present).

Routing Logic

PriceService::createPrice() determines which pricing path to take:

graph TD
    A[createPrice] --> B{Custom amount?}
    B -->|yes| C[Admin override path]
    B -->|no| D{Has voucher?}

    D -->|no| K{SOD active?}
    D -->|yes| E{Special voucher?}

    E -->|no, normal voucher| F[Voucher path - always applies]
    E -->|yes| G{Swing account?}

    G -->|yes| STD[Standard path]
    G -->|no| H{FTMO regime?}

    H -->|no| STD
    H -->|yes| I{SOD exists?}

    I -->|yes| J[SOD path - voucher burned but not applied]
    I -->|no| F

    K -->|yes| J2[SOD path]
    K -->|no| STD

    style J fill:#f96,stroke:#333
    style F fill:#6c6,stroke:#333
    style STD fill:#69c,stroke:#333

$shouldApplyVoucher Decision

Normal vouchers always apply (!$isSpecialVoucher short-circuits to true).

Special vouchers only apply when ALL of: - Not a swing account - Legal regime is FTMO - No active single order discount

$useSingleOrderDiscount Decision

$useSingleOrderDiscount = hasSingleOrderDiscount($priceInput)
    && ($priceInput->getLegalRegime() !== LegalRegime::Ftmo || !$priceInput->isSwingAccount());

SOD is disabled for swing accounts under the FTMO regime.

Voucher Consumption (Burn)

handleVoucherDiscount() sets the VoucherDiscount on the Price object before the routing logic runs. This means:

Path taken Voucher on Price? Burned on submit?
Voucher path Yes (applied as discount) Yes
SOD path Yes (not applied, but preserved) Yes
Standard path No (handleTimedDiscount overwrites it) No

The SOD case is the critical TCORE-3441 behavior: the voucher is not applied as a price discount, but it is burned (marked as used) when SubmitChallengeService calls updateUsedVoucher().

Swing-blocked and regime-blocked vouchers are NOT consumed because the standard path clears them from the Price object. The voucher remains available for the user's future orders.

Test Coverage

PriceServiceVoucherLogicTest covers all branches with these scenarios:

Scenario Voucher Applied? Voucher Consumed?
No voucher No No
Normal voucher (swing + SOD present) Yes Yes
Special voucher — happy path Yes Yes
Special voucher — blocked by swing No No
Special voucher — blocked by non-FTMO regime No No
Special voucher — blocked by SOD No Yes (burned)
Auto-apply with non-standard restriction Yes Yes

Key Files

  • hlf/modules/shop/src/Services/PriceService.php — price calculation, voucher routing
  • hlf/modules/shop/src/Entities/VoucherDiscount.php — voucher entity with isAutoApply, getLeverageTypeRestriction
  • hlf/modules/shop/src/Definitions/LeverageTypeRestriction.php — enum: Unrestricted, Swing, Standard
  • hlf/modules/shop/src/Utilities/SingleOrderDiscountFactory.php — creates SOD service per product
  • hlf/model/Ordering/Service/SubmitChallengeService.php — order submission, voucher burn
  • hlf/tests/Trader/Shop/Services/PriceServiceVoucherLogicTest.php — unit tests