# Acquirer partner API (phase 3)

Base URL `http://localhost:34100`. JSON in and out. Every request is HMAC-signed with the partner's secret.

## Authentication

Uses `BqrProtocol.Signature` (HMAC-SHA256). Send on every request:

| Header | Value |
|---|---|
| `x-bqr-participant` | the partner's `participant_id` |
| `x-bqr-timestamp` | unix epoch **milliseconds**; accepted within ±300 s |
| `x-bqr-nonce` | random string, max 128 chars, **single use per partner** |
| `x-bqr-signature` | `hex(HMAC-SHA256(secret, timestamp "\n" nonce "\n" raw_body))` |

The signature covers the exact bytes of the body; a request without a body is signed over the empty string.
Nonces are remembered for 15 minutes (more than twice the timestamp window). The signature is checked before the
nonce is recorded, so a rejected request does not burn its nonce.

Known limitation: the contract signs timestamp, nonce and body only, not the method or path. Replay is blocked by the
single-use nonce; binding the path would be a change to `bqr_protocol`.

401 codes: `missing_x_bqr_*`, `bad_signature`, `bad_timestamp`, `timestamp_out_of_range`, `replayed_nonce`, `bad_nonce`,
`unauthorized` (unknown or suspended participant, deliberately indistinguishable).

## Browsers (development only)

With `config :bqr_acquirer, :dev_cors, true` (set only in `config/dev.exs`; `false` everywhere else) the acquirer answers browser CORS
preflights and adds `access-control-allow-origin: *`, so a page on another origin, including a `file://` page such as
`tools/qr-generator.html`, can call the API with XHR. A wildcard is acceptable because the API authenticates with signature headers, not
cookies. Signing in a browser means the secret sits in the page: fine for a dev tool with the demo partner, never for a real secret.

## Errors

```json
{"error": {"code": "validation_failed", "message": "Request validation failed", "fields": {"amount": ["..."]}}}
```

