Skip to content

Payments

SovEcom collects money through one live gateway today: Stripe. The card number never touches your API. Stripe’s hosted Payment Element collects the PAN in the customer’s browser, so your store stays in PCI SAQ-A scope. Your server sees a payment-intent id, an amount in integer minor units, and a currency code.

This guide covers what ships in v1: the Stripe card flow, SEPA Direct Debit, Apple Pay and Google Pay (which ride the same Stripe Element), recording offline payments by hand, refunds with credit notes, and dispute handling. Where a feature is planned but not yet built, the page marks it.

CapabilityStateNotes
Stripe card paymentsLiveHosted Payment Element, SAQ-A, webhook-confirmed
SEPA Direct DebitLiveAsync clearing; order goes paid only when funds settle
Apple Pay / Google PayLiveNo extra code; surfaced by the same Stripe Element
Manual / offline paymentsLiveRecorded by an admin via the orders API
Refunds + credit notesLiveFull, line-item, or partial-amount; gapless credit-note numbering
Disputes / chargebacksLiveRecorded from Stripe; fulfillment auto-frozen on open
MolliePlannedProvider stub only; every method throws today
Redirect gateways (PayU, PayFast, Mollie hosted)Not yet availableA redirect gateway requires additional work beyond the current provider seam and is not yet implemented
Admin screens for payments / refunds / disputesPlannedThe behaviour exists as API endpoints; no dedicated admin UI ships yet

Adding another gateway is a core integration, not a module

Section titled “Adding another gateway is a core integration, not a module”

This is the most-asked question, so it gets answered up front. If a client needs a regional gateway (PayU, PayFast, Mollie, anything), you cannot add it as a sandboxed module. The module sandbox is never granted a payment capability, by design. Untrusted third-party code never sits on the money path, and that posture is permanent.

A new gateway is a trusted core integration. You implement the PaymentProvider interface, register it in the dependency-injection container, and add a webhook controller. For a Stripe-shaped gateway (one that returns a client secret for an in-page Element) that is roughly one to two days of work and touches no checkout logic. A redirect gateway (where the customer leaves your site to a hosted page) is larger: the current provider seam returns a clientSecret, but a redirect gateway needs a redirectUrl plus its own webhook signature scheme. That work is not yet implemented.

The storage already supports this. The payments table is provider-agnostic (provider, provider_payment_id, method, metadata), and webhook de-duplication keys on (provider, event_id). A second provider corners nothing in the database.

You wire Stripe from environment variables. When STRIPE_SECRET_KEY is absent the app still boots (handy for local dev and tests), and every payment call returns 503 Service Unavailable instead of silently doing nothing. You will see this warning in the logs at startup:

Stripe disabled — no STRIPE_SECRET_KEY configured; payments will 503

Set these on the API process:

Terminal window
# Server-side secret key (sk_live_… / sk_test_…). NEVER expose this to the browser.
STRIPE_SECRET_KEY=sk_live_xxx
# Webhook signing secret (whsec_…). Without it, EVERY webhook is rejected (fail-closed).
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Optional: pin the Stripe API version. Defaults to the version the SDK is typed against.
STRIPE_API_VERSION=2026-05-27.dahlia

The storefront needs the publishable key, and only that:

Terminal window
# Storefront (Next.js): publishable key only. Safe to ship to the browser.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx

The code pins the Stripe API version (2026-05-27.dahlia) so a server-side Stripe upgrade can’t change behaviour underneath you. Override it through STRIPE_API_VERSION only when you deliberately bump it, the same way you would any dependency. Stripe telemetry is off (the EU-privacy default).

The webhook is the source of truth for “this order is paid”. Not the browser, not the success page. In your Stripe Dashboard, add an endpoint pointing at:

POST https://your-store.example.com/webhooks/stripe

Subscribe it to at least these event types:

EventWhat SovEcom does
payment_intent.succeededMarks the payment succeeded, drives the order to paid, issues the invoice
payment_intent.processingRecords an async (SEPA) payment as processing; the order stays pending_payment
payment_intent.payment_failedMarks the payment failed; the order stays payable so the customer can retry
charge.dispute.created / .updated / .closedRecords the dispute; freezes fulfillment on open
refund.created / refund.updatedReconciles a Stripe-dashboard or async refund
charge.refundedFallback for older API versions that embed the refunds list

