# ADR 004 — Surfboard payment and fiscal (ECR) integration discovery

## Status

Accepted as the dated discovery record, 2026-10-09. Evidence for `MVP-TECH-002-S01`,
`MVP-TECH-002-S02`, and `MVP-TECH-002-S03` under
[MVP-TASK-033](../specs/003-mvp/tasks/MVP-TASK-033.md). Documentation-only: no adapter, credential,
account, ECR resource, network call from this repository, or runtime behavior was created by this
task.

## Context

Epic 003 selects Surfboard for payment processing and the Swedish electronic cash-register (ECR)
service behind provider-neutral application ports ([scope](../specs/003-mvp/scope.md),
[techstack](../techstack.md)). MVP-TASK-033 had to produce the evidence-backed integration design
before MVP-TASK-034 implements anything: fetch `https://surfpay.ai/llms.txt`, follow its
authoritative developer/API links, obtain no secrets, and record dated conclusions, explicit
unknowns, environment topology, and release implications in this repository. Every question below is
answered **Yes**, **No**, or **Unknown** with a source; guessed capabilities are not recorded as
supported.

## Evidence sources (all fetched 2026-10-09, all public, no credentials)

| Key     | Source                                                        | URL                                                     |
| ------- | ------------------------------------------------------------- | ------------------------------------------------------- |
| BRIEF   | Machine-readable integration brief, payload version 0.3.0     | `https://surfpay.ai/llms.txt`                           |
| DEV     | Developer API index (API reference, webhooks, guide trees)    | `https://ai.developers.surfboardpayments.com/llms.txt`  |
| CONF    | API conventions guide (auth, envelope, amounts, environments) | `https://surfpay.ai/guides/api-conventions.md`          |
| ORDER   | Create-an-order guide (line items, tax, statuses, errors)     | `https://surfpay.ai/guides/create-an-order.md`          |
| WEBHOOK | Webhooks guide (events, signature, retries, best practices)   | `https://surfpay.ai/guides/webhooks-notifications.md`   |
| RECEIPT | Receipts guide (receipt facts, email, link, print, ESC/POS)   | `https://surfpay.ai/guides/receipts.md`                 |
| INSTORE | In-store skill (flow, hardware constraints, polling, states)  | `https://surfpay.ai/skills/surfboard-in-store/SKILL.md` |
| GOLIVE  | Go-live skill (certification, readiness checklist, PCI)       | `https://surfpay.ai/skills/surfboard-go-live/SKILL.md`  |
| CARBON  | Payments API as OpenAPI 3.1 (all operations incl. ECR V2)     | `https://www.surfboardpayments.com/openapi/carbon.json` |
| SEARCH  | Keyword search over the published skills and guides           | `https://surfpay.ai/api/search?q=`                      |

The BRIEF itself designates DEV, CARBON, and the guides corpus as the authoritative sources and
marks `https://www.surfboardpayments.com/llms.txt` as marketing content that must not ground
integration work. DEV was read in full; CARBON was parsed programmatically for the order, payment,
receipt, webhook, and ECR operation set; SEARCH queries for `idempotency`, `shift`, `cash register`,
`ecrEnabled`, `Skatteverket control unit`, and `Z report end of day` were executed and their results
reviewed. No developer-portal login, console, or credential was used.

## Answers to the discovery questions (MVP-TECH-002-S01)

