Skip to content

feat(payments): discount engine (phase 1 — discount codes) - #382

Merged
v0l merged 3 commits into
masterfrom
feat/discount-engine-cel
Aug 20, 2026
Merged

feat(payments): discount engine (phase 1 — discount codes)#382
v0l merged 3 commits into
masterfrom
feat/discount-engine-cel

Conversation

@v0l

@v0l v0l commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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:

{'percent': 10}
order.amount >= 5000 ? {'percent': 10} : {}
order.intervals >= 12 ? {'percent': 15} : order.intervals >= 6 ? {'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.

⚠️ CEL map keys must be quoted. The examples in the issue ({percent: 10}) are protobuf syntax; in CEL a bare identifier in a map literal is a variable reference and fails with Undeclared 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 bare 10, 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

  • 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.
  • Before tax and fees. Each line's VAT is recomputed from the amount actually charged — a customer is never taxed on money they do not pay.
  • Priced fresh. A code entered after an invoice already existed used to hand back the full-price pending invoice while reporting success (caught by the e2e run — see below).
  • Limits consumed at settlement, not at invoice creation, so an abandoned invoice burns no stock and does not lock a customer out of a one-per-customer offer. Settlement lives in apply_payment, which every payment method reaches (including the admin override via WorkJob::ApplySubscriptionPayment), 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 really happened and then refuses every later quote.
  • One discount per payment, enforced by UNIQUE (subscription_payment_id) on the redemption row — the no-stacking rule in a place it cannot be bypassed.
  • Referral needs no change: commission is computed from the recorded payment amount, which is already net of the discount.

Data model note

Provenance lives on discount_redemption (settled / created / settled_at + the unique payment key) rather than as columns on subscription_payment. The payment's amount is already net of the discount, so tax, refunds and referral are correct without them, amount_off is recorded exactly once, and the unique key is what makes settlement idempotent.

API

Admin (discount RBAC resource, granted to super_admin by migration — a resource with no grants is unreachable): /api/admin/v1/discounts CRUD, {id}/redemptions, and POST /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} shows percent: 100) and the reason for a failure.

Customer: code on GET /api/v1/vm/{id}/renew and GET /api/v1/subscriptions/{id}/renew, with a discount line 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)

  1. A code was answered with the existing full-price invoice. Pending payments are reused when method/type/time_value match, so entering a code after an invoice existed returned the un-discounted one. Fixed with get_vm_cost_for_intervals_fresh, used whenever a code is supplied.
  2. Lifecycle cleanup could not delete the companydiscount has a real FK to company.

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

  • ~70 unit tests across the rule evaluator, clamping, engine, DB layer and the renewal/settlement path. 100% function coverage on the new discount module (one closure short in engine.rs).
  • 6 new e2e tests in lnvps_e2e/src/discounts.rs plus 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.
  • Both migrations applied to a scratch DB on top of the full migration history against MariaDB, with each statement in the MySQL impl run by hand there.

Design record: work/discount-engine.md. Rule-authoring guide: docs/agents/discounts.md.

v0l added 2 commits August 20, 2026 14:53
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.
@v0l
v0l force-pushed the feat/discount-engine-cel branch from 05e498c to 7bac423 Compare August 20, 2026 13:59
@v0l

v0l commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

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.

order.template_id + order.productorder.items. The old pair 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. A rule now sees each line of the order, typed by the product it bills for and carrying that product's own properties:

order.items.exists(i, i.type == 'vm' && i.template_id == 3) ? {'percent': 10} : {}
order.items.all(i, i.type == 'vm' && i.cpu >= 8) ? {'percent': 5} : {}
order.items.exists(i, i.type == 'vm' && i.template_id == null) ? {'percent': 10} : {}   # custom builds
order.items.exists(i, i.type == 'ip_range') ? {'percent': 15} : {}
type Fields
vm vm_id, template_id (null = custom build), region_id, cpu, memory, disk_size, disk_type, ip4_count, ip6_count
app deployment_id, app_id, cluster_id, resource_multiplier
ip_range subscription_id, cidr
asn_sponsoring subscription_id, asn, registry
dns_hosting
marketplace_node_fee node_id

Two decisions worth review:

  • Type is certain, detail is best-effort. The line item 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 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 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 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.

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.
@v0l
v0l merged commit a446945 into master Aug 20, 2026
11 checks passed
@v0l
v0l deleted the feat/discount-engine-cel branch August 20, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discount Engine

1 participant