Lifts the "scope cannot be edited" rule for policies. Product scope
remains read-only (moving a code between products has weird semantics
for historical redemptions), but the tiers a code applies to can now
be refined in-place via the Edit form's pill multi-picker.
- repo::update_discount_code: new applies_to_policy_id param
(Option<Option<String>>) alongside the existing applies_to_policy_ids
multi field. Both update the right columns; caller passes a
consistent pair so singular + JSON columns don't drift.
- Admin PATCH endpoint: new optional `policy_slugs` field. Server
resolves slugs against the code's existing product, then normalizes:
- [] → both columns NULL (any policy on the product)
- [one] → singular column set, JSON column cleared
- [two+] → JSON column set, singular column cleared
Sending no `policy_slugs` leaves scope alone (back-compat).
- Edit form: pill multi-picker replaces the read-only Applies-to label.
Pre-selected from the code's current allowed-policy set. Product
label stays read-only above the picker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A discount code can now apply to a subset of policies on a product
(e.g. "Patron and Pro but not Creator") instead of being limited to
exactly one policy or the entire product.
- Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array
of policy ids). Legacy `applies_to_policy_id` stays as the singular
fallback when the JSON column is empty/NULL.
- `DiscountCode::allowed_policy_ids()` helper unifies multi + singular
into one Vec. Purchase + preview scope checks consult it.
- `find_applicable_featured_discount` now narrows multi-policy
candidates in Rust (small candidate set; index-friendly SQL would
require json_each, deferred).
- Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs`
(array) alongside the existing `policy_slug` (singular). Multi wins
when both are present. PATCH does not allow scope edits — same rule
as the singular field (disable + recreate to re-scope).
- UI: pill multi-select replaces the policy dropdown on the create
form. Edit modal's scope label renders the comma-separated list.
UI + schema both back-compat: existing codes keep working unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Major feature release.
Featured (launch-special) discount codes:
- New 'featured' flag on discount_codes (migration 0017). When true,
the buy page renders a diagonal LAUNCH SPECIAL ribbon + slashed
original price + new price for every applicable tier. Purchase
endpoint auto-applies the discount for buyers who don't type a
code. Operator-typed codes still win.
- find_applicable_featured_discount repo helper: most-specific match
(policy > product > global), tiebreak by created_at.
- GET /v1/products/<slug>/policies now returns featured_discount per
policy with the post-discount price computed server-side. SDK
consumers + the dynamic pricing page get this for free.
Marketing bullets on policies:
- metadata.marketing_bullets — operator-controlled copy that renders
as additional checkmarks above the entitlement bullets on both the
admin grid tier card and the buy page tier. For things like 'Up
to 5 products' or 'BTCPay integration' that aren't real
entitlement gates.
- Authored via textarea on draft + edit policy forms.
UI:
- 'Most popular' checkbox now on the draft tier card (was edit-only).
- Discount codes tab grouped by product (matching Licenses /
Subscriptions tabs). Each code row gets a 'featured' badge when
flagged.
All 87 tests still pass. Migration is additive, no SDK changes,
backwards-compatible.
Phase 5 (invoice records the rate):
- repo::create_invoice_with_currency takes the listed currency,
listed value, exchange_rate_centibps, and exchange_rate_source as
optional params; create_invoice (the legacy form) becomes a thin
wrapper that passes None for all four. SAT-priced flows are
unchanged.
- purchase::start now branches on product.price_currency: SAT keeps
the existing path; USD/EUR calls rates::convert_to_sats and pins
the listed price + rate to the local invoice row for audit. The
buyer is still billed in BTC (BTCPay invoice is sat-denominated)
but the audit trail records what they SAW vs what they were
charged.
- Test paid_purchase_in_usd_records_listed_currency_and_rate seeds
a manual rate pin ($50k/BTC), creates a USD-priced product
($49.00), runs through purchase, asserts the invoice row carries
listed_currency='USD', listed_value=4900, rate_centibps=
500_000_000, source='manual_pin', amount_sats=98_000.
Phase 3 (buy page renders fiat):
- Server-rendered initial price respects product.price_currency:
USD products show "49.00 USD" (cents converted to display dollars)
instead of sats. Tier-picker JS still formats per-tier prices in
sats — that's a v0.3 polish when we plumb the rate into the JS
render path. Most operators ship single-policy products at first,
so the static initial render is the high-leverage piece.
Phase 6 (currency-aware discount codes):
- POST /v1/admin/discount-codes accepts optional `discount_currency`
field ('SAT' default, 'USD', 'EUR'). Whitelisted in the handler.
- repo::create_discount_code is now a thin wrapper around
create_discount_code_with_currency; the new helper persists
discount_currency to the column added in 0010. Existing SAT-only
codes keep working unchanged.
Test count: 37 (was 36; +1 paid_purchase_in_usd test).
Multi-currency design phases 1-6 all shipped (1: schema in :48; 2:
admin UI write in :48-:49; 3: buy page; 4: rate fetcher; 5: invoice
audit; 6: discount currency). Phase 7 (recurring subscriptions
re-quote) is v0.3 territory — needs the recurring-billing scaffolding
from Zaprite first.