| Status | Codes |
|---|---|
| 401 | see above |
| 404 | `not_found` (also for another partner's merchant or QR, so ids cannot be probed) |
| 409 | `reference_amount_mismatch`, `reference_already_used` |
| 422 | `validation_failed` (with `fields`), `merchant_not_active`, `qr_build_failed` |

## Endpoints

Money is a decimal **string with exactly 2 decimals** (`"250.00"`); a JSON number is rejected. Internally integer poisha.

### `POST /api/v1/qr/static`
Body `{"merchant_id": "<uuid>"}`. The merchant's reusable QR, no amount. Created once: `201` the first time, `200` with the
same QR afterwards.

### `POST /api/v1/qr/dynamic`
Body:

| Field | Required | Notes |
|---|---|---|
| `merchant_id` | yes | |
| `amount` | yes | `"250.00"`, greater than zero, at most 10 integer digits |
| `reference` | no | 1-25 chars of `A-Z a-z 0-9 . _ / -`; generated (12 chars) when absent. Goes in tag 62 sub-ID 05 |
| `expires_in` | no | seconds, 1 to 86400, default 900 |

One transaction, single use, expires. `201` when issued. **Idempotent by `(merchant_id, reference)`**: the same request again
while the QR is still active returns `200` with the same QR; same reference with another amount is `409
reference_amount_mismatch`; a reference whose QR is used or expired is `409 reference_already_used`.

### `GET /api/v1/merchants`
The authenticated partner's own merchants (`id, external_ref, name, city, mcc, kind, status, pan`), oldest first. Other partners' merchants
never appear. Used by `tools/qr-generator.html` to fill its merchant list.

### `GET /api/v1/qr/:id`
Returns the QR. `status` is `active | used | expired | cancelled`; an overdue dynamic QR reads `expired` even before the
janitor persists it.

`GET` also returns `credit_status` (the create endpoints do not): the status of the credit recorded against this QR,
`credited | declined | reversed`, or `null` while none exists (and always `null` for a static QR, which is never tied to one
credit). A reversed credit keeps its QR `used`, so **treat the merchant as paid only when `status` is `used` and
`credit_status` is `credited`**. Poll this endpoint to learn the outcome of a dynamic QR.

### Response

```json
{"data": {
  "id": "…", "merchant_id": "…", "type": "dynamic", "status": "active",
  "amount": "250.00", "currency": "050", "reference": "INV10025",
  "expires_at": "2026-10-14T08:51:28.771000Z", "used_at": null, "created_at": "2026-10-14T08:49:28Z",
  "payload": "000201010212…"
}}
```

`payload` is the Bangla QR string to render. It always round-trips through `BqrCore.decode/2` (tested).

## Switch endpoint: `POST /api/v1/switch/credit` (phase 4)

Only the switch may call it: same HMAC auth, then the caller must be the participant in
`config :bqr_acquirer, :switch_participant_id` (`bqr-switch`), otherwise 403 `forbidden`. It takes a `credit_request`
(a `pay_request` plus `switch_ref`) and answers a `txn_response`. Like the switch, **any request with a `txn_id` gets HTTP 200**:
`SUCCESS`, or `FAILED` with the reason in `code`. 422 `invalid_message` only when there is no usable `txn_id`.

Checks, in order (the first that fails declines):

1. Message valid; amount greater than zero and at most 13 characters. Invalid: `FAILED INVALID_MESSAGE`.
2. `qr_payload` decodes (CRC, framing, field rules) and agrees with the message on `merchant_pan`, `acquirer_id`, `qr_type`,
   `currency` and `reference_label`. Otherwise `INVALID_MESSAGE`.
3. `acquirer_id` is ours and `(acquirer_id, merchant_pan)` is an **active** merchant. Otherwise `INVALID_MERCHANT`.
4. Static QR: any amount above zero; the QR is never consumed, so it can be paid any number of times.
5. Dynamic QR: found by `(merchant, reference)` else `QR_NOT_FOUND`; the payload must be **byte-for-byte the QR we issued**
   (a payload edited in transit is `INVALID_MESSAGE`); then `QR_ALREADY_USED`, `QR_EXPIRED`, `AMOUNT_MISMATCH`; then the QR is
   consumed atomically.

The QR is consumed and the credit recorded in **one database transaction**: the QR is used if and only if the merchant is credited.
A decline never consumes the QR (a wrong amount leaves it payable).

**Idempotent by `txn_id`** (table `credits`, unique on `txn_id`; it is also the merchant credit ledger). A repeat returns the first
answer, even after the QR has since expired or been used, and is processed once under concurrency. Same `txn_id` with a different
request is `FAILED DUPLICATE_TXN` and changes nothing. A dynamic QR can be credited at most once (unique index on `qr_id`).

### Resolving a credit: `POST /api/v1/switch/status` and `POST /api/v1/switch/reverse` (phase 6)

Same auth (switch only), same rule: a request with a `txn_id` gets HTTP 200 and a `txn_response`. Bodies: `{"txn_id"}` and
`{"txn_id", "reason"}`. The acquirer is the only party that knows whether the merchant was credited, so its answers decide.

| Request | Merchant was credited | Declined earlier | Never seen |
|---|---|---|---|
| `status` | `SUCCESS` (+ `acquirer_ref`) | `FAILED` + the original code | `FAILED TXN_NOT_FOUND` |
| `reverse` | undone: `FAILED REVERSED`; repeated: same | `FAILED` + the original code | `FAILED TXN_NOT_FOUND` |

* **Reversal** marks the credit `reversed` (`reversed_at`, `reversal_reason`) and answers `REVERSED`. **The QR stays used** (owner
  decision): the merchant must issue a new QR, so a reversed payment can never be paid again by someone holding the old one.
  Reversing twice, or a `status` afterwards, returns the same `REVERSED`.
* **Late-arrival guard.** A `status` or `reverse` for a `txn_id` the acquirer has not seen **blocks** it (`txn_blocks`). A
  `credit_request` for it that arrives afterwards (a delayed delivery) is refused with `TXN_NOT_FOUND`, never credited. The
  check, the credit and the block all run under one per-`txn_id` lock, so a credit and a reversal arriving together cannot
  both win. Otherwise a payment the issuer was told had failed could still be credited.
* A credit is idempotent by `txn_id`; the answers above come from the stored `credits` row.

## Data model

* `partners` – API clients. `secret` is stored as-is because HMAC verification needs it (encrypt at rest later).
* `pan_allocations` – NPSB-issued 6-digit BINs, one active per kind (`regular`, `micro`), with an atomic serial counter.
  Merchant PAN = BIN + zero-padded serial, 16 chars (assumption, see CLAUDE.md).
* `merchants` – belong to a partner; carry the PAN and the acquirer identity (tag 26 sub-IDs 01 and 02) copied at creation.
* `qr_codes` – `static | dynamic`, `amount_minor`, `reference`, `payload`, `expires_at`, `status`, `used_at`.
  DB-enforced: one active static QR per merchant; one dynamic QR per `(merchant, reference)`; dynamic QRs must have amount,
  reference and expiry.
* `api_nonces` – replay protection.
* `credits` – one row per `credit_request` from the switch, declines included: idempotency record and merchant credit ledger
  (`status` `credited` | `declined` | `reversed`, `code`, `amount_minor`, `merchant_id`, `qr_id` for dynamic QRs only; a reversed
  credit keeps its `qr_id`, so the QR stays used).
* `txn_blocks` – `txn_id`s that were asked about before they arrived and must never be credited.

Lifecycle helpers for the payment phase: `QrCodes.consume/2` marks a dynamic QR used atomically (exactly one concurrent
caller wins, expired or already used QRs are refused, static QRs are never consumed). `BqrAcquirer.Janitor` persists expiry
and prunes nonces every minute; correctness never depends on it.

## Configuration

`config :bqr_acquirer, :identity, acquirer_type: :psp, acquirer_id: "9999"` – **placeholder** from the spec example; set the
ID NPSB assigns. `config :bqr_acquirer, BqrAcquirer.QrCodes, default_expiry_seconds: 900, max_expiry_seconds: 86_400`.

## Trying it

```bash
cd apps/bqr_acquirer
mix ecto.migrate && mix run priv/repo/seeds.exs     # prints a demo partner, secret, merchant_id and a static QR
mix phx.server
```

Seed credentials (dev only): participant `demo-partner`, secret `dev-only-demo-partner-secret-0123456789`. In Elixir, sign with
`BqrProtocol.Signature.headers(participant, secret, body)` and send the four headers it returns.
