# Payments

Payment data from external gateways, and the money-moving operations we send back to them.

## Shape of the Module

| File | Responsibility |
|------|----------------|
| `modules/payments/adapter.ts` | The provider-agnostic contract every gateway implements |
| `modules/payments/registry.ts` | Maps `provider_type` → adapter; `capabilities` is the source of truth for what each can do |
| `modules/payments/http.ts` | Shared `gatewayFetch` — timeouts, `GatewayError`, and the `fetchImpl` test seam |
| `modules/payments/amounts.ts` | Pure amount maths and validation, in minor units |
| `modules/payments/modification-service.ts` | Capture / refund / cancel, the ledger, and the concurrency guard |
| `modules/payments/sync-service.ts` | Bulk import, single-payment refresh, import by reference |
| `modules/payments/order-lookup-sync.ts` | Ingestion for gateways that cannot be bulk-listed |
| `modules/payments/quickpay-adapter.ts` | QuickPay |
| `modules/payments/vipps/` | Vipps MobilePay ePayment |
| `modules/payments/worldline/` | Worldline (Bambora/ePay) |

## Providers

| Provider | `provider_type` | Listable | Capture/refund/cancel | Ingestion |
|----------|-----------------|----------|-----------------------|-----------|
| QuickPay | `quickpay` | Yes | Yes | Rolling sync + backfill |
| Vipps MobilePay | `mobilepay` | **No** | Yes | Order reference lookup |
| Worldline (Bambora/ePay) | `worldline` | Yes | Yes | Rolling sync + backfill |

`capabilities.listPayments` is what separates the two ingestion paths. A provider with
`listPayments: false` has no list endpoint at all: `listPaymentsPage` throws, the sync starters
refuse before creating a run row (`assertProviderIsListable`), and the UI hides "Synk nu" and the
backfill form via `PaymentMerchant.supportsSync`.

## Credentials

Stored encrypted (AES-256-GCM) in `payment_merchants.encrypted_credentials`, never in the
repository. Each adapter declares its own `credentialFields`, and the merchant form renders them
generically — adding a provider requires no frontend change.

## Money-Moving Operations

`modifyPayment` in `modification-service.ts`. The ordering is the design:

1. Load the payment and merchant; reject an inactive merchant.
2. If `clientRequestId` was seen before for this merchant, return the previous outcome — a
   double-click never charges twice.
3. Resolve the adapter and check the operation is supported.
4. **Fetch live state from the gateway** and write it down, then validate the amount against it.
   The maximum never comes from a local row or from Typesense.
5. Insert the ledger row with an idempotency key **before** calling the gateway.
6. Call the gateway.
7. Update the ledger row and write the audit entry.
8. Refresh local state — best effort. Once money has moved the mutation must not fail because a
   follow-up read did; on failure it queues `payments.refresh_payment` and returns
   `refreshQueued: true`. The modification response is preferred over a second GET, but only when
   it is actually writable: a payload the upsert refuses (no gateway `created_at`) falls through to
   a real read rather than counting as a refresh.

A gateway refusal is **not** an exception: it comes back as `ok: false` with the message, because
"we asked and were told no" is a real outcome the operator needs to see.

**Accepted-but-unfinished is a third outcome, not a refusal.** When the gateway takes the request
and processes it asynchronously, the result is `ok: false, pending: true` with no error message, and
the ledger row stays `pending` with `completed_at` null. This matters because the alternative is
dangerous: recorded as `failed`, the operator reads a refusal and presses capture again — and the
live amounts cannot refuse the second attempt, because the gateway has not moved them yet. The
`pending` row is what holds the concurrency guard below, and the sweeper closes it against the
gateway's own answer.

### The Ledger

`payment_gateway_modifications` records what *we* asked for, who asked, and what came back. It is
distinct from `payment_operations`, which mirrors the gateway's own view and is overwritten on every
re-import. Three indexes carry the guarantees:

- `unique(idempotency_key)` — written before the call, so a retry replays instead of double-charging.
- `unique(merchant_id, client_request_id) WHERE client_request_id IS NOT NULL` — double-click guard.
- `unique(payment_record_id) WHERE status = 'pending'` — **the concurrency guard**. Two operators
  capturing the same payment at once: one wins, the other gets `MODIFICATION_IN_FLIGHT` (409).

Rows stuck on `pending` for over five minutes are closed by
`payments.sweep_stuck_modifications`, which re-reads the payment from the gateway first.

## GraphQL

