feat(payments): discount engine (phase 1 — discount codes) - #382
Conversation
A discount's eligibility *and* its effect are one CEL expression returning a
decision map, so tiered and threshold offers need no new column each time a
campaign shape changes:
{'percent': 10}
order.amount >= 5000 ? {'percent': 10} : {}
order.amount >= 10000 ? {'amount': 500, 'currency': 'EUR'} : {}
Only the integrity guards are columns — validity window, total usage limit,
per-user limit — because they need atomic enforcement at redemption and cannot
be counted inside a side-effect-free expression.
Whatever the rule returns is clamped in Rust: percent to 0..=100, a fixed amount
to >= 0, and the total to at most the order amount. A non-decision return type
(a bare `10`, a string) is rejected rather than guessed at, because a typo must
not change what customers are charged. A rule that cannot be evaluated applies
no discount instead of failing the customer's payment. Rules see only a
hand-built context (order, user, history, now), never a database row.
Applying a discount:
- It applies to the *order*, not a line item: an order aggregates line items
plus setup fees, so discounting one cost quote would miss part of the bill.
- It is subtracted before tax and the processing fee, and each line's VAT is
recomputed from the amount actually charged — a customer is never taxed on
money they do not pay.
- A discounted order is priced fresh rather than reusing a pending invoice;
otherwise a code entered after an invoice existed handed back the full-price
one while reporting success.
- Limits are consumed at settlement, not at invoice creation, so an abandoned
invoice burns no stock. Settlement runs in apply_payment, which every payment
method reaches (including the admin override), and is idempotent under
replayed webhooks. used_count is incremented unconditionally: a customer who
has paid a discounted invoice must be honoured, so the count records what
happened and then refuses every later quote.
- One discount per payment, enforced by a unique key on the redemption row.
That is the no-stacking rule where it cannot be bypassed.
- Referral commission needs no change: it is computed from the recorded payment
amount, which is already net of the discount.
Provenance lives on discount_redemption (settled/created/settled_at + unique
subscription_payment_id) rather than as columns on subscription_payment: the
payment amount is already net of the discount, so tax, refunds and referral are
right without them, and amount_off is then recorded exactly once.
Admin: /api/admin/v1/discounts CRUD under a new `discount` RBAC resource, a
redemptions listing, and a preview endpoint that evaluates a rule against a
sample order without saving — what makes raw CEL safe to expose. Customer:
`code` on the VM and subscription renew endpoints, with a `discount` line on the
payment response; an unusable code fails the request rather than quietly
invoicing full price, with one generic message so codes cannot be enumerated.
Note CEL map keys must be quoted: `{percent: 10}` is a variable reference.
Phase 2 (automatic code-less discounts, stacking, admin condition builder) is
not included. See work/discount-engine.md and docs/agents/discounts.md.
A rule could previously ask about one `template_id` and a coarse `product`
string the engine chose on its behalf. That could express "this plan" and
nothing else: a multi-line order reported no template at all, a custom build was
indistinguishable from an unknown one, and every new product shape would have
needed another scalar bolted onto the context.
`order.items` now carries each line of the order, typed by the product it bills
for and carrying that product's own properties — a VM's plan, region, cores,
memory, disk and included addresses; an app's catalog id, cluster and sizing; an
IP range's CIDR; an ASN and its registry. Rules use CEL's macros against it:
order.items.exists(i, i.type == 'vm' && i.template_id == 3)
order.items.all(i, i.type == 'vm' && i.cpu >= 8)
order.items.exists(i, i.type == 'vm' && i.template_id == null) custom builds
order.items.exists(i, i.type == 'ip_range')
Type is certain, detail is best-effort. The line item itself says what it bills
for, so `i.type` is always right, but the product row behind it may not exist
yet: an IP range or a sponsored ASN is allocated when the *first payment
settles*, so on a purchase order there is nothing to read. Those fields are null
and the line is still reported — dropping it would make `i.type == 'ip_range'`
silently false on the very order buying one. Comparing against a null detail
fails the rule, which applies no discount, so "unknown" never costs money.
Items deliberately carry no price. A VPS line's stored `amount` is not what the
VM costs — VMs are priced by the pricing engine from their cost plan, in the
payment currency, while the stored amount is in the subscription's base currency
and is not maintained for VPS lines. A per-item figure would be wrong exactly
where a rule leaned on it; `order.amount` remains the order total.
The admin rule preview accepts `items`, so a rule can be tried against a custom
build, a multi-line order or a non-VM product before it is saved.
05e498c to
7bac423
Compare
|
Rebased onto master (picks up the VM-placement and Proxmox local-storage fixes; one changelog conflict, both entries kept), and pushed a second commit widening the rule context.
Two decisions worth review:
The admin preview accepts Rechecked on the new base: workspace unit tests green, clippy clean, 100% function coverage on all five discount files, and the full e2e suite still passes (185). |
The previous commit claimed line items had no usable price. That was wrong:
`subscription_line_item.amount` and `setup_amount` are real, and for every
non-VPS product they are exactly what is charged — the renewal sums
`amount * intervals` straight from them. Even for a VPS line the amount is
maintained: written from the cost plan (or the computed custom price) when the
VM is ordered, and rewritten on upgrade and on custom-spec changes. It is also
the figure the customer sees as that line's price.
So a rule can now read `i.amount` (the line's recurring price for one interval)
and `i.setup_amount`. Both are converted out of the subscription's base currency
into the order's payment currency when the context is built, so a rule never
compares cents against millisats — the engine does that conversion because it is
the only thing holding an exchange-rate service.
What they are *not* is the authoritative charge for a VPS line: that is
recomputed at payment time from the cost plan or custom pricing including the
machine's IP assignments, and a later cost-plan change does not rewrite the
stored amount. So `i.amount` answers "this line is worth about X" and
`order.amount` — the actual net being charged — remains the figure for a
minimum-spend threshold. Both are documented that way.
Line item fields also carry serde defaults now, so the admin rule preview can
post a partial line (`{"type": "vm", "cpu": 8}`) to try a rule without having to
invent a whole invoice.
Fixes #57 (phase 1 — discount codes; phase 2 is automatic code-less discounts, stacking and the admin condition builder).
A discount's eligibility and its effect are one CEL expression returning a decision map, so tiered and threshold offers need no new column each time a campaign shape changes:
Only the integrity guards are columns — validity window, total usage limit, per-user limit — because they need atomic enforcement at redemption and cannot be counted inside a side-effect-free expression.
{percent: 10}) are protobuf syntax; in CEL a bare identifier in a map literal is a variable reference and fails withUndeclared reference to 'percent'. The admin condition builder must emit quoted keys.Safety
Whatever the rule returns is clamped in Rust: percent to
0..=100, a fixed amount to>= 0, and the total to at most the order amount. A non-decision return type (a bare10, a string) is rejected rather than guessed at — treating it as "10 percent" would let a typo change what customers are charged. A rule that cannot be evaluated applies no discount instead of failing the customer's payment. Rules see only a hand-built context (order,user,history,now), never a database row: that is the security boundary, and fields are added to it deliberately.Applying a discount
apply_payment, which every payment method reaches (including the admin override viaWorkJob::ApplySubscriptionPayment), and is idempotent under replayed webhooks.used_countis incremented unconditionally: a customer who has paid a discounted invoice must be honoured, so the count records what really happened and then refuses every later quote.UNIQUE (subscription_payment_id)on the redemption row — the no-stacking rule in a place it cannot be bypassed.Data model note
Provenance lives on
discount_redemption(settled/created/settled_at+ the unique payment key) rather than as columns onsubscription_payment. The payment'samountis already net of the discount, so tax, refunds and referral are correct without them,amount_offis recorded exactly once, and the unique key is what makes settlement idempotent.API
Admin (
discountRBAC resource, granted tosuper_adminby migration — a resource with no grants is unreachable):/api/admin/v1/discountsCRUD,{id}/redemptions, andPOST /api/admin/v1/discounts/preview, which evaluates a rule against a sample order without saving. The preview is what makes raw-CEL advanced mode safe: it reports the clamped decision ({'percent': 900}showspercent: 100) and the reason for a failure.Customer:
codeonGET /api/v1/vm/{id}/renewandGET /api/v1/subscriptions/{id}/renew, with adiscountline on the payment response. An unusable code fails the request rather than quietly invoicing full price — the customer typed something and is owed an answer — with one generic message for every rejection so the endpoint cannot enumerate valid codes.Two bugs the e2e run found (both fixed here)
time_valuematch, so entering a code after an invoice existed returned the un-discounted one. Fixed withget_vm_cost_for_intervals_fresh, used whenever a code is supplied.discounthas a real FK tocompany.Separately, and not fixed here: pending-payment reuse matches on
(method, type, time_value)and not on price, so an unpaid invoice created before a VM upgrade is reused after it, billing the old rate. Pre-existing and worth its own issue.Testing
discountmodule (one closure short inengine.rs).lnvps_e2e/src/discounts.rsplus a customer-path block in the lifecycle test (code reduces the invoice, unpaid invoice consumes nothing, settlement redeems once, per-user limit then refuses, redeemed discount cannot be deleted but can be deactivated). Full e2e suite: 185 passed, 0 failed.Design record:
work/discount-engine.md. Rule-authoring guide:docs/agents/discounts.md.