Fix pricing engine and rebuild discounts around recipes and a picker
Two bugs meant no pricing rule has ever applied correctly:
1. Condition keys never matched. The engine reads min/max/values;
the wizard wrote min_age/min_children/target_gender and the form
blade wrote a third set. Ranges saw null bounds and list rules saw
an empty allow-list, both of which passed, so every rule applied to
every participant.
2. Percentages were 100x too small. applyAdjustment divides by 10000
(basis points) but both screens stored a plain percent, so "20%"
discounted 0.2%.
They masked each other, which is why the symptom looked like a broken
engine rather than two bugs — and why everyone moved to the untyped
super-admin price override instead.
Engine
- ConditionSchema is now the single owner of the conditions vocabulary;
builder, engine, simulator and migration all read keys from it.
- Percent handles all basis-point conversion; nothing else touches the
raw column.
- evaluateInList fails closed instead of treating an empty allow-list
as "match everyone".
- custom rules no longer auto-apply; they are picker-only.
- enrollment_timing honours days_before_start (fails closed without a
program start date instead of silently passing).
- Global discount cap reads system_settings rather than a hardcoded
constant with a TODO.
- New: explain(), audience(), wouldApply(), and role-capped manual
discounts.
Per-branch
- pricing_rule_branches pivot so one rule targets many branches,
instead of one near-identical row per branch that drifts apart.
Stacking
- is_stackable now defaults to false; best-of-one is the normal case
and stacking is an explicit opt-in.
Authoring
- The five-step column editor becomes a recipe gallery plus an Arabic
sentence, with a live simulator on a real participant and an audience
count that warns when a rule would hit everyone. Saving a
conditionless rule is refused.
Checkout
- ManagesDiscounts trait plus <x-pricing.discount-picker>: branch-scoped,
searchable, pinned favourites, replace-vs-stack inline, blocked rows
show why. Wired into CollectPaymentWizard renewals; discount names are
frozen onto invoice.metadata so receipts survive later rule changes.
- NewRegistrationWizard now prices through the engine using a
provisional context built from the form, since the participant row
does not exist yet. The step-4 guard still checks the base price, so a
100% discount is not mistaken for an unpriced program.
Migration
- Rewrites conditions onto the canonical keys and scales percentages to
basis points. Rules whose conditions cannot be mapped confidently are
deactivated rather than guessed, with the old JSON kept in
metadata.legacy_conditions.
Also fixes list and coupon views that rendered the raw column (a 20%
rule would have displayed as 2000%), and adds the [x-cloak] CSS rule
that was missing app-wide.
Co-Authored-By:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Please register or sign in to comment