```graphql
capturePayment(input: PaymentAmountModificationInput!): PaymentModificationResult!  # payments.modify
refundPayment(input: PaymentAmountModificationInput!): PaymentModificationResult!   # payments.modify
cancelPayment(input: PaymentCancelInput!): PaymentModificationResult!               # payments.modify
refreshPaymentFromGateway(paymentRecordId: ID!): PaymentRecord                      # payments.read
notifyPaymentChanged(input: NotifyPaymentChangedInput!): PaymentChangeNotification! # payments.notify
importPaymentByReference(merchantId: ID!, reference: String!): PaymentRecord        # paymentsync.run
paymentProviders: [PaymentProvider!]!                                              # paymentmerchants.read
```

`notifyPaymentChanged` is the machine-to-machine entry point: Visma captures outside this system and
then reports that a payment moved. It never touches the gateway inline — it enqueues
`payments.refresh_by_reference` and answers immediately — and repeats for the same payment collapse
into the one job that has not run yet. It is deliberately forgiving: a reference we cannot place yet
is `accepted: true, queued: false`, because the notification can outrun our own import and a caller
in the money path must not be pushed into retrying. An unknown prefix is the one input reported as
`accepted: false`, since no later import will make it resolve.

```graphql
input NotifyPaymentChangedInput {
  """Bare gateway payment id, or the Visma-prefixed form. Casing is ignored."""
  reference: String!      # "612074931" or "ws:612074931"
  """Optional. Rejected when it contradicts the prefix."""
  provider: String        # "quickpay" | "mobilepay" | "worldline"
}

type PaymentChangeNotification {
  accepted: Boolean!      # false only for input no later import can fix
  queued: Boolean!        # false when a job was already pending, or nothing matched
  reason: String          # Danish explanation when nothing was queued
}
```

| `reference` | Outcome |
|---|---|
| `ws:612074931` | queued against the merchant carrying that prefix; the job gets the bare id |
| `612074931` | resolved from an existing payment record or order — the pre-prefix path |
| unknown prefix | `accepted: false` |
| prefix on a deactivated merchant | `accepted: true, queued: false` |
| `ws:ID-MANGLER` | `accepted: false` — Visma's sentinel for "should have an id but doesn't" |

### The Visma payment prefix

`payment_merchants.visma_payment_prefix` is how a notification finds its account. Visma stamps the
prefix on the payment id it hands out (`ws:612074931`, `qp_se:900000001`) and echoes the whole thing
back when it captures, so the prefix is the only thing in the notification that names the gateway
*account* — not just the gateway. That distinction is load-bearing: QuickPay DK and QuickPay SE are
one provider with two merchants, and without the prefix the lookup finds two active candidates,
calls it ambiguous, and queues nothing.

Stored lower-cased and matched case-insensitively, because Visma's own data is inconsistent: rows
written in 2024 say `WS:`, rows written in 2026 say `ws:`. Set it on every merchant that can be
notified about — a merchant without one silently never gets its payments refreshed this way. It is
editable from the merchant list, so a new country's account can get its prefix once Visma assigns it.

Setting or changing it queues `payments.prefix_rematch`, which walks every existing order whose
payment reference carries that prefix, stamps `orders.payment_provider`, and queues a
`payments.fetch_for_order` for each one. Renaming a prefix queues two walks: the new value, and the
abandoned one — orders the old prefix used to claim have their provider cleared back to NULL,
because no merchant owns that prefix any more. The walk is chunked by order id and paced so the
fetches it creates reach the gateway at roughly eleven a second; a first-time assignment on a
merchant with eleven thousand orders takes about twenty minutes. `rematchPaymentMerchantPrefix`
re-runs it on demand, which is how a merchant configured before this existed gets caught up; it is
the "Genmatch" action in the merchant list, behind a confirmation because re-running it reads every
matching order from the gateway again.

Because that walk takes minutes, the order page does not wait for it: opening an order that names a
payment nobody has linked calls `ensureOrderPayment`, which does the same match-and-fetch for that
one order synchronously and shows a spinner beside the payment id while it runs. The two paths write
the same value from the same lookup, so they are safe to overlap.

The prefix path deliberately does **not** check `capabilities.lookupByReference`. That flag means
"can be fetched by its *order* reference", which QuickPay cannot do — but what arrives here is the
gateway's own payment id, which `getPayment` accepts on every adapter.

`PaymentRecord` exposes `capturableAmount`, `refundableAmount` and `availableActions` for enabling
buttons. They are derived from locally stored amounts and are therefore only as fresh as the last
sync — the mutation always re-validates against the gateway regardless.