| #   | Question                                          | Answer      | Evidence                                                                                                                                                                                                                                                                       |
| --- | ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1   | Line items supported on order creation?           | **Yes**     | `POST /orders` requires `orderLines` with per-line `id`, `name`, `quantity`, `amount` (ORDER, CARBON)                                                                                                                                                                          |
| 2   | Gross prices and VAT represented?                 | **Yes**     | Amounts are integer minor units; prices are gross; every line and the order total require a `tax` array of `{amount, percentage, type: "VAT"}` entries, including explicit `0` entries (CONF, ORDER, CARBON)                                                                   |
| 3   | Checkout initiation supported?                    | **Yes**     | `POST /orders` against `terminal$id`; payment initiated in the same call via `controlFunctions.initiatePaymentsOptions`, or separately by the initiate-payment API (ORDER, DEV, CARBON)                                                                                        |
| 4   | Supported in-store payment methods known?         | **Yes**     | `CARD`, `SWISH`, `KLARNA` are named for in-store; `CARD` exists in every market, `SWISH` only in Sweden; the per-store list must be read from the API, not hard-coded (DEV, GOLIVE, CARBON)                                                                                    |
| 5   | Success, failure, and cancellation representable? | **Yes**     | Terminal payment states `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_CANCELLED`; cancel via `DELETE /payments/{paymentId}`, order cancel via `DELETE /orders/{orderId}` (ORDER, CARBON)                                                                                     |
| 6   | Timeout reconciliation possible?                  | **Yes**     | `GET /orders/{orderId}/status` and `GET /payments/{paymentId}/status` at any time; the webhook guide advises an API query when no event arrives within 60 seconds (ORDER, WEBHOOK)                                                                                             |
| 7   | Provider-side request idempotency?                | **Unknown** | CARBON contains no idempotency key/header construct anywhere (whole spec parsed); no guide documents one. Webhook deduplication advice (`orderId` + `paymentId` as key) is receiver-side only (WEBHOOK)                                                                        |
| 8   | Stable references available?                      | **Yes**     | `orderId` and `paymentId` are returned at creation (store both); opaque hex identifiers kept as strings; `referenceId` is the "External reference ID for the order"; receipts accept transaction, payment, or order ID (ORDER, CONF, RECEIPT, CARBON)                          |
| 9   | Authentication and environments defined?          | **Yes**     | Server headers `API-KEY`, `API-SECRET`, `MERCHANT-ID`; the base URL is issued per account and environment in the Developer Portal console and never published; Demo and Live use separate credentials and hosts; browser code must use short-lived client tokens (CONF, BRIEF) |
| 10  | Demo support available without approval?          | **Yes**     | Demo credentials are issued immediately on developer-portal sign-up; no sales call; Demo runs payment-page mode with test cards; a real card used in Demo is voided after 30 minutes (BRIEF step 3, CONF, GOLIVE)                                                              |

| 11 | Webhooks with signature verification? | **Yes** | `POST /webhooks` subscription returns a
signing certificate; verify `x-webhook-signature` as HMAC-SHA512 of the raw body with that secret
before parsing; automatic retries, failure alerts, disablement after 7 days (WEBHOOK, CARBON,
GOLIVE) | | 12 | ECR enablement controllable? | **Unknown** (capability exists; Demo availability
unverified) | `controlFunctions.ecrEnabled` on order create/update is a documented boolean; partner
config exposes portal-level `ecrEnabled` for cash-register features; whether a Demo merchant has ECR
enabled and can create registers was not testable without credentials (CARBON) | | 13 | Register
creation and state lifecycle? | **Yes** | ECR V2 API: `POST .../cash-register` (requires
`cashRegisterName`, `deviceId`), `GET .../state-info` returns `cashRegisterState` and
`shiftInfo.shiftState` (example `SHIFT_OPEN`), petty cash, open/close timestamps (CARBON) | | 14 |
Open/close shift and end-of-day? | **Yes** | `PUT .../open-shift` (cashier name, opening float),
`PUT .../close-shift`, `PUT .../end-of-day`, `PATCH .../closing-time` (default auto-close 00:00
local), plus petty-cash deposit/withdraw (CARBON) | | 15 | Control-unit and declaration data? |
**Unknown** for Skatteverket; **Yes** for control-unit fields | The cash-register record carries
`controlUnitType`, `controlUnitSerialNumber`, `controlUnitManufacturer`, `controlUnitModel`,
`controlUnitAddress`; `PUT /receipts/{orderId}` stores `sequenceNumber`, `cashRegisterName`,
`controlUnitSerialNumber` for fiscal compliance. No Skatteverket, declaration, or deklaration
endpoint exists anywhere in CARBON or DEV (CARBON, RECEIPT, SEARCH) | | 16 | Receipt delivery
supported? | **Yes** | `PUT /receipts/{orderId}` add facts, `PUT /receipts/{id}/email`,
`GET /receipts/{id}/link`, `PUT /receipts/{id}/print`, `PUT /receipts/{terminalId}/escpos` (RECEIPT,
CARBON) | | 17 | X/Z reports available? | **Partial — Z yes, X unknown** |
`GET .../cash-register/{cashRegisterId}/files` returns register files with `reportType`; the spec
example is `Z_REPORT`; no `X_REPORT` value occurs anywhere in CARBON and no guide documents an X
report (CARBON, SEARCH) | | 18 | Live certification prerequisites known? | **Yes** | Signed
contract, certification call with Surfboard, production credentials issued to a person, base URL
change Demo to Live, then live payments; no agent may provision Live (BRIEF, GOLIVE) | | 19 | Amount
and currency formats defined? | **Yes** | Integers in the smallest unit (`10.00 SEK` = `1000`);
currency is the numeric ISO 4217 code as a string, SEK = `"752"` (BRIEF, CONF) | | 20 | Card data
ever touches our servers? | **No (by design — required)** | Card capture happens only on a
Surfboard-hosted page, terminal, or SDK; any design where PAN or CVV passes through our code is
invalid (BRIEF safety rules, GOLIVE) |

