Payments — Provider-Agnostic Payment Processing
Granit.Payments provides provider-agnostic payment processing with a multi-provider per tenant model. A single tenant can route Card payments to Stripe, iDEAL to Mollie, and bank transfers to SEPA — all simultaneously. No card data ever touches Granit (hosted payment pages only, PCI DSS compliant).
Package structure
Section titled “Package structure”DirectoryGranit.Payments/ Domain: PaymentTransaction, PaymentMethod, webhook dedup, provider interfaces
- Granit.Payments.EntityFrameworkCore EF Core store, webhook dedup (insert-first)
- Granit.Payments.Endpoints Checkout, payment methods, webhooks (AllowAnonymous + signature)
- Granit.Payments.Stripe Stripe provider (PaymentIntent API, Checkout, WebhookVerifier)
- Granit.Payments.Mollie Mollie EU-sovereign provider
- Granit.Payments.Notifications Payment lifecycle notifications (5 types)
- Granit.Payments.SepaTransfer Bank transfer + reconciliation
- Granit.Payments.SepaDirectDebit Mandate management + SDD collection
Payment transaction FSM
Section titled “Payment transaction FSM”stateDiagram-v2
[*] --> Created: Create()
Created --> RequiresAction: MarkRequiresAction(url)
Created --> Processing: MarkProcessing()
Created --> Failed: MarkFailed()
Created --> Canceled: Cancel()
RequiresAction --> Processing: MarkProcessing()
RequiresAction --> Failed: MarkFailed()
Processing --> Succeeded: MarkSucceeded()
Processing --> Failed: MarkFailed()
- Created: payment recorded, awaiting provider
- RequiresAction: customer must act (3DS/SCA redirect, hosted page)
- Processing: async processing (SEPA, bank transfer)
- Succeeded: funds captured
- Failed: payment declined or error
- Canceled: cancelled by system or user
Multi-provider resolution
Section titled “Multi-provider resolution”public interface IPaymentProviderResolver{ Task<IPaymentProvider> ResolveAsync(Guid tenantId, string methodType, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PaymentAvailableMethod>> GetAvailableProvidersAsync( Guid tenantId, PaymentAvailabilityContext? context = null, CancellationToken cancellationToken = default);}The resolver reads the active PaymentMethodConfiguration records and returns the
methods each registered IPaymentProvider can handle. Passing a
PaymentAvailabilityContext filters the result against the capability snapshot
captured at activation time — see
Availability & provider catalog.
Configuration per tenant:
{ "Payments": { "ProviderMappings": { "Card": "stripe", "SepaDebit": "sepa-direct-debit", "BankTransfer": "sepa-transfer", "Ideal": "mollie", "Bancontact": "mollie" } }}Refund invariant
Section titled “Refund invariant”Refunds are child entities of PaymentTransaction. The aggregate enforces:
sum(refunds.Amount) + new.Amount <= transaction.Amount
var tx = PaymentTransaction.Create(..., amount: 100m, ...);tx.MarkProcessing();tx.MarkSucceeded("pi_123", succeededAt);
tx.RequestRefund(id, 30m, now, "Partial refund"); // OK: 30 <= 100tx.RequestRefund(id, 60m, now, "Another refund"); // OK: 90 <= 100tx.RequestRefund(id, 20m, now, "Too much"); // Throws: 110 > 100Dispute tracking
Section titled “Dispute tracking”Disputes (chargebacks) are also child entities:
tx.OpenDispute(id, "dp_123", "Fraudulent", 100m, now);// Dispute status: Open → Won | Lost | ClosedWebhook deduplication
Section titled “Webhook deduplication”Inbound webhooks use an insert-first dedup pattern — race-condition safe:
POST /api/payments/webhooks/{provider} [AllowAnonymous] 1. Resolve IPaymentWebhookVerifier by {provider} 2. VerifyAsync (signature + timestamp validation) 3. TryRecordAsync → INSERT ProcessedWebhookEvent - Success: first time → dispatch command - DbUpdateException (unique constraint): duplicate → return 200 OK 4. Dispatch ProcessWebhookCommand via Wolverine outbox 5. Return 200 OKProcessedWebhookEvent has a unique constraint on (ProviderName, ProviderEventId).
Event choreography
Section titled “Event choreography”Invoicing ──InvoiceFinalizedEto──→ Payments (auto-charge if CollectionMethod == Auto)Payments ──PaymentSucceededEto──→ Invoicing (RecordPayment)Payments ──PaymentFailedEto────→ Invoicing (keeps Open)Auto-charge with default payment method
Section titled “Auto-charge with default payment method”AutoChargeOnInvoiceHandler queries the tenant’s saved default payment method
to determine the provider and method type:
PaymentMethod? defaultMethod = await paymentMethodReader .GetDefaultForTenantAsync(tenantId, ct);
// Uses defaultMethod.Type ("card", "sepa_debit") and// defaultMethod.ProviderName ("stripe", "mollie") for routingIf no default method is configured, the invoice stays Open for manual payment.
PaymentTransaction.MethodType
Section titled “PaymentTransaction.MethodType”Each transaction stores both ProviderName (“stripe”) and MethodType (“card”).
On payment failure, both are propagated through the dunning retry chain via
PaymentFailedEto → RetryPaymentPayload → InitiatePaymentCommand(ProviderName: override).
This ensures retries use the exact same provider, even if the tenant’s config
changes between the original payment and a retry 14 days later.
Checkout flow
Section titled “Checkout flow”// 1. Customer selects a payment method type — filtered by contextvar context = new PaymentAvailabilityContext( CountryCode: "BE", CurrencyCode: "EUR", Amount: 25m, SequenceType: PaymentMethodSequenceType.OneOff);
var methods = await resolver.GetAvailableProvidersAsync(tenantId, context, ct);// → only methods whose capability snapshot accepts (BE, EUR, 25€, OneOff)
// 2. Resolve the provider for the selected methodvar provider = await resolver.ResolveAsync(tenantId, "card", ct);
// 3. Create a checkout session (hosted page)var session = await checkoutFactory.CreateAsync( invoiceId, amount, currency, returnUrl, ct);// → { Url: "https://checkout.stripe.com/...", SessionId: "cs_xxx" }Notifications
Section titled “Notifications”Granit.Payments.Notifications defines 5 notification types:
| Type | Name | Severity | Opt-out |
|---|---|---|---|
PaymentSucceededNotificationType | Payments.PaymentSucceeded | Success | Yes |
PaymentFailedNotificationType | Payments.PaymentFailed | Warning | No |
PaymentMethodExpiringNotificationType | Payments.PaymentMethodExpiring | Warning | Yes |
RefundProcessedNotificationType | Payments.RefundProcessed | Success | Yes |
DisputeOpenedNotificationType | Payments.DisputeOpened | Error | No |
Admin API
Section titled “Admin API”| Method | Route | Permission |
|---|---|---|
| GET | /api/payments/transactions | Payments.Transactions.Read |
| GET | /api/payments/transactions/{id} | Payments.Transactions.Read |
| POST | /api/payments/charge | Payments.Charges.Execute |
| POST | /api/payments/refund | Payments.Refunds.Execute |
| POST | /api/payments/checkout | Payments.Charges.Execute |
| GET | /api/payments/methods | Payments.Methods.Read |
| GET | /api/payments/methods/available | Payments.Methods.Read |
| POST | /api/payments/methods | Payments.Methods.Manage |
| DELETE | /api/payments/methods/{id} | Payments.Methods.Manage |
| GET | /api/payments/configuration | Payments.Configuration.Manage |
| GET | /api/payments/configuration/catalog | Payments.Configuration.Manage |
| POST | /api/payments/configuration/{p}/{m}/activate | Payments.Configuration.Manage |
| POST | /api/payments/configuration/{p}/{m}/deactivate | Payments.Configuration.Manage |
| POST | /api/payments/configuration/{p}/{m}/resync | Payments.Configuration.Manage |
| POST | /api/payments/webhooks/{provider} | [AllowAnonymous] |
Charge and checkout endpoints use Granit.Http.Idempotency ([Idempotent])
to prevent double charges. Configuration endpoints (activate / deactivate / resync) are
also idempotent. The /methods/available endpoint accepts
?country=¤cy=&amount=&sequenceType= query params — details in
Availability & provider catalog.
See also
Section titled “See also”- Providers —
IPaymentProvidercontract, Stripe, Mollie, SEPA - Availability & provider catalog — method catalog, capability snapshots, runtime filtering
- SaaS Overview — ecosystem architecture and choreography
- Invoicing — invoice lifecycle and partial payments
- Subscriptions — billing cycle orchestration