Amounts are **minor units (øre)** everywhere, on both sides of the API.

## Background Jobs

| Handler | Schedule | Purpose |
|---------|----------|---------|
| `payments.rolling_sync` | every minute | Bulk import for listable providers |
| `payments.order_lookup_sync` | every 15 min | Import by order reference for non-listable providers |
| `payments.sweep_stuck_modifications` | every 5 min | Close ledger rows left `pending` |
| `payments.refresh_payment` | on demand | Fallback when the inline post-modification refresh failed |
| `payments.refresh_by_reference` | on demand | What a `notifyPaymentChanged` call turns into |
| `payments.reconcile_due` | nightly | Sweeps whatever has fallen due for a re-read |
| `payments.notification_watchdog` | periodic | Notices when the notifications stop arriving |
| `payments.backfill_chunk` | on demand | One chunk of a backfill |

## Orders ↔ Payments

`src/modules/payments/order-payment-link.ts` owns this in both directions. Two keys exist, and they
are not equally good:

1. **The gateway payment id, with its prefix** — `ws:612515340`. The prefix names the merchant
   (QuickPay DK and QuickPay SE are the same gateway; only the prefix separates them) and the id
   names the payment there. This wins.
2. **`orders.magento_order_id == payment_records.order_id`** — the fallback. It only matches when
   the order carries Magento's increment id, which a Visma-imported order did not until the Magento
   order sync began filling it in: 12,157 of 16,679 orders in a production-shaped database had no
   Magento id, and their payments were unreachable from the order page however correct both rows
   were.

The order sync fills both fields now, from Magento and from Visma's payment line (`OrdLn.R7`). They
are no longer entered by hand.

### The payment is fetched the moment the order names it

Writing a payment id onto an order queues `payments.fetch_for_order`, which calls
`GET /payments/{id}` at the gateway directly. No window, no watermark — those answer "what changed
lately", and a watermark that drifts silently stops answering even that.

Queued inside the import transaction, and only when the id actually changed, so the hourly syncs'
deliberate two-hour overlap costs nothing. Parallelism is bounded by `DURABLE_WORKER_CONCURRENCY`
alone: there is no concurrency scope, because a global one would throttle a historical import that
is supposed to run flat out, and a per-order one would leave four permanent `durable_worker_slots`
rows per order — those rows are never deleted.

**A throttling gateway is handled by failing, not by queueing.** That only works because
`getPayment` returns null *only* on 404 and rethrows everything else. It used to swallow every
error, so a rate-limited lookup during a large import came back as "no such payment", the job
completed successfully, and the payment was never fetched — with the retry machinery sitting right
there, never given a reason to run. `tests/unit/quickpay-get-payment.test.ts` pins the distinction.

## Gotchas Worth Knowing

**Vipps `state` never changes.** It stays `AUTHORIZED` after capture, refund and cancel. Status is
derived exclusively from `aggregate.*`. Check refund *before* capture, or every refund reads as a
capture; and never derive "cancelled" from `cancelledAmount` alone, because a partial capture with
the remainder released has both a captured and a cancelled amount.

**Vipps `/accesstoken/get` validates only client id and secret.** A wrong subscription key still
yields a valid token and fails later at the gateway, so the connection test makes a second call.

**Vipps `expires_in` is a string**, `Idempotency-Key` is capped at 50 characters and must not be
derived from the amount (two equal partial refunds would share a key), and `reference` must match
`^[a-zA-Z0-9-]{8,64}$` — see `toVippsReference`.

**A payment with no gateway `created_at` is silently skipped** by `upsertPaymentWithOperations` and
only reported to Sentry. Every normalizer needs a fallback chain for it; this is the most likely way
a new integration loses data without an obvious error.

**Bambora answers 200 with `meta.result: false`** for a refused operation, so the HTTP status alone
is not an outcome. It calls a refund a "credit" and a cancel a "delete"; our normalized operation
types stay `refund` and `cancel`.

**`payment_records` amounts are 32-bit integers**, capping a single payment at roughly 21.5 M DKK.

## Scripts

```bash
# Read-only: connection test + first page + one payment, normalized
bun run src/scripts/probe-payment-gateway.ts <merchantId> [reference]

# Test data: creates an order, a gateway payment, and imports it (test environment only)
bun run src/scripts/seed-test-payments.ts <merchantId> [amountInMinorUnits] [phoneNumber]
```

Run `probe-payment-gateway.ts` first against any new account — it is what confirms the credentials,
the response shape, and (for a listable provider) the paging parameters.