## Selected in-store flow and hardware assumptions

The selected flow is the server-driven in-store flow (INSTORE, ORDER, DEV):

```text
create order (optionally initiating payment in the same call)
  -> initiate payment (when not already initiated)
  -> poll status and/or consume webhook
  -> terminal state: completed | failed | canceled
  -> attach receipt facts
```

- `POST /orders` requires `terminal$id` and `orderLines`, returns `data.orderId` and
  `data.paymentId`; both must be stored. Orders are not merchant-scoped in the path — the merchant
  travels in the `MERCHANT-ID` header (CONF).
- `controlFunctions.initiatePaymentsOptions.paymentMethod` starts the payment in the same call (one
  round trip, the documented preference). Payment statuses are `PAYMENT_INITIATED`,
  `PAYMENT_PROCESSING`, `PAYMENT_PROCESSED`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED`,
  `PAYMENT_CANCELLED`; only the last three are terminal (ORDER, DEV). `PAYMENT_PROCESSED` is treated
  as non-terminal (event ordering places "processed" before "completed"); this is an assumption to
  confirm in the Demo smoke test.
- **Hardware constraint (decisive):** a server-initiated payment needs a networked EMV terminal. A
  `checkoutX` SoftPOS terminal accepts the create-order call but completes only through a native
  same-device app switch, so it can never take a payment from our separate web POS (INSTORE). The
  terminal for this application must therefore be a networked hardware terminal (SurfTouch/SurfPad
  class), not SoftPOS.
- **Demo constraint:** Demo runs in payment-page mode with test cards only; physical terminal
  behaviour is explicitly not proven in Demo (GOLIVE). Staging evidence can prove the API flow to a
  terminal state, not a card tap on hardware.

## Formats: amounts, currency, line items, VAT

- Amounts are integers in the smallest currency unit everywhere — request, tax entries, and
  responses. Currency is the numeric ISO 4217 code as a string: SEK is `"752"` (BRIEF, CONF).
- Prices are gross (VAT-inclusive), matching the application's `grossPrice` model. Every order line
  and `totalOrderAmount` must carry a `tax` array; zero-rate lines still send a `0` percentage entry
  (ORDER).
- `amount.total` is the **per-unit** price (`regular + shipping - campaign` for one unit); the order
  total is `sum(total * quantity)`. Sending a line total is rejected with `P_0001` (ORDER, INSTORE).
- Line VAT percentages in the application domain (`vatPercent`) map to the provider `tax` entries as
  neutral data inside the adapter; the provider field names (`orderLines`, `totalOrderAmount`,
  `tax`) never leave `backend/src/integrations/`.

## Definitive versus unknown outcomes (mapping design for MVP-TASK-034)

The neutral classes in `backend/src/application/checkout.ts` stay unchanged. The adapter maps
provider facts onto them:

| Provider fact                                                                                                             | Neutral outcome                            |
| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| Payment reaches `PAYMENT_COMPLETED` (envelope `SUCCESS`)                                                                  | `succeeded`                                |
| Payment reaches `PAYMENT_FAILED`                                                                                          | `rejected`                                 |
| Payment reaches `PAYMENT_CANCELLED`, or payment/order cancelled via API                                                   | `cancelled`                                |
| Envelope `status: "ERROR"` at `POST /orders` before any payment exists (e.g. `OR_*`, `P_*`, `PS_*`, `GC_*`, `SP_*` codes) | `rejected` (definitively captured nothing) |
| Hard deadline reached while status is `PAYMENT_INITIATED`/`PROCESSING`/`PROCESSED`                                        | `unknown`                                  |
| Transport timeout/reset, HTTP 5xx, or malformed/unparseable response on any money-moving call                             | `unknown`                                  |
| Status cannot be re-verified by the deadline (401/403/404, envelope error on reads)                                       | `unknown`                                  |

Rules: branch on the envelope `status`, never on the HTTP code alone (a 200 may carry
`status: "ERROR"`); `unknown` is never reported as success, never silently downgraded to a
definitive failure, and never blindly retried. Cancellation remains an explicit operator action
mapped from the provider cancel endpoints, not a side effect of a timeout.

## Idempotency and stable references

- **Provider-side idempotency is Unknown.** No idempotency key, header, or deduplication contract
  exists in the published CARBON spec or guides (question 7). Duplicate-charge protection therefore
  stays **application-side**: the durable operation identity from MVP-TASK-030 prevents a second
  initiation of the same operation, exactly as today. `referenceId` on `POST /orders` will carry the
  application `operationId` for forensic correlation; it must **not** be treated as a deduplication
  guarantee. Do not probe for provider idempotency by deliberately repeating charge attempts.
- **Stable references:** `orderId` is the order-scoped reference that survives a new payment attempt
  on the same open order (a cancelled/failed payment leaves the order `PENDING` and reuses it), so
  the neutral `providerReference` is the **`orderId`**; `paymentId` is per-attempt state used for
  status, cancel, and webhook correlation inside the adapter and is recorded alongside it in the
  durable attempt record. Identifiers are opaque hex strings: store and log them as strings, never
  parse them. The request field `terminal$id` contains a `$` and must be quoted in code generation
  (CONF, ORDER).

## Authentication, environments, and configuration

Server-side calls authenticate with three headers (`API-KEY`, `API-SECRET`, `MERCHANT-ID`). The base
URL is issued per account and environment in the Developer Portal console
(`https://developers.surfboardpayments.com/console/api-keys`) and is never published, so it must be
read from configuration and never guessed or copied from an example (CONF, BRIEF). Environments are
never mixed inside one flow: Demo credentials against a Live host fail without a useful error.

