Subscription Management
At a glance
Subscription Management runs the recurring-revenue lifecycle for tenant platform fees: Stripe Checkout and Swish QR checkout, automatic invoicing on renewal with per-line VAT, ECB-quarterly-fixed multi-currency billing across EUR, SEK, NOK, DKK, GBP, USD and CHF, the formal Price-Lock Guarantee that pins each Subscription to its original pricing_version, automatic revenue distribution into per-recipient RevenueEntry rows and outstanding-debt enforcement against club transfers and licence renewals.
How it works
Each Subscription document carries a pricing_version (ISO date) identifying the exact price list in force when the contract was signed; this value is preserved across renewals so a customer never sees an unannounced price hike — the Price-Lock Guarantee published at petanque.life/price-lock-guarantee in four languages. CRUD covers create, renew, terminate and upgrade. Automatic renewal triggers the invoicing pipeline: an Invoice is generated with per-line tax_rate and tax_amount derived from the tenant VAT config (F08.09.02), tax_total is summed and the total is set; auto-generation flows are /invoices/auto-generate/license-renewal (accepts fee_components with name, amount, recipient type) and /invoices/auto-generate/affiliation.
Local-currency invoicing follows the ECB quarterly fixing policy: rates are pinned the first banking day of January, April, July and October; supported currencies are EUR, SEK, NOK, DKK, GBP, USD and CHF; each invoice persists ecb_fixing_rate and ecb_fixing_date for historical reproducibility — published at petanque.life/currency-policy. Settlement runs through two checkout flows. Stripe Checkout: POST /checkout/ with gateway=stripe returns the Stripe URL, the user pays in Stripe, the platform listens on POST /webhooks/stripe for checkout.session.completed (creates Payment, updates Invoice, generates receipt, distributes revenue), checkout.session.expired (marks expired) and payment_intent.payment_failed (flags failure).
Swish: POST /checkout/ with gateway=swish returns base64 QR data, the Swish app posts back to /webhooks/swish, same pipeline. Revenue distribution executes on every confirmed payment: the system fetches the RevenueDistributionRule for the fee category, splits the amount per configured percentages and writes RevenueEntry rows per recipient (federation, district, external); without a rule, 100 percent goes to the federation. Outstanding invoices in status sent, overdue or partially_paid block club transfer (PL-301) at creation and licence renewal at approval.
Per-tenant gateway configuration lives at /payment-gateways/, with credentials as Azure Key Vault references — Sweden uses Stripe plus Swish, France uses Stripe.
Key capabilities
- Subscription document carrying pricing_version preserved across renewals (Price-Lock Guarantee)
- Stripe Checkout and Swish QR checkout with webhook-driven Payment creation
- Auto-generated renewal invoices with per-line VAT and tenant default template
- ECB quarterly fixing for local-currency billing across EUR, SEK, NOK, DKK, GBP, USD and CHF
- Automatic revenue distribution via RevenueDistributionRule writing per-recipient RevenueEntry rows
- Outstanding-debt enforcement that blocks transfer and licence renewal until invoices clear
- Per-tenant gateway configuration with Azure Key Vault-backed credentials
In practice
A federation signs up on the v2026.01 catalog at the Self-Service M tier in EUR. Twelve months later renewal triggers: the system pulls the still-valid pricing_version v2026.01 (Price-Lock holds even though v2026.04 is now published), generates an invoice in EUR using the Q2 ECB fixing for any non-EUR cross-rate audit, applies 25 percent MOMS per the tenant VAT config and emails the treasurer a Swish QR link. He scans, the Swish callback hits /webhooks/swish, the platform creates a Payment, marks the Invoice paid, generates the receipt and runs revenue distribution writing the platform-fee RevenueEntry.
Three months in, the federation initiates a club transfer; /debts/check returns clear and the transfer proceeds. A neighbouring federation lets the renewal invoice slip past due — /debts/check returns blocked and their next licence renewal is refused until the invoice settles or a credit note clears it.
Features in this subsystem
91| ID | Status | Features |
|---|---|---|
| F08.11.01 | Shipped | Subscription datamodell med pricing_version — varje Subscription kopplas till en pricing_version (ISO-datum) som identifierar den prislista som gällde vid avtalstillfället. Oförändrat vid förnyelse. Indexerat för rapportqueries. ✅ PL-T042 |
| F08.11.02 | Shipped | Price-Lock Guarantee — formell policy: priset som pricing_version anger gäller så länge Subscription:en är aktiv. Inga automatiska prishöjningar vid förnyelse. Publicerad på petanque.life/price-lock-guarantee (4 språk). ✅ PL-T042 |
| F08.11.03 | Shipped | Subscription CRUD-livscykel (PL-T285) — PUT /subscriptions/{id} uppgraderar tier inom låst pricing_version (downgrades blockas 422). POST /subscriptions/{id}/cancel med immediate=False (default) eller True (sys_finance + fresh-auth). POST /subscriptions/{id}/renew är idempotent på (subscription_id, period_start, period_end). DELETE /subscriptions/{id} soft-deletar (sys_finance + fresh-auth) — blockeras vid obetalda fakturor. Audit via write_finance_audit. ✅ PL-T285 |
| F08.11.04 | Shipped | Pricing-version-rapport (PL-T285) — GET /sys/billing/subscription-report/by-pricing-version?include_cancelled=<bool> aggregerar antal aktiva subscriptions och låst årsintäkt per pricing_version, plus tenant-typ-fördelning. Cache 5 min via Redis. Sys-konsol-vy sys/app/(dashboard)/billing/subscription-report.tsx med trendgraf och tabell. ✅ PL-T285 |
| F08.11.05 | Shipped | Auto-renewal-jobb (PL-T285) — subscription_auto_renewal_job körs varje timme via the job scheduler, plockar auto_renew=True AND status=ACTIVE AND next_renewal_at <= now, skapar Invoice via existerande pipeline med oförändrat pricing_version. Retry 1h/2h/4h, sedan dunning (PL-T047). Drift-detektion subscription_renewal_drift (SEV2) när jobbet inte körts >25h. Audit subscription.auto_renewed / subscription.auto_renewal_failed. ✅ PL-T285 |
| F08.11.06 | Shipped | ECB-kvartalsfixing för lokal valuta — formell policy: fakturering i lokal valuta sker enligt ECB:s kvartalsfixade referensväxelkurser. Fixing första bankdagen i januari, april, juli, oktober. Stödda valutor: EUR, SEK, NOK, DKK, GBP, USD, CHF. Varje faktura sparar ecb_fixing_rate och ecb_fixing_date för historisk reproducerbarhet. Publicerad på petanque.life/currency-policy (4 språk). ✅ PL-T043 |
| F08.11.07 | Shipped | Tenant-typ-tier-byte via Stripe Subscription Schedule (PL-T226) — POST /sys/billing/{tenant_id}/change-tier (sys_finance + fresh-auth) byter TenantConfig.tenant_type, repointar TenantSubscription mot ny TenantBillingType, skapar Stripe Subscription Schedule prorated, invaliderar TenantTierResolver-cachen och skriver tenant.tier.billing_changed + tenant.tier.synced_from_stripe-audit. Companion-endpoint …/change-tier/quote är read-only förhandsbesked. Downgrades från federation-grade SKU blockas (422 tier_downgrade_blocked). ✅ PL-T226 |
| F08.11.08 | Shipped | Tenant-facing upgrade-quote + ROI-räknare (PL-T226) — POST /admin/billing/upgrade-quote returnerar prorated belopp + pre-fylld contact-sales/?topic=tier-upgrade-URL. Admin-vyn views/billing/SubscriptionView.tsx läser GET /me/tenant-tier, listar inkluderade delsystem och låter kunden simulera tier-byte. Faktiska byten ligger hos sys-operator. ✅ PL-T226 |
| F08.11.09 | Shipped | Stripe webhook tenant_type-sync + drift-job (PL-T226) — customer.subscription.updated läser metadata.tenant_type, applicerar om det driftat, skriver tenant.tier.synced_from_stripe-audit + bustar tier-resolver-cachen. Job stripe_subscription_drift_check (varje timme) fångar oöverensstämmelser → billing_health=warning + SEV2 platform-event. ✅ PL-T226 |
| F08.11.10 | Shipped | ✅ Betalgrinden — admin och arrangörsappen läser tenantens eget avtal (D-ADMINAPP m-2, 94f74ebf.m-2) — den första kod i huset som LÄSER abonnemangsstatus för att grinda något. Domen räknas i servern (internal/billing.AdminAccessDecision) ur tenantens eget platform_subscription + platform_invoice + dunning_case och bärs som fältet admin_access i GET /me/capabilities (decision granted|blocked, basis subscription_active|subscription_past_due|payment_pending|no_subscription|subscription_ended|not_a_customer|not_enforced, enforced, policy_decision/policy_basis och SERVERNS next_steps[]). Hela admin grindas — admin-webben (ShellHost) och arrangörsappen (SessionGate) — utom avtals-/onboardingvägen (login, auth/complete, kom-igang*, abonnemang); en stängd tenant får grindkortet (admin-access-gate, stegen ur next_steps, *Kontrollera igen* som gör exakt en ny orakelläsning) och ingen vy monteras, ingen vyläsning sker. past_due är ÖPPET med indrivningsbannern (admin-dunning-banner); plattforms- och sandbox-tenanter är not_a_customer. Fail-closed: oklar dom ⇒ admin-access-gate-unknown, aldrig en vy. Verkställighetens omfång är bootkonfig (PL_ADMIN_ACCESS_GATE enforce|selected|off + PL_ADMIN_ACCESS_GATE_TENANTS; ogiltig kombination = bootfel). Arrangörsappen fick dessutom /abonnemang i telefonform (hopfällbara sektioner, fakturorna som kort, fakturamottagaren med If-Match + Idempotency-Key, betal-/dokumentlänkar i systemets webbläsare — ingen betal-SDK). Fixtur: seed-admin-access (sex club-tenanter, en per avtalsläge, drivna genom BILL:s riktiga sömmar). Bevis: dadminapp-m2-e2e.mjs (descriptor 916-dadminapp-m2-betalgrind, arrangörsappen i mobil viewport). Icke-mål: ingen servergrind på domän-API:t, ingen självbetjänad teckning, ingen ändring i BILL:s statusmaskin. Specar: specs/api/endpoints/authz-capabilities.md §admin_access, specs/admin/views/betalgrind.md, specs/arrangor/views/betalgrind.md, specs/arrangor/views/abonnemang.md; domän: docs/domain/subscription-lifecycle.md §0c. ✅ D-ADMINAPP m-2 |
| F08.01.01 | Shipped | /license-fee-configs/, /license-fee-overrides/, /license-fees/calculate ✅ PL-204 |
| F08.01.02 | Shipped | /fee-schedules/ (fee_category=club_affiliation, med affiliation_tiers) ✅ PL-801 |
| F08.01.03 | Shipped | /fee-schedules/ (fee_category=competition_entry) ✅ PL-801 |
| F08.01.04 | Shipped | /fee-management/copy-season (säsongskopiering), /fee-management/by-season/{id} (konsoliderad vy) ✅ PL-F0801a |
| F08.01.05 | Shipped | /fee-surcharge-rules/ (CRUD), /fee-surcharges/calculate (early bird/late fee) ✅ PL-F0801a |
| F08.01.07 | Shipped | /revenue-distribution-rules/ + automatisk distribution vid betalning ✅ PL-801 |
| F08.01.09 | Shipped | /fee-schedules/ (fee_category=sanction) ✅ PL-801 |
| F08.02.01 | Shipped | /payments/ (registrera, spåra, återbetala) ✅ PL-801 |
| F08.03.01 | Shipped | /invoices/ (skapa, rader, summor) ✅ PL-801 |
| F08.03.02 | Shipped | Automatisk faktura vid licensgodkännande ✅ PL-801 |
| F08.03.06 | Shipped | Fakturastatus: draft → sent → paid/overdue/cancelled ✅ PL-801 |
| F08.03.07 | Shipped | /invoices/{id}/send-reminder ✅ PL-801 |
| F08.03.08 | Shipped | /debts/check/{debtor_id} — blockerar transfer och licensförnyelse ✅ PL-801 |
| F08.08.01 | Shipped | /financial-reports/revenue, /financial-reports/revenue/by-org-node ✅ PL-801 |
| F08.02.02 | Shipped | /checkout/ (Stripe Checkout Session), /webhooks/stripe ✅ PL-802 |
| F08.02.02 | Shipped | /checkout/ (Swish QR-betalning), /webhooks/swish ✅ PL-802 |
| F08.02.04 | Shipped | /receipts/, /receipts/{id}/pdf (kvittogenerering) ✅ PL-802 |
| F08.02.06 | Shipped | Automatisk avstämning via Stripe/Swish webhooks ✅ PL-802 |
| F08.02.07 | Shipped | /payments/{id}/gateway-refund (återbetalning via gateway) ✅ PL-802 |
| F08.02.10 | Shipped | Betalningsspårning: /payment-history/{debtor_id} ✅ PL-802 |
| F08.03.01 | Shipped | /invoices/ utökad med tax_rate per rad, tax_amount, tax_total ✅ PL-F0803a |
| F08.03.02 | Shipped | /invoices/auto-generate/license-renewal, /invoices/auto-generate/affiliation ✅ PL-F0803a |
| F08.03.03 | Shipped | /invoice-templates/ (CRUD, per-federation, default-hantering) ✅ PL-F0803a |
| F08.03.04 | Shipped | /invoices/{id}/pdf (PDF-nedladdning), /invoices/{id}/deliver (e-postleverans) ✅ PL-F0803a |
| F08.03.05 | Shipped | /credit-notes/ (CRUD, issue, apply, void — kreditnotor mot fakturor) ✅ PL-F0803b |
| F08.03.06 | Shipped | /invoices/{id}/status-history, /invoices/mark-overdue (statusspårning med historik) ✅ PL-F0803b |
| F08.03.07 | Shipped | /reminder-configs/, /invoices/reminder-queue, /invoices/send-due-reminders (påminnelseautomatik) ✅ PL-F0803b |
| F08.03.08 | Shipped | /debt-collection/ (CRUD, escalate, resolve — inkassoworkflow) ✅ PL-F0803b |
| F08.05.01 | Shipped | /sponsors/ (CRUD med kontaktinfo, nivå, sponsrade entiteter) ✅ PL-F0805 |
| F08.05.02 | Shipped | /sponsors/{id}/contracts (kontraktshantering med belopp, period, villkor) ✅ PL-F0805 |
| F08.05.03 | Shipped | /sponsors/{id}/exposure (exponeringsregistrering och sammanfattning) ✅ PL-F0805 |
| F08.05.04 | Shipped | /grants/ (ansökan: draft → submitted → approved/rejected → reporting) ✅ PL-F0805 |
| F08.05.05 | Shipped | /grants/{id}/reports (bidragsrapportering efter godkännande) ✅ PL-F0805 |
| F08.05.06 | Shipped | /sponsor-allocations/ (intäktsallokering per OrgNode med procentsatser) ✅ PL-F0805 |
| F08.04.01 | Shipped | /budgets/ (CRUD med inkomst-/utgiftskategorier per OrgNode), /budgets/copy (kopiera med inflationsjustering) ✅ PL-F0804 |
| F08.04.02 | Shipped | BudgetCategory med type: income/expense, kopplad till kontoplan ✅ PL-F0804 |
| F08.04.03 | Shipped | /budgets/{id}/actualize + /budgets/{id}/variance (budget vs. faktisk) ✅ PL-F0804 |
| F08.04.04 | Shipped | draft → proposed → approved → closed (godkännandeflöde med audit log) ✅ PL-F0804 |
| F08.04.05 | Shipped | /accounting-reports/income-statement (årsvis + kvartalsvis med quarter-param), /accounting-reports/budget-tracking, /accounting-reports/balance-sheet ✅ PL-F0804 |
| F08.04.06 | Shipped | /accounting-entries/ (verifikationer med kontoplan), /accounting-export/sie + /fec + /json ✅ PL-F0804 |
| F08.01.04 | Shipped | /fee-management/copy-season (kopiera avgiftsdata), /fee-management/by-season/{id} ✅ PL-F0801a |
| F08.01.05 | Shipped | /fee-surcharge-rules/ (CRUD), /fee-surcharges/calculate (beräkning) ✅ PL-F0801a |
| F08.01.06 | Shipped | /fee-waiver-rules/ (CRUD), /fee-waivers/calculate (beräkning med ålder/familj/scoping) ✅ PL-F0801b |
| F08.01.07 | Shipped | /fee-splitting/calculate (fördelningsberäkning), /fee-splitting/execute (skapar RevenueEntry) ✅ PL-F0801b |
| F08.01.08 | Shipped | /fee-audit/ (lista), /fee-audit/{id} (detalj), /fee-audit/entity/{type}/{id} (per entitet) ✅ PL-F0801b |
| F08.01.09 | Shipped | /sanction-fee-configs/ (CRUD), /sanction-fee-configs/calculate/{season_id} (beräkning) ✅ PL-F0801b |
| F08.01.10 | Shipped | /suspension-fee-configs/ (CRUD), /suspension-fee-configs/calculate/{season_id} (beräkning) ✅ PL-F0801b |
| F08.01.06 | Shipped | /fee-waiver-rules/ (CRUD), /fee-waivers/calculate ✅ PL-F0801b |
| F08.01.07 | Shipped | /fee-splitting/calculate, /fee-splitting/execute ✅ PL-F0801b |
| F08.01.08 | Shipped | /fee-audit/, /fee-audit/{id}, /fee-audit/entity/{type}/{id} ✅ PL-F0801b |
| F08.01.09 | Shipped | /sanction-fee-configs/ (CRUD), /sanction-fee-configs/calculate/{season_id} ✅ PL-F0801b |
| F08.01.10 | Shipped | /suspension-fee-configs/ (CRUD), /suspension-fee-configs/calculate/{season_id} ✅ PL-F0801b |
| F08.07.01 | Shipped | /expense-claims/ (CRUD med specificerade utgiftsposter, kvitto-URL, käll-referens) ✅ PL-F0807 |
| F08.07.02 | Shipped | /expenses/travel/calculate (km-ersättning + traktamente), embedded TravelExpense i claims ✅ PL-F0807 |
| F08.07.03 | Shipped | /expense-claims/{id}/submit, /expense-claims/{id}/approve, /expense-claims/{id}/reject ✅ PL-F0807 |
| F08.07.04 | Shipped | /expense-claims/{id}/pay (utbetalning med valfri betalningsmetod och referens) ✅ PL-F0807 |
| F08.07.05 | Shipped | /expense-reports/by-category, /expense-reports/by-org-node (rapporter med filter) ✅ PL-F0807 |
| F08.06.01 | Planned | GET/POST /v1/prize-pools, GET/PATCH/DELETE /v1/prize-pools/{id}, POST …:activate :close, PUT …/funding |
| F08.06.02 | Planned | GET/PUT /v1/prize-pools/{id}/distribution-rule, GET/POST /v1/prize-distribution-presets + GET/PUT/DELETE /v1/prize-distribution-presets/{id}, POST …:generate-payouts :recompute-distribution |
| F08.06.03 | Planned | m-1:s GET /v1/prize-payouts[/{id}] + PATCH (skatteland/sats) — plus m-2:s verb: POST /v1/prize-payouts/{id}:submit|:withdraw|:approve|:reject|:pay och kollektionsverben POST /v1/prize-payouts:submit|:approve (≤ 200 rader, Idempotency-Key, ett fel i EN rad fäller aldrig de andra). |
| F08.06.04 | Planned | GET /v1/prize-payouts/tax-report + GET /v1/prize-payouts/tax-report/export.csv; beräkningen är m-1:s GET/PUT /v1/withholding-tax-rates. GET /v1/prize-pools/summary svarar nu MÄTT withheld_this_year per valuta (posten är BORTA ur unavailable[]), och in_review/approved_unpaid bär batchknapparnas N. |
| F08.06.05 | Planned | GET/POST /v1/trophy-items, GET /v1/trophy-items/catalog, GET/PATCH/DELETE /v1/trophy-items/{id}, POST /v1/trophy-items/{id}:assign|:book-cost, GET /v1/trophy-assignments[/{id}], POST /v1/trophy-assignments/{id}:award|:return och kollektionsverbet POST /v1/trophy-assignments:award (≤ 200 rader, Idempotency-Key, per-rad-utfall). Svepet run-job trophy_return_reminder. Kontraktet: specs/api/endpoints/trophy-inventory.md. |
| F08.09.01 | Shipped | /license-fee-revisions/ (CRUD, approve, apply — årlig revision av licensavgifter) ✅ PL-F0809a |
| F08.09.02 | Shipped | /tenant-vat-configs/ (CRUD), /tenant-vat-configs/active, /tenant-vat-configs/resolve-rate ✅ PL-F0809a |
| F08.09.03 | Shipped | /invoice-number-series/ (CRUD), /invoice-number-series/preview ✅ PL-F0809a |
| F08.09.04 | Shipped | /payment-term-rules/ (CRUD), /payment-term-rules/resolve ✅ PL-F0809a |
| F08.09.05 | Shipped | /sepa-mandates/ (CRUD, activate, cancel), /sepa-direct-debits/ (CRUD, submit, settle, reject) ✅ PL-F0809a |
| F08.08.01 | Shipped | /financial-reporting/revenue-summary (intäkter per källa: licenser, tävlingar, sponsring, bidrag) ✅ PL-F0808 |
| F08.08.02 | Shipped | /financial-reporting/expense-summary (kostnader per kategori: utlägg + bokföringsposter) ✅ PL-F0808 |
| F08.08.03 | Shipped | /financial-reporting/federation-summary (hierarkisk sammanställning per OrgNode-nivå + budget) ✅ PL-F0808 |
| F08.08.04 | Shipped | /financial-reporting/club-health (hälsoindikatorer: betalningsgrad, kostnadskvot, förfallna fakturor) ✅ PL-F0808 |
| F08.08.05 | Shipped | /financial-reporting/trend-analysis (flerårsanalys med tillväxttakt, max 10 år) ✅ PL-F0808 |
| F08.08.06 | Shipped | /financial-reporting/export/csv (UTF-8 BOM), /financial-reporting/export/saft (SAF-T XML, OECD) ✅ PL-F0808 |
| F08.10.01 | Shipped | GET /accounting-export/fortnox (semikolonseparerad CSV, svenskt decimalformat, per-tenant kontoplansmappning) ✅ PL-F0810a |
| F08.10.02 | Shipped | GET /accounting-export/xero (kommaseparerad CSV, internationellt decimalformat, per-tenant kontoplansmappning) ✅ PL-F0810a |
| F08.10.03 | Shipped | GET /accounting-export/visma (tabbseparerad textfil, svenskt decimalformat, API-synk via integrationsconfig) ✅ PL-F0810a |
| F08.10.04 | Shipped | /chart-of-accounts-mappings/ (CRUD, per provider per tenant, duplikatskydd 409) ✅ PL-F0810a |
| F08.10.05 | Shipped | GET /accounting-export/sie4 (utökad SIE4 med #KTYP, #IB, #UB, #RES, CP437) ✅ PL-F0810b |
| F08.10.06 | Shipped | /financial-statements/ (CRUD, generering bokslut med resultat-/balansögonblicksbild, finalisering) ✅ PL-F0810b |
| F08.10.07 | Shipped | /period-locks/ (CRUD, check, unlock, blockerar entry create/post/void inom låst period) ✅ PL-F0810b |
| F08.10.08 | Shipped | /accounting-reports/consolidated-income-statement, /consolidated-balance-sheet (per OrgNode, kvartal) ✅ PL-F0810b |