Skip to content

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).

  • 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
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
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"
}
}
}

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 <= 100
tx.RequestRefund(id, 60m, now, "Another refund"); // OK: 90 <= 100
tx.RequestRefund(id, 20m, now, "Too much"); // Throws: 110 > 100

Disputes (chargebacks) are also child entities:

tx.OpenDispute(id, "dp_123", "Fraudulent", 100m, now);
// Dispute status: Open → Won | Lost | Closed

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 OK

ProcessedWebhookEvent has a unique constraint on (ProviderName, ProviderEventId).

Invoicing ──InvoiceFinalizedEto──→ Payments (auto-charge if CollectionMethod == Auto)
Payments ──PaymentSucceededEto──→ Invoicing (RecordPayment)
Payments ──PaymentFailedEto────→ Invoicing (keeps Open)

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 routing

If no default method is configured, the invoice stays Open for manual payment.

Each transaction stores both ProviderName (“stripe”) and MethodType (“card”). On payment failure, both are propagated through the dunning retry chain via PaymentFailedEtoRetryPaymentPayloadInitiatePaymentCommand(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.

// 1. Customer selects a payment method type — filtered by context
var 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 method
var 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" }

Granit.Payments.Notifications defines 5 notification types:

TypeNameSeverityOpt-out
PaymentSucceededNotificationTypePayments.PaymentSucceededSuccessYes
PaymentFailedNotificationTypePayments.PaymentFailedWarningNo
PaymentMethodExpiringNotificationTypePayments.PaymentMethodExpiringWarningYes
RefundProcessedNotificationTypePayments.RefundProcessedSuccessYes
DisputeOpenedNotificationTypePayments.DisputeOpenedErrorNo
MethodRoutePermission
GET/api/payments/transactionsPayments.Transactions.Read
GET/api/payments/transactions/{id}Payments.Transactions.Read
POST/api/payments/chargePayments.Charges.Execute
POST/api/payments/refundPayments.Refunds.Execute
POST/api/payments/checkoutPayments.Charges.Execute
GET/api/payments/methodsPayments.Methods.Read
GET/api/payments/methods/availablePayments.Methods.Read
POST/api/payments/methodsPayments.Methods.Manage
DELETE/api/payments/methods/{id}Payments.Methods.Manage
GET/api/payments/configurationPayments.Configuration.Manage
GET/api/payments/configuration/catalogPayments.Configuration.Manage
POST/api/payments/configuration/{p}/{m}/activatePayments.Configuration.Manage
POST/api/payments/configuration/{p}/{m}/deactivatePayments.Configuration.Manage
POST/api/payments/configuration/{p}/{m}/resyncPayments.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=&currency=&amount=&sequenceType= query params — details in Availability & provider catalog.