Environment variables for MVP-TASK-034 — names documented now, **values always empty in the
repository**, populated only in the deployment secret system:

```text
SURFBOARD_API_URL=          # console-issued base URL (Demo or Live), never a literal in source
SURFBOARD_API_KEY=          # secret
SURFBOARD_API_SECRET=       # secret
SURFBOARD_MERCHANT_ID=      # environment-scoped identifier
SURFBOARD_STORE_ID=         # environment-scoped identifier
SURFBOARD_TERMINAL_ID=      # environment-scoped identifier (networked EMV terminal)
SURFBOARD_CASH_REGISTER_ID= # environment-scoped identifier, after one-time register creation
SURFBOARD_WEBHOOK_SECRET=   # signing certificate returned by POST /webhooks, secret
```

`SURFBOARD_PARTNER_ID` is deliberately absent: this application acts as one merchant's POS, not as a
partner onboarding or logistics client. Secret and identifier ownership:

| Item                                                                | Owner                                             | Where it lives                                                          |
| ------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------- |
| API key/secret, base URL                                            | Human account owner issues; deployment owns       | Developer Portal to deployment secret store; local `.env` (git-ignored) |
| Merchant/store/terminal/register IDs                                | Surfboard provisions; deployment owns             | Deployment configuration (same table)                                   |
| Webhook signing secret                                              | Surfboard issues at subscription; deployment owns | Deployment secret store                                                 |
| Skatteverket declaration, contract, certification, Live credentials | Merchant/human only                               | Outside this system entirely                                            |

Rules: `.env.example` stays tracked with empty values; values are never committed, printed, logged,
returned to the browser, or placed in test fixtures; the adapter reads them server-side only. The
existing `CHECKOUT_MODE` composition remains the adapter switch: local/test keep `disabled`/`fake`,
hosted `staging` selects the Surfboard adapter with Demo values, hosted `production` selects the
same adapter with Live values — **after MVP-TASK-034 adds the adapter, switching environments is
pure configuration with no source-code edit**, and hosted deployments keep rejecting `fake`.