Copy the endpoint’s signing secret into STRIPE_WEBHOOK_SECRET. SovEcom verifies every inbound request against it using the raw request body. An unsigned or forged request gets rejected with 400 and changes nothing. A missing signing secret means SovEcom rejects everything, by design.

You don’t drive this by hand. It runs on the storefront checkout path. Knowing the sequence helps when you reconcile an order.

  1. The customer reaches checkout. The storefront calls POST /store/v1/carts/:cartId/payment-intent. SovEcom authorises that the caller owns the cart (cart cookie or customer JWT), turns the cart into an order in pending_payment (stock is consumed at this point), creates a Stripe PaymentIntent for the server-computed order total, and returns a clientSecret.
  2. The browser confirms the payment with Stripe’s hosted Payment Element. The card number goes straight to Stripe. Your API never sees it.
  3. Stripe sends payment_intent.succeeded to your webhook. SovEcom verifies the signature, drives the order from pending_payment to paid, and issues the invoice exactly once.

The amount charged is always order.total_amount in the order’s currency, computed on the server. SovEcom never trusts a client-supplied amount. The Stripe idempotency key for the intent is the order id, so a retried create returns the same intent and never double-charges.

SEPA rides the same Stripe Element. Enable SEPA Direct Debit on your Stripe account and offer EUR, and that is the whole setup. The difference is timing. SEPA confirms instantly but clears over days and can fail after acceptance. SovEcom never fulfils before the money is in.

  • When Stripe sends payment_intent.processing, SovEcom records the payment as processing and leaves the order in pending_payment. The storefront shows a “payment processing” state.
  • When the funds clear, payment_intent.succeeded drives the order to paid and issues the invoice, exactly like a card.
  • If clearing fails, payment_intent.payment_failed marks the payment failed and the order stays payable for a retry.

A processing SEPA order is shielded from the unpaid-order sweeper, so it won’t be cancelled while clearing (which can legitimately take days).

Both ride Stripe’s automatic_payment_methods on the same Payment Element. There is no SovEcom code for them and nothing to configure in the admin. Stripe surfaces the wallet button based on the customer’s browser and device capability plus your Stripe account configuration. Enable Apple Pay and Google Pay in your Stripe Dashboard and register your domain there. The buttons then appear in the storefront’s existing Element.

For bank transfer, cash on delivery, or cash at pickup, an admin records the payment by hand. The call writes a real payments row and drives the order to paid, so the invoice issues and the order behaves like any other paid order. Two endpoints, both requiring orders:write and both audited:

POST /admin/v1/orders/:orderId/payments
Content-Type: application/json
{ "method": "bank_transfer", "amount": 4990 }

method is one of bank_transfer, cod, cash, or other. amount is optional integer minor units; omit it to use the full order total (the common “mark fully paid” case).

A convenience alias marks the full amount paid with method: "other":

POST /admin/v1/orders/:orderId/mark-paid

Both paths refuse to over-pay or double-pay. The amount must equal the order total (v1 has no partial-payment model), and the order must be in pending_payment. Recording a payment on an already-paid or cancelled order returns 409 Conflict. If a SEPA payment is mid-clearing on the same order, SovEcom refuses the manual record so the two can’t both collect.

You issue a refund against an order’s captured payment. SovEcom calls Stripe (or records an offline refund for a manual payment), issues a credit note with gapless numbering that corrects the original invoice, optionally restocks, and drives the order to refunded or partially_refunded. The money, the credit note, and the order state all commit together or roll back together.

POST /admin/v1/orders/:orderId/refunds
Content-Type: application/json
{
"amount": 1500,
"reason": "Damaged on arrival",
"idempotencyKey": "refund-<orderId>-2026-06-25-a"
}

Requires orders:write. Audited. Three mutually exclusive modes:

ModeBodyEffect
Fullneither items nor amountRefunds the entire remaining balance. Add "restock": true to restock every unrefunded line
Line-itemitems: [{ orderItemId, quantity, restock? }]Refunds named lines by quantity; per-line restock
Partial-amountamount: <minor units>Refunds an arbitrary amount; no restock

SovEcom computes the tax reversal per mode. Line refunds use cumulative-remainder rounding so the sum across all units equals the line’s exact paid tax, with no rounding drift that could over-reverse VAT. Partial-amount and full refunds reverse tax proportionally, clamped so the reversed tax can never exceed the order’s original VAT. A refund can never exceed the refundable remaining balance. An over-refund request returns 422.

If you refund directly in Stripe’s own dashboard, the refund.created / charge.refunded webhook reconciles it back into SovEcom. SovEcom records the refund, issues the credit note, restocks nothing (amount-mode), drives the order state, and writes a system-actor audit entry. A dashboard refund and an API refund converge on the same records. Reconciliation is idempotent on the Stripe refund id, so the echoing webhook of an API-initiated refund is a no-op.

A SEPA refund comes back pending. The money hasn’t moved and the bank may still reject it. SovEcom reserves the refunded amount (so a concurrent refund can’t over-refund) but defers the irreversible parts (the credit note and the order-state change) until Stripe confirms the refund succeeded. If the refund later fails, SovEcom backs out the reservation and never issues a credit note. A confirmed succeeded refund replays the deferred effects exactly once.

SovEcom admin — refund dialog with mode selector

When a cardholder disputes a charge, Stripe sends charge.dispute.created. SovEcom records the dispute against the order and payment, captures the evidence-due-by date, and freezes fulfillment on the order. While an order is frozen, SovEcom refuses its fulfilled and shipped transitions (422), so a disputed order can’t ship out from under you.

The dispute outcome (won / lost) is webhook-driven only. Stripe is the source of truth, and won / lost are terminal. SovEcom never lets a redelivered or out-of-order event regress a resolved dispute or re-freeze an order you already unfroze. A lost dispute reconciles its money through the refund path.

Read and act on disputes through the admin API:

# The dispute queue. Filter by status or order. Needs orders:read.
GET /admin/v1/disputes?status=open
# Clear the fulfillment freeze a dispute placed on its order. Needs orders:write. Audited.
POST /admin/v1/disputes/:id/unfreeze-fulfillment

Open disputes appear in the Disputes queue (Disputes in the sidebar) with the order, amount, reason, and evidence-due date. A Stripe charge.dispute.created webhook records the dispute and freezes the order’s fulfilment so it cannot ship. When the dispute resolves, use Unfreeze fulfilment to release the order.

SovEcom admin — disputes queue

Because SovEcom consumes stock when a payment intent is created, an abandoned checkout holds stock in a pending_payment order. A scheduled sweeper cancels orders that have sat unpaid past a TTL and restores their stock. Tune the window with:

Terminal window
# Minutes a pending_payment order may sit before the sweeper cancels it. Sensible default applies.
UNPAID_ORDER_TTL_MINUTES=60

The sweeper skips any order with a processing or succeeded payment, so an in-flight SEPA order is never cancelled while clearing. SovEcom clamps the TTL below the Stripe idempotency-key lifetime (about 24 hours), so a stale order is always cancelled before a second live charge could be created against it.

  • SAQ-A scope. The card PAN is collected only by Stripe’s hosted Element in the browser. No custom card form, no PAN at your API, no raw-card logging. Keep it that way; adding a custom card form would change your PCI scope.
  • Webhook is truth. Order paid state is set by the verified webhook, never by the browser’s success redirect. A customer who fakes a success page does not get a paid order.
  • Watch the logs for loud lines. SovEcom surfaces a handful of rare races as error logs for you to reconcile by hand: a payment captured for a cancelled order (PAYMENT CAPTURED FOR CANCELLED ORDER), a double collection on one order, or a gateway refund whose order has gone missing. These need a manual refund or reconciliation. SovEcom logs them precisely so you catch them.

SovEcom treats the Stripe webhook as the source of truth for payment state. Payment gateways are trusted core integrations, never sandboxed modules — untrusted third-party code is never granted access to the money path.