Payment Processing
At a glance
Payment Processing is the gateway-agnostic settlement layer that records, reconciles, refunds and audits every inbound payment against the Invoice it clears, integrating Stripe Checkout for cards and bank-debit, Swish QR for Swedish payers, Bankgirot transfer matching, multi-currency capture with ECB rate freeze, refund flows, club bulk-payment fan-out and Azure Key Vault-backed gateway credentials behind one uniform Payment lifecycle.
How it works
Each payment is anchored to an Invoice and progresses through a tracked status lifecycle written to /payments/. Online card and bank-debit settlement runs through Stripe Checkout: clients call POST /checkout/ with gateway=stripe, the user is redirected to Stripe Checkout, and the platform listens on POST /webhooks/stripe for checkout.session.completed (creates Payment, updates Invoice, generates receipt, distributes revenue), checkout.session.expired (marks the session expired) and payment_intent.payment_failed (flags failure). Swedish payers can pay by Swish; POST /checkout/ with gateway=swish returns a base64 QR payload, the Swish app posts back to /webhooks/swish, and the same Payment-creation pipeline executes.
Bank transfers settle through the Bank Reconciliation subsystem, which writes Payment rows when an inbound BGI line matches an Invoice OCR reference. Receipt PDFs are emitted on demand via /receipts/{id}/pdf. Refunds run through /payments/{id}/gateway-refund, which reverses the original Stripe charge or Swish settlement and writes a corresponding negative Payment.
Multi-currency support stores both transaction currency and tenant base currency on every Payment, with the ECB reference rate frozen at invoice-issue time to keep historical conversions reproducible. Club bulk payment lets a club treasurer settle every member licence in one Stripe session that fans out into per-member Payment rows. Per-tenant payment gateways are configured via /payment-gateways/, with credentials stored as Azure Key Vault references rather than plaintext secrets.
Every Payment writes a structured audit row queryable through /payment-history/{debtor_id}, and the entire pipeline is idempotent on Stripe event id and Swish callback id to survive webhook replays.
Key capabilities
- Stripe Checkout integration with webhook-driven Payment creation, receipt generation and revenue distribution
- Swish QR-code flow for Swedish payers with same downstream pipeline as Stripe
- Refund processing via /payments/{id}/gateway-refund that reverses the original gateway charge
- Club bulk payment that fans one Stripe session out to per-member Payment rows
- Multi-currency support with ECB reference rate frozen on the originating Invoice
- Per-tenant gateway configuration with credentials stored as Azure Key Vault references
- Idempotent webhook processing and full audit trail through /payment-history/{debtor_id}
In practice
A French club treasurer opens the licence renewal cart for 47 members and chooses Pay all by card. The platform creates one Stripe Checkout session totalling 2 350 EUR; he completes the card payment in Stripe. Within seconds the platform receives checkout.session.completed, creates 47 Payment rows linked to 47 invoices, generates 47 receipts, runs revenue distribution to ligue, comite and insurance pools and emails the treasurer a master receipt with a per-member breakdown.
Two weeks later one member requests a refund; the treasurer hits Refund on that member's invoice, /payments/{id}/gateway-refund reverses the line through Stripe, and a negative Payment row is appended to /payment-history/.
Features in this subsystem
19| ID | Status | Features |
|---|---|---|
| F08.02.01 | Shipped | ✅ Payment recording and status tracking |
| F08.02.02 | Planned | ✅ Online payment gateway integration (Stripe, PayPal, local providers) |
| F08.02.03 | Planned | ✅ Bank transfer payment matching |
| F08.02.04 | Shipped | ✅ Payment receipt generation |
| F08.02.05 | Planned | ✅ Installment payment plans (split license fee over months) |
| F08.02.06 | Shipped | ✅ Payment reconciliation |
| F08.02.07 | Planned | ✅ Refund processing |
| F08.02.08 | Planned | ✅ Club bulk payment (club pays all member licenses) |
| F08.02.09 | Planned | ✅ Multi-currency support (international competitions) |
| F08.02.10 | Shipped | ✅ Payment audit trail |
| F08.02.11 | Shipped | ✅ Affiliate hybrid signup med Stripe Payment Link — gated av sys_support-godkännande, 14 d expiry, valuta klassificeras per land (SE→SEK, NO→NOK, DK→DKK, GB→GBP, CH→CHF, övriga→EUR), webhook driver provisionering via PL-T003 ✅ PL-T302 |
| F08.02.13 | Shipped | ✅ Per-nivå betalkonfiguration — UI-ytor (PAY-2 UI, d49e6f73.m-2) — betalkonfigurationen synlig och användbar i alla fyra apparna, uteslutande mot riktiga endpoints via den genererade klienten (inga stubbar, ingen hårdkodad data): admin /ekonomi (Ekonomi-ytan: konton per nivå, arv/resolved_from, metod-/valuta-togglar, onboarding-status, förbundsträd, koppla-konto med autonomi-grind — ersätter den gamla <Stub>); app /klubbadmin-betalkonto (kassörens tunna konto-status + rå onboarding-länk + status-refresh, capability-gatad finance:view); web Betalmetoder (publik, white-label metodvisning driven av den anonyma projektionen GET /v1/public/tenants/{code}/payment-methods, integrerad på /ansok); sys betalkonfig (/betalkonfig — operatörens läs-endast tvärs-tenant övervakning GET /v1/sys/payment-accounts, payout-paus ⏸, engelska/ljust tema). Två additiva backend-deltan (t-1): kontometadata org_number/contact_email/note + den publika display-projektionen. Ingen plattformsavgift; sys-mockupens "Platform payment parameters"/platform-fee-sektion byggs INTE (mockup-konflikt, se docs/migration/mockup-changelog.md). Specar: specs/admin/views/ekonomi.md, specs/app/views/klubbadmin-betalkonto.md, specs/web/views/betalmetoder.md, specs/sys/views/betalkonfig.md. ✅ PAY-2 UI |
| F08.02.14 | Shipped | ✅ Metod-kapabilitetsmodell + metodtillgänglighet & pass-through-avgifter (PAY-3, 8579a2a7.m-1) — v1-metoduppsättningen (card/sepa_debit/swish/mobilepay/ideal/bancontact/vipps/paypal) materialiserad som seedad plattforms-scopad referensdata payment_method_capability (data, inte kod — utökningsbar för PAY-5 utan kodändring) med usage-flaggor (supports_recurring/is_one_off_only/requires_mandate), charge_model/merchant_of_record och land/valuta, plus pass-through-avgiften som live-data payment_method_fee (procent som bp integer + fast *_minor bigint, aldrig float, ingen markup-/kommissionskolumn — PL tar 0 %). Checkout-läsvägen GET /v1/payment-methods returnerar skärningen kapabilitet ∩ PAY-2-aktivering ∩ avgiftsdata med exakt fee_breakdown per metod (percentage_component/fixed_component/fee_total/grand_total som Money, banker's rounding), autentiserad + tenant-bunden utan finance-cap, läcker ingen konto-identitet/PII, 422 validation_error på ogiltig currency/amount_minor. Noll drift mot PAY-2:s katalog. Spec: specs/api/endpoints/payment-methods.md; arkitektur: docs/engineering/architecture/09-payment-infrastructure.md. ✅ PAY-3 |
| F08.02.15 | Shipped | ✅ Engångsbetalning med lokala Stripe-wallets + Vipps + pass-through (PAY-3, 8579a2a7.m-2) — den faktiska engångs-charge fyller röret: stripeProvider.CreatePayment parametriseras per metod (Swish SE/SEK, MobilePay DK/FI, Vipps NO/NOK, iDEAL NL/EUR, Bancontact BE/EUR, kort överallt) som en destination charge (on_behalf_of, aldrig application_fee — PL tar 0 %). Vipps är en native Stripe-capability (vipps_payments, private preview — anropet bär vipps_preview=v1-headern; ingen egen modul/webhook; Vipps-recurring är en öppen fråga, engångs i v1). Metod-guard på POST /v1/payments: kapabel för nodens land/valuta (422 method_unsupported_for_account) ∧ aktiverad i PAY-2 (409 method_not_enabled) — konsistent med GET /v1/payment-methods. Svaret bär client_secret + (wallet) redirect_url; status drivs requires_action → succeeded enbart av webhook (klienten pollar GET /v1/payments/{id}). charge.refunded reconcilieras in i refunds (provider-initierad refund syntetiseras; fee_minor nollas aldrig); netto-aggregat (succeeded − refunded) driver subjektets status. Pass-through-avgiften visas exakt innan bekräftelse (0 % markup). Specar: specs/api/endpoints/payments.md, specs/shared/payment-method-picker.md; arkitektur: docs/engineering/architecture/09-payment-infrastructure.md. ✅ PAY-3 |
| F08.02.16 | Shipped | ✅ PayPal — egen PaymentProvider-modul, klubb-ägd (PAY-3, 8579a2a7.m-4) — PayPal som den andra äkta PaymentProvider (internal/payments/paypal, provider='paypal') bredvid Stripe, mot PayPals egen plattform (Complete Payments / Multiparty — INTE via Stripe). Klubben äger sitt EGNA PayPal-konto och ÄR merchant-of-record (charge_model=paypal_partner, merchant_of_record=tenant): pengarna settlar direkt på klubbens konto, descriptor visar klubben, och PL drar INGEN partner-/plattformsavgift (application_fee_minor=0, klubben behåller 100 % minus PayPals egen avgift). Provider-routing på POST /v1/payments + refund efter payee-/betalnings-kontots provider (ingen on_behalf_of-gren berör PayPal; Stripe-vägen oförändrad). Egen signaturverifierad webhook POST /v1/payments/webhooks/paypal ovanpå F1-11:s idempotenta webhook_events-rygg (single-fire på (paypal, provider_event_id); osignerad → 400, ingen rad) med handlers PAYMENT.CAPTURE.COMPLETED/DENIED/REFUNDED, MERCHANT.ONBOARDING.COMPLETED, MERCHANT.PARTNER-CONSENT.REVOKED (uppdaterar payments/refunds/payment_accounts; fee_minor nollas aldrig; netto-aggregat driver subjektets status). Engångs i alla målländer (DE/FR/ES/IT/NL/BE + SE/NO/DK/FI). Öppet/go-live (aldrig påstått klart): PayPal-recurring är en öppen fråga som verifieras med PayPal (recurring-metoderna returnerar ErrNotImplemented); partner-/plattformsavtal + per-klubb PayPal-business-konto + permission-grant (Partner Referrals) är go-live-krav (ej kodleverabel); exakta PayPal-avgiftssatser är placeholders (is_verified=false) tills verifierade. Specar: specs/api/endpoints/paypal.md; arkitektur: docs/engineering/architecture/09-payment-infrastructure.md. ✅ PAY-3 |
| F08.02.17 | Shipped | ✅ Connected-account-onboarding + provider-capability + 2026 enhanced verification (PAY-3, 8579a2a7.m-5) — onboardingen härdad till ett övervakat flöde så en nollteknisk klubbkassör kan ta sitt connected account hela vägen till "kan ta betalt". Onboarding-läge per KONTOTS land, aldrig ett plattformsval (PL är SE-registrerat → embedded som default; FR-konton → account_tokens/hosted under fransk reglering oavsett att plattformen är svensk). Entitetstyp nonprofit_association (ideell förening) accepteras och passeras till providerns onboarding (MCC-/entitetshantering per land, audit-spårat). Metod-capability-lager: den nya tabellen account_method_capability (per-konto provider-aktiveringsstatus requested → pending → active, skild från PAY-2:s per-nod metod-config) skrivs via POST /v1/payment-accounts/{id}/capabilities/{method}/request (finance:manage, Idempotency-Key, exakt en audit-rad) och synkas ur providerns capability-map via den signaturverifierade account.updated-vägen + refresh-status; checkout (GET /v1/payment-methods) gatas på en TREDJE dimension account_method_capability.status='active'. 2026 enhanced verification: requirements klassas currently_due/past_due/eventually_due med due-dates (UBO/direktör-ID, jun–okt 2026), payout-paus exponeras explicit i klarspråk (payout_paused + requirements_plain med i18n message_key + "Ditt nästa steg" + deadline) och eskaleras idempotent till tenant-admin FÖRE deadline (Omtanke: aldrig en tyst payout-frysning); requirement-tick-jobbet (payment_account_requirement_tick, säkerhetsnät ≥6 mån) är kopplat till klassningen/eskaleringen och körbart via run-job. Deployable ytor: admin /ekonomi (payout-paus-varning + "Ditt nästa steg" + (åter)starta embedded-onboarding + begär metod-aktivering + capability-badge per metod) och sys /betalkonfig (tvärs-tenant kontohälsa: antal restricted/payout-pausade, annalkande requirement-deadlines; aldrig payer-PAN/IBAN), båda mot riktiga endpoints via den genererade TS-klienten (inga stubbar). Ingen ny konto-/betalnings-/refund-/webhook-tabell (enda nya = account_method_capability), ingen fee-/markup-kolumn. Öppet/go-live (aldrig påstått klart): exakta FR-onboarding-krav (account tokens vs Stripe-hostad per entitetstyp) verifieras mot Stripe före lansering. Specar: specs/admin/views/ekonomi.md, specs/sys/views/betalkonfig.md; arkitektur: docs/engineering/architecture/09-payment-infrastructure.md. ✅ PAY-3 |
| F08.02.18 | Shipped | ✅ Spelarens betalar-yta /me/payments (PAY-3, 8579a2a7.m-6) — betalarens EGNA self-service-yta, skild från admins scope-filtrerade ledger. Payer-enrichment (ej parallell ledger): den nullbara kolumnen payments.payer_user_id (+ index (payer_user_id, created_at)) materialiserar payer-identiteten på F1-11:s BEFINTLIGA payments-rad — fylld vid betalningsskapande (player-checkout ur bäraren; off-session recurring = källans payer; admin-on-behalf/walk-in = NULL). Payer är den tenant-överskridande auth-identiteten, så historiken är TVÄRS tenants. Nya self-scopade endpoints (selfPattern, payer ALLTID ur bäraren, aldrig query/body — IDOR-säkert, annans rad → 404): POST /v1/me/payments (player-checkout-capture, FR-1), GET /v1/me/payments (egen historik tvärs tenants, filter status/subject_type/from/to + paginering), GET /v1/me/payments/{id} (betalning + refunds + subject-länk + ETag), GET /v1/me/payments/{id}/receipt (nedladdningsbart, tenant-brandat kvitto med riktig data + raden "Petanque Life tar 0,00", Content-Disposition: attachment, ingen klartext-PAN/IBAN), POST /v1/me/payments/{id}/refund-request (payer-initierad refund vid avbokad anmälan). RefundEligibilityPolicy-krok per subject_type (provider-neutralt Go-interface + deny-by-default registry; m-6 äger interfacet/dispatchen, ej domänreglerna) — refund tillåts endast när betalningen är refunderbar OCH policyn medger (subjekt avbokat + belopp), annars 409 refund_not_eligible; skapar EN F1-11 refunds-rad (samma rygg som admin-refund, fee_minor nollas ALDRIG), netto-aggregat flippar status; varje begäran (beviljad + nekad) audit-spårad. Demo/seed-policy för competition_entry (via subject_refund_eligibility) gör flödet lokalt exekverbart tills D-COMP registrerar sin egen. Sparade betalsätt återanvänder m-3:s /me/recurring-sources (bygger ej om). Ingen ny betalnings-/refund-/webhook-tabell (enda schema-tillägg = payer-kolumnen + genererad fee_minor-spegling + demo-seed). App-UI (t-2): app-vyn /betalningar (app/app/betalningar.tsx) med underflikarna History (betalhistorik + mockupens filterchips, per-rad "Kvitto ›"-nedladdning, status-badge färg + IKON + TEXT, refunderad-markering, detaljvy med refund-lista och — endast när eligible — "Begär återbetalning" bakom en bekräftelse-stepper) och Saved methods (återanvänder m-3:s betalmetoder-yta) — delade byggstenar i packages/shared/src/payments/mePayments.ts (wire-typer, filterchips, semantisk status-ton, canRequestRefund-predikat, klarspråks-microcopy), data via den genererade klienten (meListPayments/meGetPayment/meRefundRequest) + autentiserad kvitto-nedladdning; riktiga tom-/laddnings-/fel-tillstånd, inga hårdkodade siffror, WCAG 2.2 AA (ikon + text, ≥48px). Specar: specs/api/endpoints/me-payments.md, specs/app/views/me-payments.md. ✅ PAY-3 |
| F08.02.19 | Shipped | ✅ Betal-steget i setup-guiden — data-driven kontostatus (D-SIGNUP-2, f154e12a.m-3) — den adaptiva setup-guidens (kom-igang / kom-igang-klubb / kom-igang-distrikt) steg "Connect payments" kopplat till den riktiga F1-11/PAY-2-betal-ryggen, utan att bygga om något penningflöde. Guidens läsmodell (GET /v1/onboarding/guide) DERIVERAR betal-stegets status server-side ur tenantens/nodens riktiga payment_accounts (aldrig m-1:s hårdkodade literal): payment_status (not_connected = inget konto · action_needed = konto finns men charges_enabled=false, KYC/requirements kvar · active = active + charges_enabled · unknown = ärligt läsfel, aldrig låtsad "klar"), payment_provider (stripe/paypal), payment_charges_enabled och en klarspråks-payment_requirements[]. Samma riktiga status driver utfalls-mätarens förmåga collect_member_fees ("Ta betalt av medlemmar"/"Ta betalt av besökare") — achieved=true enbart när ett konto är active + charges_enabled, aldrig hårdkodat. Betal-steget markeras klart som UTFALL när kontot är aktivt (ingen manuell bock); distriktsvarianten är Required:false/överhoppbart ("bara om distriktet tar emot pengar direkt"). Nod-skopning: en klubb vars pengar settlar till ett eget finansiellt autonomt nod-konto (F1-9) bedöms på nodens konto; en federation utan nod-konto på tenant-nivå. admin-vyn (components/onboarding/PaymentStepStatus.tsx i GuideView) renderar den riktiga kontostatusen inline (leverantör + charges_enabled + klarspråks-kravlista) som ikon+text+färg (WCAG 2.2 AA, ≥48px CTA), med ärliga tom-/fel-tillstånd och deep-link till den byggda djupytan admin/ekonomi (den döda rutten /admin/kom-igang/betalning är borta). Per-variant settlement-kopia ("Money settles straight to your club — PL never holds it", PL tar 0 %). Endast läsning via befintliga /v1/payment-accounts*/guide-kontraktet — inga nya betal-endpoints. Specar: specs/api/endpoints/onboarding-guide.md, specs/views/admin-onboarding-guide-views.md. ✅ D-SIGNUP-2 m-3 |
| F08.02.12 | Shipped | ✅ Per-nivå betalkonfiguration med arv nedåt (PAY-2, d49e6f73.m-1) — connected account per Tenant/OrgNode (F1-11:s payment_accounts; OrgNode-konto kräver financial-autonomi, BR06.12) med per-konto aktiverade metoder (usage/display_order/constraints, land×valuta×usage-guards 422 method_unsupported_for_account, onboarding-grind 409 account_not_onboarded) och valutor (payment_currency_config, exakt EN default, 422 currency_not_supported). Kärnan: resolve-before-charge GET /v1/payment-config/resolve (§5d.2) — egen nod → närmaste förälder med kvalificerat konto → tenant-rot, spårbar resolved_from (self/ancestor/tenant), 404 no_effective_payment_account när kedjan är tom; arv är resolution-only (BR06.11). Plus metodkatalog-endpoint (data, inte kod), läs-endast sys-översikt GET /v1/sys/payment-accounts (payout-paus-bevakning), PATCH/disable på konton, tröskelstyrd requirement-tick och seed-payment-config (SE self / FR ancestor / NO tenant). Ingen plattformsavgift — PL tar 0 på tenant-betalningar. Spec: specs/api/endpoints/payment-config.md. ✅ PAY-2 |
Stakeholders who need this subsystem
Surfaces in 3 stakeholder analyses