## Redacted logging (design)

- **Never logged:** API key/secret, base URL, webhook secret, `MERCHANT-ID`, store, terminal, and
  register identifiers (they are deployment configuration, per the existing
  [architecture](../architecture.md) rule), auth headers, request/response bodies, and webhook
  bodies before verification. Card data never exists in this system to log.
- **Logged:** application operation ID, neutral outcome class, envelope `status`, provider error
  codes (`OR_*`, `PS_*`, ...), HTTP status class, webhook event type, and the per-operation
  `orderId`/`paymentId` references needed for support and reconciliation. References are
  transaction-scoped, not secrets, and are what make a Demo failure diagnosable.
- Implementation is a single redacting logger inside the adapter; no ad-hoc `console` calls, no
  payload dumps on error paths.

## Webhook ingress and authentication (design)

- One persistent **console webhook** (`POST /webhooks`) is the primary notification channel.
  Subscription requires `webhookUrl`, `webhookName`, `events`, and `alertEmail`; the response
  returns the webhook identifier and the signing certificate stored as `SURFBOARD_WEBHOOK_SECRET`
  (CARBON, WEBHOOK). The per-order alternative (`controlFunctions.callBackUrl`) is not needed for
  the MVP flow.
- Event set: order payment completed, failed, cancelled, and order cancelled are required; payment
  initiated/processed are optional observability additions. Terminal events and
  logistics/merchant-application events are out of scope.
- Ingress is a backend-only POST route (added by MVP-TASK-034, which must then update
  `docs/api/openapi.yaml` in the same task). Authentication is the signature, not a session: verify
  `x-webhook-signature` as base64 HMAC-SHA512 of the **raw body** against `SURFBOARD_WEBHOOK_SECRET`
  using a timing-safe comparison **before** parsing, return 401 on mismatch, then return 2xx quickly
  and process asynchronously (WEBHOOK, GOLIVE).
- Handling is idempotent: `orderId` + `paymentId` is the deduplication key, terminal payment
  statuses are final, duplicate and out-of-order deliveries are no-ops, and fulfillment never
  depends on the browser. The endpoint requires HTTPS and is publicly reachable. Surfboard retries
  failures, alerts `alertEmail`, and disables the webhook after 7 days of failures, so the alert
  address must be monitored by the team.

## Timeouts, polling, and retry rules (design)

- Every provider HTTP call uses a bounded timeout (same convention as the existing email adapter:
  configurable, proposed default 10 seconds per request).
- Status convergence: poll `GET /orders/{orderId}/status` with capped backoff, treat the provider's
  60-second guidance as a mandatory fallback query when no webhook arrives, and apply one overall
  attempt deadline (configurable; proposed default 120 seconds, confirmed during the Demo smoke
  test). Reaching the deadline with no terminal state yields `unknown` — a real outcome, never an
  endless retry loop (WEBHOOK, INSTORE).
- Retry classification: **safe** — status/state/report GETs are retried with bounded backoff and
  jitter because they move no money. **Never auto-retried** — `POST /orders` (and any initiation)
  after timeout, reset, or 5xx: the outcome is `unknown` and a repeat would risk a second charge;
  duplicate suppression is the application's operation identity (MVP-TASK-030), not a provider
  guarantee (question 7). Envelope `ERROR` responses are definitive and not retried within the same
  attempt; an operator may start a new attempt after, for example, `PS_0025` (terminal not
  connected).

## Reconciliation ownership (design)

- **Application:** owns the durable per-operation record (attempt, outcome, timestamp, provider
  reference — already in the finalized history), the `operationId` to `orderId`/`paymentId`
  correlation, webhook/poll fusion, and resolution of every `unknown` by re-querying status.
  Unknowns stay unresolved until a status query decides them; there is no offline queue and no
  browser-driven retry. No reconciliation HTTP endpoint may be added — settlement semantics are a
  separate explicit decision, not a side effect.
- **Adapter:** preserves provider references and never fabricates one; a reference proves an attempt
  exists, never that money was captured.
- **Operations/human:** Surfboard settlement reports (daily/monthly), register `Z_REPORT` files, and
  payout comparison are provider-side artifacts. Automated settlement reconciliation is **not** in
  the MVP; it is a documented future obligation before real money settles (GOLIVE: build
  reconciliation before going live).

