#118 verifies that the voucher shape on a franchised Sovereign is
identical to Catalyst-Zero. Two artefacts:
1. New §"Voucher shape propagates automatically" in
docs/FRANCHISE-MODEL.md explaining WHY there is no propagation
problem to solve: vouchers are not a CRD. They are rows in the
per-Sovereign billing service's Postgres database, and every
Sovereign runs the same SHA-pinned core/services/billing image.
Same image → same migration → same schema → same handlers → same
shape. The doc lists which file owns each part of the shape and
includes a 4-step curl smoke test to run on any Sovereign at
first-provisioning to confirm the invariant holds.
2. New core/services/billing/handlers/vouchers_test.go covering the
public POST /billing/vouchers/redeem-preview endpoint added in
#117. Four cases:
- 404 on unknown / soft-deleted code (no tombstone leak)
- 200 on a valid live code, asserting the public shape excludes
times_redeemed and max_redemptions (defence-in-depth against
enumeration)
- 410 Gone on a code that exists but has hit its cap, with the
credit/description still in the response so the landing page can
show "campaign ended"
- 400 on whitespace-only input
The tests run on every CI build of the billing service, on every
Sovereign that builds from this repo. If a future change drifts the
preview endpoint's shape, the tests fail before the regression can
ship.
Also tidies vouchers.go imports (removed two unused stdlib imports
that were placeholder).
Closes#118.
#117 adds a franchise-aligned URL surface for the existing PromoCode
voucher implementation, plus one new endpoint (redeem-preview) for the
public landing flow described in docs/FRANCHISE-MODEL.md §3.
The orchestrator's hint was right — the issue/list/revoke handlers
already exist (AdminUpsertPromo / AdminListPromos / AdminDeletePromo
on the legacy /billing/admin/promos surface). This commit:
1. Adds new endpoint handlers in core/services/billing/handlers/vouchers.go:
- POST /billing/vouchers/issue (superadmin or sovereign-admin)
- GET /billing/vouchers/list (superadmin or sovereign-admin)
- DELETE /billing/vouchers/revoke/{code} (superadmin or sovereign-admin)
- POST /billing/vouchers/redeem-preview (unauthenticated; public)
The first three reuse the existing store-layer methods. The last is
new — it validates a code without consuming it, returning a safe
shape (no times_redeemed, no max_redemptions exposure) so an
attacker scraping the public endpoint cannot enumerate cap status.
2. Distinguishes 404 (code never existed or soft-deleted — same
tombstone-leak protection as #91) from 410 Gone (code exists but is
inactive or capped). The 410 body still includes the credit and
description so the landing page can show "this campaign has ended".
3. Keeps the legacy /billing/admin/promos endpoints in place — the
existing admin UI continues to work without any breaking change.
New code should target /billing/vouchers/...
4. Updates docs/FRANCHISE-MODEL.md to point to the new URL surface.
The actual REDEMPTION still happens transactionally inside POST
/billing/checkout via the `promo_code` field — that path locks the
promo row, inserts the promo_redemptions edge, increments
times_redeemed, and adds the credit_ledger entry in one transaction.
Splitting it into a separate /redeem endpoint would break that
atomicity, so we deliberately do not add one. The public redeem flow
is preview → signup → checkout-with-promo_code.
Closes#117.
#114 verification of FRANCHISE-MODEL.md (committed at 9dfa4c8) against the
real code in core/admin and core/services/billing. Two drifts found and
fixed:
1. API endpoint paths were aspirational (/v1/admin/promos, /v1/redeem) but
the implementation has /billing/admin/promos and a customer-side
/billing/checkout with a promo_code field. Doc now matches code.
2. Auth flow described as "Keycloak signup" but marketplace today uses
magic-link + Google OAuth (Keycloak is the documented design target,
not the current implementation). Doc now reflects current auth.
Also expanded the PromoCode schema table to include the soft-delete
(deleted_at) column from #91 and the times_redeemed counter, plus a
note that the term "Voucher" in this document is the user-facing label
for the same row the code calls PromoCode.
Closes the #114 verification scope: doc reflects the code as of this commit.
Per docs/PROVISIONING-PLAN.md ticket [H] franchise. Documents the franchise + voucher model exactly as it exists today (PromoCode CRUD in core/admin, BHD credit-based vouchers, public /v1/redeem endpoint that triggers Organization auto-creation). No new CRD designed — this captures what's already deployed.
docs/FRANCHISE-MODEL.md:
- Chain of responsibility: OpenOva → Catalyst → Catalyst-Zero (Contabo) → omantel.omani.works (franchised) → omantel-issued vouchers → tenant Orgs
- Voucher = PromoCode CRUD: code, credit_omr, description, active, max_redemptions
- API endpoints: GET/POST/PUT/DELETE /v1/admin/promos (org-admin or sovereign-admin), POST /v1/redeem (public, rate-limited)
- 5-step redemption flow: issuance → distribution → signup → install drawdown → revenue split
- What franchisees CAN/CANNOT do (Kyverno admission policies enforce signed-Blueprint constraints)
- Cross-Sovereign tenancy + Org migration between Sovereigns
- Deferred items (voucher CRD lift, cross-Sovereign voucher, percentage-discount tiers)
docs/PROVISIONING-PLAN.md:
- Adds "Execution status (live)" table tracking groups A-M
- 6 groups now in 🚧 active status with commit references
- 1 group (F charts) flipped to ✅
- 1 group (A consolidation) flipped to ✅
- DoD (group M) gated on operator-provided Hetzner credentials + first blueprint-release CI runs landing the 11 OCI artifacts at ghcr.io/openova-io/bp-*
Closes [H] tickets: docs/FRANCHISE-MODEL.md authored, voucher CRD shape documented (lift to CRD deferred), what-franchisees-can/cannot rules enumerated.