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 routinghlf/modules/shop/src/Entities/VoucherDiscount.php— voucher entity withisAutoApply,getLeverageTypeRestrictionhlf/modules/shop/src/Definitions/LeverageTypeRestriction.php— enum:Unrestricted,Swing,Standardhlf/modules/shop/src/Utilities/SingleOrderDiscountFactory.php— creates SOD service per producthlf/model/Ordering/Service/SubmitChallengeService.php— order submission, voucher burnhlf/tests/Trader/Shop/Services/PriceServiceVoucherLogicTest.php— unit tests