## Fiscal (ECR) findings

The Carbon spec contains a dedicated **Electronic Cash Register (ECR) V2 API** tag whose description
states it "enables merchants to manage electronic cash registers for daily operations ...
subscribing cash registers, opening/closing registers and shifts, managing cash deposits and
withdrawals, and tracking register state ... a sequential flow matching natural business day
operations" (CARBON). Two fiscal shapes exist in the docs: Surfboard's hosted ECR (register created
through the API), and merchant-owned control units whose facts are attached to receipts Surfboard
does not hold (RECEIPT). **The MVP assumption is the Surfboard-hosted ECR**, with receipt facts
attached per sale; external control-unit fields are honored when the merchant supplies them.

Verified ECR operations (all under `/merchants/{merchantId}/stores/{storeId}/cash-register/...`):

| Operation                        | Endpoint                                          | Notes                                                                                                                            |
| -------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Create register (one-time setup) | `POST .../cash-register`                          | Requires `cashRegisterName` and `deviceId`; returns `cashRegisterId` — "save this ID"                                            |
| Fetch register record            | `GET .../cash-register/{cashRegisterId}`          | Includes `designation`, `modelOrProgram`, `activationDate`, `closingTime`, and control-unit details                              |
| Register state                   | `GET .../state-info`                              | `cashRegisterState`, `shiftInfo` (`shiftNumber`, `shiftState` e.g. `SHIFT_OPEN`, cashier, opened/closed timestamps), `pettyCash` |
| Open shift                       | `PUT .../open-shift`                              | Cashier name, opening float, terminal/device identifier                                                                          |
| Close shift                      | `PUT .../close-shift`                             | Records shift number and closing facts                                                                                           |
| Scheduled close time             | `PATCH .../closing-time`                          | Default auto-close 00:00 local; sending a time sets a custom close type                                                          |
| End of day                       | `PUT .../end-of-day`                              | Performs the end-of-day close; example success message "Cash register closed successfully"                                       |
| Register files (reports)         | `GET .../files`                                   | Returns files with `reportType`; example `Z_REPORT`                                                                              |
| Petty cash in/out                | `POST .../deposit-petty-cash`, `PUT .../withdraw` | Out of MVP scope                                                                                                                 |

`ecrEnabled` is a `controlFunctions` boolean on order create/update — "whether ECR mode is enabled"
— plus a partner-portal capability flag. The design is that the adapter sets `ecrEnabled` from
configuration (fiscal configured => enabled) rather than adding a provider-shaped field to the
application. Receipt facts map neutral sale facts onto `PUT /receipts/{orderId}` (`sequenceNumber`,
`cashRegisterName`, `controlUnitSerialNumber`, optional cashier/customer names). **Skatteverket
declaration data has no API**: the spec contains no declaration endpoint, so the declaration itself
remains a human, merchant-side obligation (question 15). X reports are undocumented (question 17).

## Ports

Two ports, both implemented only in `backend/src/integrations/` adapters, both consuming only
provider-neutral types (matching the existing [architecture](../architecture.md) boundaries and the
static guard in `tests/ci/checkout-boundary.test.mjs`):

**1. Payment port — the existing `CheckoutGateway`** (`backend/src/application/checkout.ts`).
Discovery now proves a provider supports line items, VAT, and payment methods (questions 1–4), so
MVP-TASK-034 may extend the neutral request with neutral facts only — e.g. line
`{name, quantity, grossUnitMinor, vatPercent}` derived from finalized item snapshots and a neutral
payment-method choice. Provider field names (`orderLines`, `controlFunctions`, `totalOrderAmount`,
`tax`, `terminal$id`) stay in the adapter; the four outcome classes, `operationId`, and
`providerReference` semantics are unchanged. Extending the port is optional for 034: charging a
single gross total already works, and line-item fidelity is required only when the fiscal/receipt
path needs per-line VAT — decide against the Demo smoke evidence, not by guessing.

**2. Fiscal port — smallest separate port, named `FiscalGateway` here so the name does not drift:**
the smallest set needed for a legal sale, receipt, and end-of-day evidence:

```ts
interface FiscalSaleFacts {
  providerOrderId: string; // reference of the completed provider order
  sequenceNumber: string; // receipt sequence — source/reset rules are Unknown, see below
  cashierName?: string;
}

interface FiscalRegisterState {
  registerOpen: boolean;
  shiftOpen: boolean;
  shiftNumber: number | null;
}

interface FiscalGateway {
  attachSaleFacts(facts: FiscalSaleFacts): Promise<void>; // PUT /receipts/{orderId}
  getRegisterState(): Promise<FiscalRegisterState>; // GET  .../state-info
  openShift(cashierName: string): Promise<void>; // PUT  .../open-shift
  closeShift(): Promise<void>; // PUT  .../close-shift
  endOfDay(): Promise<void>; // PUT  .../end-of-day
}
```

Deliberately **out of the port**: register creation (one-time human-owned setup, never a runtime
application action), petty cash deposit/withdraw, X reports (undocumented), receipt
email/print/ESC-POS delivery (no MVP surface), and any Skatteverket submission (no API). Where
`cashRegisterName` and `controlUnitSerialNumber` come from is deployment configuration, not
per-request application state. Non-commercial orders bypass checkout and have no provider order, so
they receive no fiscal record in the MVP — a future decision, not implied here.

## Environment topology and release implications

| Environment  | Adapter selection                                                                        | Credentials                                                                                              | Purpose                                                                          |
| ------------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Local / test | `CHECKOUT_MODE=disabled` or `fake` (deterministic fake)                                  | None — credential-free, offline                                                                          | All developer work and automated acceptance; never touches Surfboard             |
| Staging      | Surfboard adapter via server-side configuration                                          | Demo base URL, Demo key/secret, non-production merchant/store/terminal/register IDs, Demo webhook secret | Proves an order reaches a terminal payment state plus required ECR/receipt facts |
| Production   | **The same adapter**, independently supplied Live base URL, credentials, and identifiers | Live values issued to a person after certification                                                       | Real transactions; enabled only after every Live prerequisite below              |

Environment selection is configuration, not application branching. Nothing in the repository changes
between staging and production except deployed values, so **production requires no source-code
edit** (MVP-TECH-002-S03). Staging cannot select Live accidentally because the adapter reads exactly
one credential set from server-side configuration and no request, user action, or browser input can
influence it. Local/test and automated acceptance stay on the fake regardless of any Surfboard
availability — discovery completing or failing never blocks fake-backed work (MVP-TECH-002-S02).
Live calls, Live CI transactions, and provisioning charged ECR resources are prohibited;
refund/capture/void outside Demo require a human.

Release implications: the Demo-to-Live path is a deployment event (new credentials and base URL)
plus the human certification sequence — not a code branch, feature flag, or rebuild.

## Demo prerequisites and human-owned steps

**Demo prerequisites for MVP-TASK-034 (all human-owned, none completed by this task):**

1. Developer-portal account signed up and Demo base URL, API key, API secret, and merchant ID copied
   from the console into the deployment/local `.env` by a person — values are never pasted into
   chat, source, or `.env.example` (BRIEF step 3).
2. Demo merchant and store exist (sign-up provisions Demo capability), and a networked EMV terminal
   is registered to the store; its `terminalId` is recorded in configuration.
3. Webhook subscription created in the console/ADR flow with a monitored `alertEmail`; the returned
   signing secret stored as `SURFBOARD_WEBHOOK_SECRET`; the public HTTPS ingress URL reachable from
   Surfboard.
4. ECR capability confirmed for the Demo merchant: partner/portal `ecrEnabled` available, cash
   register created once (`cashRegisterName` + terminal `deviceId`) and its `cashRegisterId` stored
   in configuration, control-unit facts (`controlUnitSerialNumber`, manufacturer/model/type/address)
   obtained from the merchant.
5. Receipt sequence semantics confirmed in Demo: who assigns `sequenceNumber`, what resets it —
   currently **Unknown** (RECEIPT wording says "from your cash register").

**Human-owned steps outside the MVP (explicit, not blocking local or Demo work):**

- Skatteverket declaration: a merchant-side legal obligation with **no API in Surfboard's published
  surface** (question 15); completion or applicability is confirmed by the merchant, never by this
  application.
- Live enablement (in order): signed contract and approval; onboarding/certification call with
  Surfboard; production credentials issued **to a person**; base URL switched to Live; merchant
  onboarding/KYB and ECR enablement for the production merchant; terminal/register provisioning for
  production; explicit release approval; Skatteverket declaration where applicable (BRIEF, GOLIVE,
  story invariants).
- Physical card-tap proof on real hardware: impossible in Demo (payment-page mode), so it happens at
  certification, not in staging evidence.

## Register of unknowns (explicit, not blocking the fake)

1. Provider-side request idempotency (question 7) — mitigation: application-side duplicate
   suppression; no provider claim is made anywhere.
2. ECR availability in Demo (question 12) — if the Demo merchant cannot use ECR, staging evidence
   degrades to payment-only and the blocker is recorded instead of invented behaviour.
3. Skatteverket declaration interface (question 15) — none exists; human obligation.
4. X report (question 17) — undocumented; `Z_REPORT` only.
5. Receipt `sequenceNumber` ownership/reset — confirmed only by Demo exercise (prerequisite 5).
6. Exact terminal semantics of `PAYMENT_PROCESSED` — assumed non-terminal; confirm in Demo.
7. Webhook retry schedule and exact disablement behaviour beyond the documented 7-day rule — treated
   as opaque; the application never depends on a specific retry cadence.
8. Whether `referenceId` influences provider-side deduplication — Unknown; never assumed.

## Consequences and non-goals

- The fake remains the only adapter in the repository after this task; nothing about discovery
  changed runtime behaviour, the HTTP contract, or OpenAPI, and no test was required beyond
  documentation checks (prose-only change).
- All Surfboard vocabulary stays inside `backend/src/integrations/`; the neutral contract gains
  nothing from this record by itself, and the static provider-boundary guard continues to enforce
  that.
- The design is reviewable end to end: adapter mapping, configuration names, secret ownership,
  logging redaction, webhook ingress/authentication, timeout/retry rules, and reconciliation
  ownership each have a section above.
- Explicitly not done and not allowed in this task: opening a developer-portal account, requesting
  or storing credentials, creating ECR/register resources, any Live call, any card-data handling,
  refunds/captures/voids, settlement automation, and an offline queue.

## MVP-TASK-034 handoff — exact prerequisites

MVP-TASK-034 must not start until, and must record a precise blocker if any item is missing:

1. This record reviewed and the two port designs accepted (or revised here first).
2. Demo credentials (base URL, key, secret, merchant ID) present in the staging/secret
   configuration, supplied by a human — never sourced from chat, source, or fixtures.
3. Demo store with a registered **networked EMV** terminal; `SURFBOARD_TERMINAL_ID` set. SoftPOS is
   disqualified for this architecture (hardware section).
4. ECR confirmation for the Demo merchant (prerequisite 4 above), including `cashRegisterId` and
   control-unit facts — or an explicit, recorded decision to ship payment-only staging evidence with
   the ECR gap named.
5. Webhook secret and a publicly reachable HTTPS ingress route; the route addition ships with its
   `docs/api/openapi.yaml` update and contract test in the same task.
6. Confirmed receipt `sequenceNumber` semantics (prerequisite 5) before any fiscal write.
7. Existing safety tasks (MVP-TASK-028–032) landed, because duplicate suppression and
   unknown-outcome handling are this design's substitute for the provider idempotency that does not
   exist.
8. A separately triggered Demo smoke test planned: create order, initiate payment, poll or consume
   the webhook to a terminal state, record `orderId` and final status, verify receipt/ECR facts
   reconcile, with no credential exposed — success is a real observed response, never compiled code
   (BRIEF step 5, task 034 acceptance).
9. If Demo, hardware, or ECR remains unavailable: implement no guessed behaviour; record the precise
   blocker and keep local/automated work fake-backed (MVP-TECH-002-S02).

## Traceability

- `MVP-TECH-002-S01` — this record: every question above with source and date, unknowns explicit,
  Demo and human-owned prerequisites listed.
- `MVP-TECH-002-S02` — environment topology section plus unchanged fake-backed acceptance; this task
  added no runtime code, so all application acceptance remains deterministic.
- `MVP-TECH-002-S03` — ports, configuration, secret ownership, and release-implications sections:
  provider fields stay in adapters, staging selects Demo from server-side configuration, production
  is configuration-only, and unsupported capabilities are in the unknowns register.
