# Ghaem Gold API

Revision: 51ce12b9664f


========================================================================
# Assets API (app: assets, version 1.0.0)
========================================================================

Servers: http://localhost:3000

The asset registry: what a balance line can be denominated in — Rial, gold and
silver weights, individual coin variants, bullion, and foreign currencies —
plus the review queue for everything the sync pipeline had to invent at
runtime.

When the accounting export names an asset this system has never seen, the
ingest pipeline cannot stop and it must not guess silently. It registers the
asset on the spot, keyed on the vendor's exact Persian label, marks it
`isPendingReview`, and puts it in front of a human. This module is that
human's screen.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. Operator opens `GET /api/v1/assets?isPendingReview=true` — the review
   queue of assets the sync auto-registered.
3. Operator judges each row by its `usageCount`: "this unnamed asset appears
   on 14 balances" is a different priority from "…on none".
4. Operator opens one with `GET /api/v1/assets/{id}`, which returns the row's
   `version` in the `ETag` header.
5. Operator names it and, where necessary, corrects its classification with
   `PATCH /api/v1/assets/{id}`, echoing that `ETag` back as `If-Match`.
6. When the operator is satisfied the classification is right, they send
   `isPendingReview: false` in the same or a later `PATCH` to clear it from
   the queue.

## Security Notes
- Every endpoint requires a valid Bearer access token. Reads need
  `assets:read`; the edit needs `assets:manage`. In the seeded role set
  `ACCOUNTANT` holds both and `VIEWER` holds only `assets:read`.
- There is no ownership scoping: an operator who holds the permission sees
  every asset. An unknown id is `404 RESOURCE_NOT_FOUND`.
- **`sourceLabelFa` is not editable.** It is the vendor's own matching key —
  the exact normalised label the ingest pipeline matches a balance line
  against. Changing it would silently orphan every existing balance line and
  make the next sync auto-register a duplicate. `key`, `unit` and `decimals`
  are likewise absent from the update schema.
- Changing `metal` or `kind` is not a rename; it is a **repair**. It re-runs
  the gold invariant over every latest balance snapshot that carries this
  asset and reports each row whose verdict flipped, in the same response.
  Reclassifying a mislabelled silver bullion out of the gold coin family
  moves real weight out of a real sum, and the affected parties' invariant
  flags have to move with it.
- The re-check covers **latest snapshots only**. Historical snapshots are
  deliberately left as they were: they record what was believed at the time.
- `isPendingReview` is cleared **only** when the caller explicitly sends
  `isPendingReview: false`. Naming an asset does not confirm it by
  implication — "renamed, classification not yet verified" is a real state,
  and inferring confirmation from an edit would erase it.
- `nominalGram` is reference metadata for display only. It is a decimal
  **string** at three-decimal scale and must never be multiplied by a coin
  count to derive a weight.
- `PATCH` requires `If-Match` carrying the row's `version`, echoed as the
  `ETag` response header by every single-asset read and write. A stale value
  is `409 CONCURRENT_MODIFICATION`.
- Every success is the §9.1 envelope `{ data, meta }`; every failure is
  `{ error: { code, message, messageFa, status, details, requestId } }` with
  both an English and a Persian sentence.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## x-review-queue
description: How an asset gets into the review queue and what takes it out, from `src/modules/assets/assets.service.ts` and the ingest transformers.
entry: When the accounting export names an asset whose normalised Persian label matches no existing sourceLabelFa, the ingest pipeline registers a new asset on the spot — nameFa and nameEn both set to the raw vendor label, sortOrder 9000, isPendingReview true — rather than dropping the balance line or guessing a classification.
triage: usageCount on the list response is the priority signal: an unnamed asset on 14 balances matters more than one on none.
exit: Only an explicit isPendingReview: false on PATCH /api/v1/assets/{id} clears the flag. Renaming leaves the row in the queue, because "renamed, classification not yet verified" is a real state.

## x-invariant-recheck
description: Why a classification edit is a repair rather than a rename, from `src/modules/assets/asset-maintenance.ts`.
trigger: A patch that changes `kind` or `metal`. Any other patch reports `ran` as false.
scope: Latest balance snapshots carrying this asset only. Historical snapshots are left as they were, because they record what was believed at the time; the count of those skipped is reported rather than hidden.
transaction: The re-check runs inside the same transaction as the edit, so a failure rolls the rename back with it and no half-applied reclassification can commit.
reading_the_report: A false → true flip in balancesChanged is a repair. A true → false flip means the new classification breaks an invariant that previously held and should be reconsidered.

## Operations

### GET /api/v1/assets
operationId: listAssets
auth: bearer
summary: List assets, pending-review ones first.

Returns a page of assets with the number of balance lines depending on
each one.

**Notes:**
- Requires `assets:read`.
- Rows with `isPendingReview: true` sort **first regardless of any
  filter**. The queue's whole purpose is that these surface without
  anyone having to remember to look for them.
- `?isPendingReview=true` narrows the page to that queue explicitly.
- `search` is a substring match over `key`, `nameEn`, `nameFa` **and**
  `sourceLabelFa` — the last of these is how an operator finds the asset
  behind an unfamiliar label in the accounting export.
- `usageCount` is the number of balance lines referencing the asset. It is
  what makes the queue decidable and is computed per request, not stored.
- `isActive` and `isPendingReview` coerce loosely: `"true"`, `"1"` and
  similar truthy strings are accepted. `kind` and `metal` are strict
  enums; an unrecognised value is `400`.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100. A page
  past the end returns an empty `data` array, not `404`.
- Side effects: none.
responses:
  200: PaginatedAssets
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/assets/{id}
operationId: getAsset
auth: bearer
summary: Retrieve one asset.

Returns a single asset, including the vendor label it is matched by.

**Notes:**
- Requires `assets:read`.
- Sets the `ETag` response header to the row's `version`, quoted — e.g.
  `ETag: "1"`. Send that value back as `If-Match` on the subsequent
  `PATCH`.
- `usageCount` is **not** included on this shape; it is a list-only
  convenience.
- `sourceLabelFa` is the exact normalised label the ingest pipeline
  matches balance lines against. It is `null` for a seeded asset that no
  vendor label maps onto directly, such as `IRR`.
- Side effects: none.
responses:
  200: AssetEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/assets/{id}
operationId: updateAsset
auth: bearer
summary: Name or reclassify an asset.

Applies a partial update under an optimistic-concurrency precondition
and, when the classification moved, re-judges the gold invariant over
every latest balance snapshot carrying this asset.

**Notes:**
- Requires `assets:manage`.
- **`If-Match` is required**. Omitting it is `400 VALIDATION_FAILED` on
  the `If-Match` field; a stale value is `409 CONCURRENT_MODIFICATION`
  carrying both the expected and the actual version. `W/"1"`, `"1"`, `1`
  and `*` are all accepted spellings.
- At least one field must be supplied.
- `sourceLabelFa`, `key`, `unit` and `decimals` are **not** accepted.
  `sourceLabelFa` in particular is the vendor's matching key: changing it
  would orphan every existing balance line and cause the next sync to
  auto-register a duplicate.
- Changing **`kind` or `metal`** triggers the invariant re-check. The
  response's `invariantRecheck` reports `ran: true`, how many latest
  snapshots were examined, how many historical ones were skipped, and one
  entry for **each row whose verdict actually flipped**. An empty
  `balancesChanged` array means the reclassification did no harm.
- When the patch touches neither `kind` nor `metal`, `invariantRecheck`
  comes back with `ran: false` and zeroes — the invariant cannot have
  moved.
- The re-check runs inside the same transaction as the edit, so a failure
  there rolls the rename back with it.
- `isPendingReview` is cleared **only** by explicitly sending
  `isPendingReview: false`. Renaming an asset leaves it in the queue.
- `coinVintage` and `nominalGram` accept `null` to clear them.
  `nominalGram` must be a decimal string with at most three fractional
  digits; it is display metadata and is never used in arithmetic.
- Side effects: the asset row is updated, affected latest balance
  snapshots have their invariant flag corrected, and an `asset.updated`
  audit row is written with a before/after diff.
- The response carries the updated asset and an updated `ETag`.
request body: UpdateAssetRequest
responses:
  200: AssetUpdateEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PaginationMeta
type: object
description: The §9.1 success envelope's `meta` for an offset-paginated list. `totalPages` is derived by the envelope interceptor, never by a handler.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total rows matching the filter across all pages.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when `total` is 0.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - page
  - pageSize
  - total
  - totalPages
  - requestId
  - timestamp

### AssetKind
type: string
enum:
  - FIAT_IRR
  - GOLD_WEIGHT
  - GOLD_WEIGHT_EX_COIN
  - SILVER_WEIGHT
  - COIN
  - BULLION
  - FX
description: What the asset is. `GOLD_WEIGHT_EX_COIN` is the gold position with coins excluded, which is what makes the gold invariant checkable. `COIN` rows carry both a gram figure and a count at once. Editable — and editing it triggers the invariant re-check.

### Metal
type: string
enum:
  - GOLD
  - SILVER
  - NONE
description: Which metal the asset is made of; `NONE` for Rial and foreign currency. Editable — and editing it triggers the invariant re-check. Getting this wrong is what puts silver weight into a gold sum.

### AssetUnit
type: string
enum:
  - RIAL
  - GRAM
  - COUNT
  - FX_MAJOR
description: The unit a balance line in this asset is measured in. Read-only: it is fixed when the asset is registered, because every persisted balance line is already stored in it.

### Asset
type: object
description: One denomination a balance line can be expressed in. Seeded assets arrive confirmed; anything the sync met at runtime arrives with `isPendingReview: true`.
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7). The identifier every endpoint in this file takes.
  key:
    type: string
    description: Stable machine key, e.g. `IRR`, `XAU18`, `COIN_EMAMI_ZIR`. Read-only; an auto-registered asset gets a generated one.
  kind:
    $ref: #/components/schemas/AssetKind
  unit:
    $ref: #/components/schemas/AssetUnit
  metal:
    $ref: #/components/schemas/Metal
  nameFa:
    type: string
    description: Persian display name. For an auto-registered asset this starts as the vendor's raw label and is the first thing a reviewer replaces.
  nameEn:
    type: string
    description: English display name. For an auto-registered asset this also starts as the vendor's Persian label, which is why the queue exists.
  sourceLabelFa:
    type: string
    nullable: True
    description: The **exact normalised** `Name` from the accounting export that this asset is matched by. Read-only: changing it would orphan every existing balance line and make the next sync register a duplicate. `null` for a seeded asset no vendor label maps onto, such as `IRR`.
  nominalGram:
    type: string
    nullable: True
    description: Decimal **string** at three-decimal scale, e.g. `"9.760"`. Reference metadata for display only — never multiply it by a coin count to derive a weight. `null` when not applicable or not yet recorded.
  decimals:
    type: integer
    description: How many fractional digits this asset's amounts carry. Read-only and fixed at registration, since stored values already use it.
  coinVintage:
    type: string
    nullable: True
    description: The mint year that distinguishes one coin variant from another, e.g. `"1386"`. `null` for non-coin assets and for coins where the vendor does not distinguish vintages.
  sortOrder:
    type: integer
    description: Display ordering, ascending. Auto-registered assets get a high value (9000) so they sit at the end of a confirmed listing — though the pending flag still floats them to the top.
  isActive:
    type: boolean
    description: Whether the asset is offered in the panel. Retiring an asset does not delete it.
  isPendingReview:
    type: boolean
    description: `true` ⇒ auto-discovered at runtime and still waiting for a human. Such rows sort first regardless of filter. Cleared **only** by explicitly sending `isPendingReview: false`.
  version:
    type: integer
    description: Optimistic-concurrency counter. Returned as the `ETag` header and required back as `If-Match` on `PATCH`.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant — for an auto-registered asset, the sync run that first met its label.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write.
  usageCount:
    type: integer
    description: Balance lines referencing this asset. Present on the **list** response only, computed per request, and the number that makes the review queue decidable.
required:
  - id
  - key
  - kind
  - unit
  - metal
  - nameFa
  - nameEn
  - sourceLabelFa
  - nominalGram
  - decimals
  - coinVintage
  - sortOrder
  - isActive
  - isPendingReview
  - version
  - createdAt
  - updatedAt

### InvariantChange
type: object
description: One latest balance snapshot whose gold-invariant verdict flipped as a result of the reclassification.
properties:
  balanceId:
    type: string
    format: uuid
    description: Internal id of the balance snapshot.
  externalCode:
    type: string
    description: The accounting system's party code the snapshot belongs to, a **string**. Openable at `GET /api/v1/financial-records/{externalCode}`.
  observedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the snapshot was taken from the accounting export.
  invariantOkBefore:
    type: boolean
    description: The verdict this snapshot carried before the reclassification.
  invariantOkAfter:
    type: boolean
    description: The verdict it carries now. A `false → true` flip is a repair; a `true → false` flip means the reclassification has broken something and should be reconsidered.
required:
  - balanceId
  - externalCode
  - observedAt
  - invariantOkBefore
  - invariantOkAfter

### InvariantRecheckReport
type: object
description: What the classification change did to already-persisted balances. Returned in the same response as the edit, because an operator correcting silver-as-gold needs to see the affected rows, not go looking for them afterwards.
properties:
  ran:
    type: boolean
    description: `false` when the patch touched neither `kind` nor `metal` — the invariant cannot have moved, so nothing was examined.
  scope:
    type: string
    enum:
      - LATEST_SNAPSHOTS
    description: Always `LATEST_SNAPSHOTS`. Historical snapshots are deliberately left alone: they record what was believed at the time.
  balancesExamined:
    type: integer
    description: Latest snapshots carrying this asset that were re-judged. `0` when `ran` is `false`.
  balancesChanged:
    type: array
    description: Only the rows whose verdict actually flipped. An empty array means the reclassification did no harm.
    items:
      $ref: #/components/schemas/InvariantChange
  historicalSnapshotsSkipped:
    type: integer
    description: How many non-latest snapshots carrying this asset were left untouched, reported so the number is visible rather than silently omitted.
required:
  - ran
  - scope
  - balancesExamined
  - balancesChanged
  - historicalSnapshotsSkipped

### AssetEnvelope
type: object
description: Success envelope around a single asset.
properties:
  data:
    $ref: #/components/schemas/Asset
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### AssetUpdateEnvelope
type: object
description: Success envelope around an edited asset and its invariant re-check report.
properties:
  data:
    type: object
    properties:
      asset:
        $ref: #/components/schemas/Asset
      invariantRecheck:
        $ref: #/components/schemas/InvariantRecheckReport
    required:
      - asset
      - invariantRecheck
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedAssets
type: object
description: A page of assets in the §9.1 paginated envelope. Rows here carry `usageCount`, which the single-asset shape omits.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/Asset
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### UpdateAssetRequest
type: object
description: Partial update; at least one field must be supplied. `sourceLabelFa`, `key`, `unit` and `decimals` are deliberately absent — all four are fixed at registration and every persisted balance line already depends on them.
properties:
  nameEn:
    type: string
    minLength: 1
    maxLength: 120
    description: English display name.
  nameFa:
    type: string
    minLength: 1
    maxLength: 120
    description: Persian display name.
  kind:
    allOf:
      -
        $ref: #/components/schemas/AssetKind
    description: New classification. **Changing this triggers the invariant re-check** over every latest balance snapshot carrying the asset.
  metal:
    allOf:
      -
        $ref: #/components/schemas/Metal
    description: New metal. **Changing this triggers the invariant re-check** over every latest balance snapshot carrying the asset.
  coinVintage:
    type: string
    minLength: 1
    maxLength: 16
    nullable: True
    description: Mint year, e.g. `"1386"`, or `null` to clear it.
  nominalGram:
    type: string
    nullable: True
    pattern: ^-?\d+(?:\.\d{1,3})?$
    description: Reference weight as a decimal **string** with at most three fractional digits, or `null` to clear it. Display metadata only; never used in arithmetic.
  sortOrder:
    type: integer
    minimum: 0
    maximum: 10000
    description: New display ordering, ascending.
  isActive:
    type: boolean
    description: Whether the asset is offered in the panel.
  isPendingReview:
    type: boolean
    description: Send `false` to confirm the asset and clear it from the review queue. Nothing else clears it — naming an asset does not confirm it by implication.


========================================================================
# Audit Log API (app: audit, version 1.0.0)
========================================================================

Servers: http://localhost:3000

«مشاهده لاگ‌ها» — the append-only, tamper-evident record of everything that
happened in the system, and the tool that proves it has not been altered.

Every mutation anywhere in this API writes its audit row **inside the same
transaction** as the change itself, so a change that committed always has a
row and a row never describes a change that rolled back. Each row carries the
hash of the row before it, forming a chain: altering or deleting any row
invalidates every row after it, which is what
`GET /api/v1/audit-logs/verify-chain` detects.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. Client loads the filter dropdown from
   `GET /api/v1/audit-logs/actions` — the catalog of every action the system
   can record.
3. Operator browses with `GET /api/v1/audit-logs`, filtering by actor,
   action, category, severity, outcome, resource, date range or free text.
4. Paging uses a **cursor**, not a page number: `meta.nextCursor` from one
   response becomes `?cursor=` on the next.
5. Operator opens one row with `GET /api/v1/audit-logs/{id}` for its full
   `changes` diff, `metadata`, and hash-chain fields.
6. `GET /api/v1/audit-logs/verify-chain` walks the whole chain and reports the
   first break, if any.
7. Exporting the log is an administrator action — see the Admin Audit Log API.

## Security Notes
- Every endpoint requires a valid Bearer access token. Browsing, reading one
  row and listing the action catalog need `audit:read`; verifying the chain
  needs `audit:verify`. Exporting needs `audit:export`, which only `ADMIN`
  holds — that endpoint is in the Admin Audit Log API.
- In the seeded role set `VIEWER` holds `audit:read` but not `audit:verify`.
- The log is **append-only over HTTP and in the code**: there is no `POST`,
  `PATCH` or `DELETE` anywhere in this module. Rows are written only as a side
  effect of the operation they describe.
- A denied permission check anywhere in the API writes its own row here with
  `outcome: "DENIED"`. Repeated denials are a meaningful security signal, and
  a denial nobody records is a probe nobody sees.
- Actor identity is **snapshotted** onto every row — username, full name and
  role key at the moment of the action — so the trail keeps reading correctly
  after an operator is renamed, demoted or soft-deleted.
- Secrets never reach a row. A password change records the field name and the
  literal `"[redacted]"`; identifiers such as IBANs appear masked, exactly as
  they do in the API responses.
- Both `summaryEn` and `summaryFa` are rendered server-side, so the panel
  never maintains its own translation of an action.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## Cursor pagination
- This module is the one place in the API that pages by **cursor** rather than
  by page number. An append-only table drifts under an offset reader: new rows
  arrive at the top mid-review and push everything down, so page 2 would show
  rows page 1 already showed.
- `meta` therefore carries `pageSize`, `hasMore`, `nextCursor` and
  `previousCursor` — and deliberately **no `total`**. Counting an append-only
  table on every page is both expensive and immediately stale.
- `direction: "FORWARD"` walks towards **older** rows; `"BACKWARD"` towards
  newer ones. Forward is the default.
- A cursor this system did not issue is rejected rather than silently ignored.
- Rows are ordered by id descending, and the id is time-ordered, so newest
  first is also insertion order reversed.

## x-hash-chain
description: How the trail is made tamper-evident, from `src/modules/audit/audit-hash.ts` and the chain verifier.
mechanism: Each row stores prevHash — the hash of the row before it — alongside its own hash, computed over its content. The rows therefore form a chain in insertion order.
detection:
  -
    altered_row: Recomputing the row's hash disagrees with the stored one: reason HASH_MISMATCH.
  -
    deleted_or_reordered_row: The next row's prevHash no longer matches the row before it: reason PREV_HASH_MISMATCH.
propagation: A break invalidates every row after it, which is why the verifier reports only the first one.
write_path: Every mutation queues its audit entry on the same transaction as the change, and the transaction helper flushes it before commit. A change that committed always has a row; a row never describes a change that rolled back.
redaction: Plaintext passwords never reach a row — the diff records the field name and the literal "[redacted]". IBANs and card numbers appear masked, exactly as in API responses.

## Operations

### GET /api/v1/audit-logs
operationId: listAuditLogs
auth: bearer
summary: List audit log entries, newest first.

Returns a cursor-paginated page of the trail, filtered as asked.

**Notes:**
- Requires `audit:read`.
- **Cursor-paginated, not offset-paginated.** `meta` carries `pageSize`,
  `hasMore`, `nextCursor` and `previousCursor`, and **no `total`**.
- Pass `meta.nextCursor` back as `?cursor=` to continue. `direction`
  defaults to `FORWARD`, which walks towards older rows.
- `pageSize` defaults to the module's own value and is clamped to 1–100.
- `q` is folded through the same Persian character map the rest of the
  system uses before being matched against the rendered summaries, so an
  Arabic-yeh query finds a Persian-yeh summary.
- `action` is validated **loosely** on purpose: an unrecognised action is
  a filter that matches nothing, not a `400`. `category`, `severity` and
  `outcome` are strict enums.
- `dateFrom` / `dateTo` bound `occurredAt` and are coerced from any
  date-parseable string.
- Filters combine with AND. Omitting all of them returns the whole trail,
  newest first.
- `changes` and `metadata` are free-form and vary by action; treat them as
  opaque unless you know the action.
- Side effects: none. Reading the log is not itself audited — only
  exporting it is.
responses:
  200: CursorPaginatedAuditLogs
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/audit-logs/actions
operationId: listAuditActions
auth: bearer
summary: List the catalog of recordable actions.

Returns every action the system can write, with its category and default
severity — the source for a filter dropdown.

**Notes:**
- Requires `audit:read`.
- Matched **before** `/{id}`, so `actions` is a reserved segment.
- **Not paginated** — `data` is a plain array, and it is compile-time
  constant: this is the catalog, not a query over what has actually
  happened. An action with no rows yet is still listed.
- Every action is named `<domain>.<entity>.<verb_past_tense>`, e.g.
  `payment.order.confirmed`, `auth.permission.denied`.
- `defaultSeverity` is what a row of this action gets unless the writing
  code overrides it, so a row's actual `severity` can differ.
- The ten categories are `AUTH`, `OPERATOR`, `RBAC`, `PARTY`,
  `BANK_ACCOUNT`, `PAYMENT`, `MATCHING`, `SETTINGS`, `SYNC` and `AUDIT`.
- Side effects: none.
responses:
  200: AuditActionCatalogEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/audit-logs/verify-chain
operationId: verifyAuditChain
auth: bearer
summary: Verify the audit log's hash chain.

Walks every row in insertion order, recomputing each hash and checking it
against the next row's recorded predecessor, and reports the first break.

**Notes:**
- Requires `audit:verify`. In the seeded roles `VIEWER` holds `audit:read`
  but **not** this — verifying is an integrity check, not a read.
- Matched **before** `/{id}`, so `verify-chain` is a reserved segment.
- `ok: true` with `firstBreak: null` is the healthy answer. `rowsChecked`
  says how much of the trail was covered.
- A break is reported **once**: the first one found. A tampered or missing
  row anywhere invalidates every row after it, so reporting them all would
  be noise.
- `reason: "HASH_MISMATCH"` means the row's own recorded hash does not
  match its content — the row itself was altered.
  `reason: "PREV_HASH_MISMATCH"` means the row's recorded predecessor hash
  does not match the row before it — a row was deleted or reordered.
- `index` is the position in insertion order, so an operator can see how
  far into the trail the break is without matching ids by eye.
- This endpoint walks the **whole** table. On a large trail it is a slow
  request by design; it is an integrity audit, not a health probe.
- Side effects: none. Unlike an export, verifying is not itself audited.
responses:
  200: VerifyChainEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/audit-logs/{id}
operationId: getAuditLog
auth: bearer
summary: Retrieve one audit log entry.

Returns a single row in full, including its change diff, its metadata and
its position in the hash chain.

**Notes:**
- Requires `audit:read`.
- The row is **immutable**. There is no `PATCH` or `DELETE` here, and none
  exists in the code either.
- `changes` is the before/after diff of the fields the action moved, and is
  `null` for actions that changed no field — a read, a denial, a login.
  Secrets appear as the literal `"[redacted]"`, never as a value.
- `metadata` is free-form context that varies by action: the permission and
  reason on a denial, the amount on a payment, the revoked-session count on
  a logout.
- `actorUsername`, `actorFullName` and `actorRoleKey` are **snapshots**
  taken when the action happened, so the row keeps reading correctly after
  the operator is renamed, demoted or deleted. `actorId` may point at an
  operator who no longer exists.
- `actorType` is `OPERATOR` for a human action and identifies the system
  for one performed by a scheduled job.
- `requestId` ties the row to the log lines and the API response from the
  same request.
- `prevHash` is `null` only for the very first row in the chain.
- Side effects: none.
responses:
  200: AuditLogEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### CursorMeta
type: object
description: The §9.1 envelope's `meta` for a cursor-paginated list. There is deliberately **no `total`**: counting an append-only table on every page is expensive and immediately stale.
properties:
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  hasMore:
    type: boolean
    description: Whether another page exists in the current `direction`.
  nextCursor:
    type: string
    nullable: True
    description: Pass back as `?cursor=` to continue in the same direction. `null` when there is nothing further.
  previousCursor:
    type: string
    nullable: True
    description: Pass back as `?cursor=` with `direction=BACKWARD` to walk towards newer rows. `null` on the first page.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - pageSize
  - hasMore
  - nextCursor
  - previousCursor
  - requestId
  - timestamp

### AuditCategory
type: string
enum:
  - AUTH
  - OPERATOR
  - RBAC
  - PARTY
  - BANK_ACCOUNT
  - PAYMENT
  - MATCHING
  - SETTINGS
  - SYNC
  - AUDIT
description: The area of the system an action belongs to. Fixed at ten values; an action's category comes from the catalog, not from the caller.

### AuditSeverity
type: string
enum:
  - INFO
  - NOTICE
  - WARNING
  - CRITICAL
description: How much attention the row deserves. `WARNING` covers refused permission checks; `CRITICAL` covers things like refresh-token reuse, which is the signature of a stolen credential.

### AuditOutcome
type: string
enum:
  - SUCCESS
  - FAILURE
  - DENIED
description: `SUCCESS` — the action completed. `FAILURE` — it was attempted and failed. `DENIED` — a permission check refused it before it ran.

### AuditLog
type: object
description: One immutable row of the trail. Written inside the same transaction as the change it describes, and hash-chained to the row before it.
properties:
  id:
    type: string
    format: uuid
    description: The row's id (UUID v7). Time-ordered, so it doubles as the pagination cursor and as the row's position in insertion order.
  occurredAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the action happened.
  actorType:
    type: string
    description: `OPERATOR` for a human action; a system identifier for one performed by a scheduled job or a background worker.
  actorId:
    type: string
    format: uuid
    nullable: True
    description: The acting operator's id, or `null` for a system action. May name an operator who has since been soft-deleted.
  actorUsername:
    type: string
    nullable: True
    description: The actor's username **snapshotted at the time of the action**, so the row keeps reading correctly after a rename. `null` for a system action.
  actorFullName:
    type: string
    nullable: True
    description: The actor's display name, likewise snapshotted.
  actorRoleKey:
    type: string
    nullable: True
    description: The role the actor held **at the time**, so a later demotion does not rewrite what they were allowed to do then.
  actorIp:
    type: string
    nullable: True
    description: Client IP recorded for the request; `null` when unavailable.
  userAgent:
    type: string
    nullable: True
    description: The request's `User-Agent` header; `null` when absent.
  requestId:
    type: string
    nullable: True
    description: ULID tying this row to the log lines and the API response from the same request. Several rows can share one — a cross-box move writes two.
  action:
    type: string
    description: The catalog action, named `<domain>.<entity>.<verb_past_tense>` — e.g. `payment.order.confirmed`.
  category:
    allOf:
      -
        $ref: #/components/schemas/AuditCategory
    description: The action's category, from the catalog.
  outcome:
    $ref: #/components/schemas/AuditOutcome
  severity:
    allOf:
      -
        $ref: #/components/schemas/AuditSeverity
    description: The row's actual severity, which may differ from the catalog's `defaultSeverity` when the writing code raised it.
  resourceType:
    type: string
    nullable: True
    description: What kind of record the action touched, e.g. `PaymentOrder`.
  resourceId:
    type: string
    nullable: True
    description: The record's id. `null` for an action that touched no single record.
  resourceLabel:
    type: string
    nullable: True
    description: A human-readable label for the record **at the time** — a payment reference, a username, a creditor's name.
  summaryFa:
    type: string
    description: Server-rendered Persian sentence describing what happened.
  summaryEn:
    type: string
    description: The same sentence in English. Both are rendered server-side.
  changes:
    nullable: True
    description: The before/after diff of the fields the action moved. `null` for actions that changed no field — a login, a denial, an export. Secrets appear as the literal `"[redacted]"`, never as a value; identifiers such as IBANs appear masked.
    type: array
    items:
      type: object
      additionalProperties: True
  metadata:
    type: object
    nullable: True
    description: Free-form context whose keys vary by action: the permission and reason on a denial, the amount on a payment, the revoked-session count on a logout. Treat it as opaque unless you know the action.
    additionalProperties: True
  prevHash:
    type: string
    nullable: True
    description: The hash of the preceding row. `null` only for the very first row in the chain.
  hash:
    type: string
    description: This row's own hash, computed over its content. Altering the row makes this disagree, which is what the chain verifier detects.
required:
  - id
  - occurredAt
  - actorType
  - actorId
  - actorUsername
  - actorFullName
  - actorRoleKey
  - actorIp
  - userAgent
  - requestId
  - action
  - category
  - outcome
  - severity
  - resourceType
  - resourceId
  - resourceLabel
  - summaryFa
  - summaryEn
  - prevHash
  - hash

### AuditActionCatalogEntry
type: object
description: One action the system can record. Compile-time constant — listed whether or not any row of it exists yet.
properties:
  action:
    type: string
    description: The action name, `<domain>.<entity>.<verb_past_tense>`.
  category:
    $ref: #/components/schemas/AuditCategory
  defaultSeverity:
    allOf:
      -
        $ref: #/components/schemas/AuditSeverity
    description: The severity a row of this action gets unless the writing code raises it, so an actual row's `severity` can be higher.
required:
  - action
  - category
  - defaultSeverity

### ChainBreak
type: object
description: The first — and only reported — break in the hash chain. Everything after it is equally invalid, so reporting them all would be noise.
properties:
  index:
    type: integer
    description: The break's position in insertion order, so an operator can see how far into the trail it is without matching ids by eye.
  id:
    type: string
    format: uuid
    description: The id of the row where verification failed.
  reason:
    type: string
    enum:
      - PREV_HASH_MISMATCH
      - HASH_MISMATCH
    description: `HASH_MISMATCH` — the row's own hash does not match its content, so the row was altered. `PREV_HASH_MISMATCH` — its recorded predecessor hash does not match the row before it, so a row was deleted or reordered.
required:
  - index
  - id
  - reason

### VerifyChainResult
type: object
description: The outcome of walking the whole chain.
properties:
  ok:
    type: boolean
    description: `true` when every row verified. `firstBreak` is then `null`.
  rowsChecked:
    type: integer
    description: How many rows were walked — the whole table.
  firstBreak:
    allOf:
      -
        $ref: #/components/schemas/ChainBreak
    nullable: True
    description: `null` when the chain is intact.
required:
  - ok
  - rowsChecked
  - firstBreak

### AuditLogEnvelope
type: object
description: Success envelope around a single audit row.
properties:
  data:
    $ref: #/components/schemas/AuditLog
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### CursorPaginatedAuditLogs
type: object
description: A cursor-paginated page of the trail. `meta` carries cursors instead of a page number, and no `total`.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/AuditLog
  meta:
    $ref: #/components/schemas/CursorMeta
required:
  - data
  - meta

### AuditActionCatalogEnvelope
type: object
description: Success envelope around the action catalog. Not paginated.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/AuditActionCatalogEntry
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### VerifyChainEnvelope
type: object
description: Success envelope around a chain verification.
properties:
  data:
    $ref: #/components/schemas/VerifyChainResult
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta


========================================================================
# Admin Audit Log API (app: audit-admin, version 1.0.0)
========================================================================

Servers: http://localhost:3000

Exporting the audit trail — a single endpoint, and the most tightly gated read
in the whole API.

Browsing the trail, reading one row, listing the action catalog and verifying
the hash chain are all in the Audit Log API and are open to any operator
holding `audit:read` or `audit:verify`. Taking a **bulk copy** of it out of
the system is different: the trail is the record of who did what, and a copy
of it leaves the tamper-evident chain behind. So the export needs a permission
only `ADMIN` holds, needs a password re-entry within the last five minutes,
and writes an audit row about itself.

## Authentication
This endpoint requires a Bearer access token whose operator holds
`audit:export` — one of the three **dangerous** permissions in the system, and
held only by `ADMIN` in the seeded role set.

1. Obtain a token pair from `POST /api/v1/auth/login` — see the
   Authentication API. There is no separate staff login endpoint.
2. Call `POST /api/v1/auth/reauth` with the password. This stamps the current
   access token as freshly password-verified for **five minutes**.
3. Send `Authorization: Bearer <accessToken>` on the export request, within
   that window.

Without a recent re-authentication the call is `403 AUTH_REAUTH_REQUIRED`,
which the panel should turn into a password prompt rather than an error toast.
**`ADMIN` is not exempt** from this: all three dangerous permissions are held
only by `ADMIN`, so exempting it would make the rule unreachable code. The
stamp is bound to the access token, so refreshing the token or logging out
ends it.

A missing, malformed or expired token returns `401`; a valid token whose
operator lacks `audit:export` returns `403 PERMISSION_DENIED`, audited with
`outcome: DENIED`.

## Conventions
- The response is the §9.1 envelope `{ data, meta }`. Unlike the payment and
  matching exports, this endpoint returns **JSON**, not a spreadsheet — it is
  a bounded bulk read, not a file download, and the caller formats it.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- The export is **capped at 10,000 rows** and says so: `truncated: true` means
  the filter matched more than the cap and the answer is the newest 10,000.
  Narrow the filter rather than paging — an export is not a paginated feed.
- Every filter `GET /api/v1/audit-logs` accepts is accepted here, minus the
  cursor pagination ones.
- Rows come back in the same shape and the same order as the browse endpoint:
  newest first, with their hash-chain fields intact.
- Secrets are redacted and identifiers masked in the export exactly as they
  are everywhere else. An export is not a way around the redaction rules.
- The route is under the global rate limit of 100 requests per minute per IP.

## The export is itself audited
Every successful export writes an `audit.log.exported` row into the very trail
it just copied, recording who exported, when, and which filters they used.
That row is then part of the hash chain like any other — so a bulk read of the
audit log is itself permanently visible in the audit log.

## x-export-controls
description: Why a read of the audit log is the most tightly gated endpoint in the API, from `src/modules/audit/audit.controller.ts` and the permissions guard.
controls:
  -
    permission: audit:export, held only by ADMIN in the seeded roles. ACCOUNTANT and VIEWER are explicitly withheld it.
  -
    reauth: audit:export is one of three isDangerous permissions. POST /api/v1/auth/reauth must have succeeded on this access token within the last five minutes, or the call is 403 AUTH_REAUTH_REQUIRED. ADMIN is not exempt — all three dangerous keys are ADMIN-only, so exempting it would make the rule unreachable.
  -
    reauth_scope: The stamp is bound to the access token, so refreshing the token or logging out ends the five-minute window. It is not shared between an operator's sessions.
  -
    row_cap: 10,000 rows. truncated: true means the newest 10,000 of a larger match. There is no cursor here on purpose — an export is a download, not a feed.
  -
    self_recording: A successful export writes an audit.log.exported row into the trail it just copied, recording the operator and the filters, and that row joins the hash chain like any other.
  -
    redaction_still_applies: Passwords appear as "[redacted]" and IBANs appear masked in the exported rows, exactly as in every other response.

## Operations

### GET /api/v1/audit-logs/export
operationId: adminExportAuditLogs
auth: bearer
summary: Export matching audit log entries.

Returns up to 10,000 matching rows as JSON, newest first, and records the
export in the trail.

**Notes:**
- Requires `audit:export`, a **dangerous** permission held only by `ADMIN`.
  A password re-entry through `POST /api/v1/auth/reauth` must have
  succeeded on this access token within the last five minutes, or the call
  is `403 AUTH_REAUTH_REQUIRED`. `ADMIN` is not exempt.
- Matched **before** `/{id}`, so `export` is a reserved segment in this
  path space.
- **Capped at 10,000 rows.** `truncated: true` means the filter matched
  more than that and `rows` is the newest 10,000 of them. Narrow the filter
  — there is deliberately no cursor here, because an export is a download,
  not a paginated feed.
- Returns **JSON in the standard envelope**, not a CSV or XLSX file. This
  differs from the payment and matching exports on purpose: those produce
  spreadsheets for humans, this produces records for a system.
- Every filter `GET /api/v1/audit-logs` accepts works here, with the same
  semantics: `action` is loose (an unrecognised value matches nothing);
  `category`, `severity` and `outcome` are strict enums; `q` is folded
  through the shared Persian character map before matching the rendered
  summaries.
- Sending no filters at all exports the newest 10,000 rows of the whole
  trail.
- Rows carry their `prevHash` and `hash`, so an exported set can be
  re-verified offline against the chain.
- Redaction still applies: passwords appear as `"[redacted]"` and
  identifiers such as IBANs appear masked, exactly as in every other
  response.
- Side effects: an `audit.log.exported` row is written into the trail —
  inside the chain — recording the operator and the filters used. A
  successful export is therefore permanently visible to the next person who
  reads the log.
responses:
  200: AuditExportEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### AuditCategory
type: string
enum:
  - AUTH
  - OPERATOR
  - RBAC
  - PARTY
  - BANK_ACCOUNT
  - PAYMENT
  - MATCHING
  - SETTINGS
  - SYNC
  - AUDIT
description: The area of the system an action belongs to. Fixed at ten values.

### AuditSeverity
type: string
enum:
  - INFO
  - NOTICE
  - WARNING
  - CRITICAL
description: How much attention the row deserves. `WARNING` covers refused permission checks; `CRITICAL` covers things like refresh-token reuse.

### AuditOutcome
type: string
enum:
  - SUCCESS
  - FAILURE
  - DENIED
description: `SUCCESS` — the action completed. `FAILURE` — it was attempted and failed. `DENIED` — a permission check refused it before it ran.

### AuditLog
type: object
description: One immutable row of the trail, in the same shape the browse endpoint returns — including the hash-chain fields, so an exported set can be re-verified offline.
properties:
  id:
    type: string
    format: uuid
    description: The row's id (UUID v7). Time-ordered, so it also gives insertion order.
  occurredAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the action happened.
  actorType:
    type: string
    description: `OPERATOR` for a human action; a system identifier for a scheduled job.
  actorId:
    type: string
    format: uuid
    nullable: True
    description: The acting operator's id, or `null` for a system action.
  actorUsername:
    type: string
    nullable: True
    description: The actor's username **snapshotted at the time of the action**. `null` for a system action.
  actorFullName:
    type: string
    nullable: True
    description: The actor's display name, likewise snapshotted.
  actorRoleKey:
    type: string
    nullable: True
    description: The role the actor held **at the time**, not the one they hold now.
  actorIp:
    type: string
    nullable: True
    description: Client IP recorded for the request; `null` when unavailable.
  userAgent:
    type: string
    nullable: True
    description: The request's `User-Agent` header; `null` when absent.
  requestId:
    type: string
    nullable: True
    description: ULID tying this row to the log lines from the same request. Several rows can share one.
  action:
    type: string
    description: The catalog action, `<domain>.<entity>.<verb_past_tense>`.
  category:
    $ref: #/components/schemas/AuditCategory
  outcome:
    $ref: #/components/schemas/AuditOutcome
  severity:
    $ref: #/components/schemas/AuditSeverity
  resourceType:
    type: string
    nullable: True
    description: What kind of record the action touched.
  resourceId:
    type: string
    nullable: True
    description: The record's id; `null` for an action that touched no single record.
  resourceLabel:
    type: string
    nullable: True
    description: A human-readable label for the record **at the time**.
  summaryFa:
    type: string
    description: Server-rendered Persian sentence describing what happened.
  summaryEn:
    type: string
    description: The same sentence in English.
  changes:
    type: array
    nullable: True
    description: The before/after diff of the fields the action moved; `null` when it changed none. Secrets appear as the literal `"[redacted]"` and identifiers appear masked — an export is not a way around redaction.
    items:
      type: object
      additionalProperties: True
  metadata:
    type: object
    nullable: True
    description: Free-form context whose keys vary by action.
    additionalProperties: True
  prevHash:
    type: string
    nullable: True
    description: The hash of the preceding row; `null` only for the first row in the chain.
  hash:
    type: string
    description: This row's own hash, computed over its content.
required:
  - id
  - occurredAt
  - actorType
  - actorId
  - actorUsername
  - actorFullName
  - actorRoleKey
  - actorIp
  - userAgent
  - requestId
  - action
  - category
  - outcome
  - severity
  - resourceType
  - resourceId
  - resourceLabel
  - summaryFa
  - summaryEn
  - prevHash
  - hash

### AuditExportEnvelope
type: object
description: Success envelope around an export. JSON, not a file — this is a bounded bulk read, and the caller formats it.
properties:
  data:
    type: object
    properties:
      rows:
        type: array
        description: The matching rows, newest first, capped at 10,000.
        items:
          $ref: #/components/schemas/AuditLog
      truncated:
        type: boolean
        description: `true` when the filter matched more than 10,000 rows and only the newest 10,000 are here. Narrow the filter; there is no cursor.
    required:
      - rows
      - truncated
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta


========================================================================
# Authentication API (app: auth, version 1.0.0)
========================================================================

Servers: http://localhost:3000

Operator sign-in, refresh-token rotation and session management for the Ghaem
Gold panel. §4.1 — the only population that signs in is the **operator**
(panel staff); a *party* is an external counterparty that has no username, no
password and no session, and there is nothing here they could use.

## Flow
1. Operator posts credentials to `POST /api/v1/auth/login` and receives a
   15-minute JWT access token plus an opaque 7-day refresh token.
2. Client sends `Authorization: Bearer <accessToken>` on every subsequent
   request in every module of this API.
3. When the access token expires, the client spends the refresh token at
   `POST /api/v1/auth/refresh`; the response carries a *new* pair and the
   presented refresh token is dead from that moment.
4. If `operator.mustChangePassword` is `true`, the panel routes the operator
   to `POST /api/v1/auth/change-password` before letting them work.
5. Before a dangerous action (`operators:delete`, `roles:manage-permissions`,
   `audit:export`) the panel calls `POST /api/v1/auth/reauth` with the
   password, which unlocks those routes for five minutes.
6. `POST /api/v1/auth/logout` ends the current session, or every session the
   operator holds when no `refreshToken` is sent.

## Security Notes
- `login` and `refresh` are the only unauthenticated routes here. Everything
  else requires a valid Bearer access token; none of them requires a
  permission, because an operator may always end their own session and change
  their own password.
- **Every** sign-in failure returns the same `401 AUTH_INVALID_CREDENTIALS`:
  unknown username, wrong password, suspended account, soft-deleted account,
  and a *correct* password presented during a lockout are indistinguishable.
  A distinguishable answer during a lockout would confirm both the username
  and the password.
- Unknown usernames still spend Argon2 time against a decoy hash, so response
  timing does not reveal whether an account exists.
- Lockout: 5 consecutive failures lock the account for 15 minutes, and each
  further block of 5 doubles the lock (15 → 30 → 60 → 120 → 240 → 480
  minutes, then flat). Attempts made *while* the lock is in force are refused
  before the password is compared and do not extend it. The counter is
  cleared only by a successful sign-in or an administrative password reset.
- Refresh tokens are **single-use and rotating**. Presenting an
  already-rotated token revokes every session in its family and returns
  `401 AUTH_TOKEN_REUSED` with a `CRITICAL` audit row — that pattern is the
  signature of a stolen refresh token.
- The access token is stateless, but `JwtAuthGuard` re-reads the operator's
  live status and role on every request. Suspending or demoting an operator
  bites on their next request, not on their next sign-in.
- Refusals never leak internals: the plaintext password appears in no log
  line, audit row, or error message.
- `expiresAt`, `refreshTokenExpiresAt` and `reauthenticatedUntil` are
  ISO-8601 UTC strings with milliseconds, e.g. `"2026-08-21T09:27:33.104Z"`.
- Every success is wrapped in the §9.1 envelope `{ data, meta }`; every
  failure is `{ error: { code, message, messageFa, status, details, requestId } }`
  with both an English and a Persian sentence.

## Rate limiting
- `POST /api/v1/auth/login` is limited to **5 requests per minute per IP**;
  it is the only route in the API with a policy tighter than the global one.
- Every other route in this file falls under the global limit of 100 requests
  per minute per IP.

## x-token-specifications
description: Token lifetimes and rotation policy, from `JWT_ACCESS_TTL` / `JWT_REFRESH_TTL` (.env) and `src/modules/auth/refresh-token.service.ts`.
specs:
  -
    access_token: JWT, HS256, 15 minutes (JWT_ACCESS_TTL=15m), stateless
  -
    access_token_claims: sub (operator id), jti, sid (rotation family id), roleKey, permissions[], iat, exp
  -
    refresh_token: opaque random string, 7 days (JWT_REFRESH_TTL=7d), stored as a hash only
  -
    refresh_rotation: single-use; the presented token is revoked and linked to its successor
  -
    reuse_detection: replaying a spent token revokes the entire rotation family and audits CRITICAL
  -
    live_revalidation: every request re-reads the operator's status and role; a permission change forces a database re-read when the token predates the role's updatedAt

## x-lockout-specifications
description: Sign-in lockout schedule, from `src/modules/auth/lockout.ts` and `LOGIN_MAX_ATTEMPTS` / `LOGIN_LOCKOUT_MINUTES` (.env).
specs:
  -
    threshold: 5 consecutive failures (LOGIN_MAX_ATTEMPTS)
  -
    first_lock: 15 minutes (LOGIN_LOCKOUT_MINUTES)
  -
    escalation: each further block of 5 failures doubles the lock: 15 → 30 → 60 → 120 → 240 → 480 minutes
  -
    cap: 5 doublings, then flat, so an account always becomes recoverable by waiting
  -
    attempts_during_lock: refused before the password is compared; they do not count and do not extend the lock
  -
    counter_reset: a successful sign-in, or POST /api/v1/operators/{id}/reset-password
  -
    observable_status: the lockout is never visible at sign-in; it returns 401 AUTH_INVALID_CREDENTIALS like any other failure

## x-rate-limits
description: Per-IP throttling, from `src/common/http/throttle-policies.ts`. The global policy is registered at the composition root; the tighter ones are attached to their own routes.
policies:
  -
    global: 100 requests / 60 s / IP — every route in this API
  -
    login: 5 requests / 60 s / IP — POST /api/v1/auth/login only

## Operations

### POST /api/v1/auth/login
operationId: login
auth: public
summary: Sign in and obtain an access/refresh token pair.

Verifies an operator's username and password and issues a 15-minute
access token together with a 7-day rotating refresh token. The response
also carries the operator's identity and their effective permission
keys, so the panel can render its navigation without a second call.

**Notes:**
- Public endpoint; no token required.
- Returns `200`, not `201` — a session is not a created resource here.
- Every failure is the same `401 AUTH_INVALID_CREDENTIALS`, whatever went
  wrong. See `## Security Notes`.
- Rate limited to 5 requests per minute per IP. Exceeding it returns
  `429 RATE_LIMITED` before the password is checked.
- The password field is validated only as "1–512 characters". The
  configured minimum length is enforced where a password is *set*, never
  on the sign-in form: rejecting a short guess here would tell an
  attacker not to bother making it.
- `permissions` is read live from the role, not from any cached token.
- `operator.mustChangePassword` is `true` for an operator created or
  password-reset by an administrator; the panel must send them to
  `POST /api/v1/auth/change-password` before anything else.
- Side effects on success: `lastLoginAt`/`lastLoginIp` recorded, the
  lockout counter cleared, a new refresh-token family opened, and an
  `auth.logged_in` audit row written. Every failure writes an
  `auth.login_failed` audit row naming the username that was tried.
request body: LoginRequest
responses:
  200: SessionEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/auth/refresh
operationId: refreshSession
auth: public
summary: Rotate a refresh token for a new token pair.

Spends the presented refresh token and returns a new access/refresh pair
for the same rotation family. The presented token is revoked in the same
transaction that issues its successor.

**Notes:**
- Public endpoint: it authenticates with the refresh token in the body,
  not with the access token. Any `Authorization` header is ignored.
- Presenting a token that has **already been rotated** revokes every live
  token in its family and returns `401 AUTH_TOKEN_REUSED`. The client is
  then fully signed out and must call `POST /api/v1/auth/login` again.
- An unknown, expired or administratively revoked token returns a
  deliberately generic `401 UNAUTHENTICATED` — distinguishing them would
  tell the holder of a stolen token which kind they hold.
- A token belonging to a suspended, disabled or soft-deleted operator
  returns `423 AUTH_ACCOUNT_LOCKED` and revokes the whole family; a token
  belonging to an operator inside a login lockout returns `423` without
  revoking anything.
- `permissions` in the response is re-read from the role, so a grant
  revoked since the last rotation is already absent.
- Side effects on success: the presented token is revoked and linked to
  its successor, a new refresh token is issued into the same family, and
  an `auth.refreshed` audit row is written. On reuse, a `CRITICAL`
  `auth.token_reused` row is written and the family is revoked.
request body: RefreshRequest
responses:
  200: SessionEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  423: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/auth/logout
operationId: logout
auth: bearer
summary: End the current session, or every session this operator holds.

Revokes refresh tokens and clears the session's dangerous-action marker.
The scope depends on whether the client can name the session it means.

**Notes:**
- Requires a valid Bearer access token; no permission needed.
- With `refreshToken` in the body, only the rotation family the request
  arrived on is revoked and `scope` is `"SESSION"`.
- Without it, **every** live refresh token the operator holds is revoked
  and `scope` is `"ALL"` — the safe reading of "log me out" when the
  client cannot say which session it means.
- The body's `refreshToken` value is not verified against the session; its
  mere presence selects the narrower scope, and the family revoked is the
  one carried in the access token (`sid`).
- The access token itself is stateless and stays technically valid until
  its `exp`, at most 15 more minutes. The panel must discard it.
- `revokedSessions` counts the refresh tokens that were still live; a
  double logout returns `0` rather than failing.
- Side effects: refresh tokens revoked, the five-minute re-auth marker for
  this token cleared, and an `auth.logged_out` audit row written.
request body: LogoutRequest
responses:
  200: LogoutEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/auth/me
operationId: getCurrentOperator
auth: bearer
summary: Read the signed-in operator and their effective permissions.

Returns the operator behind the presented access token, together with the
permission keys their role currently grants.

**Notes:**
- Requires a valid Bearer access token; no permission needed.
- `permissions` is read **live** from the role rather than echoed from the
  token, so a grant revoked a moment ago is already absent here. A token
  issued before the role last changed will therefore list fewer (or more)
  keys than it was signed with.
- `roleKey` is likewise the operator's current role, not the one recorded
  in the token at sign-in.
- An `ADMIN` operator's `permissions` array lists all 47 keys explicitly;
  the authorization guard also short-circuits `ADMIN` to allow-all, so the
  array and the effective access agree.
- Side effects: none. This endpoint writes no audit row.
responses:
  200: OperatorIdentityEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/auth/change-password
operationId: changePassword
auth: bearer
summary: Change your own password.

Verifies the current password, stores the new one, and signs the operator
out everywhere except the session this request arrived on.

**Notes:**
- Requires a valid Bearer access token; no permission needed. An operator
  may only change their **own** password here — resetting somebody else's
  is `POST /api/v1/operators/{id}/reset-password` in the Admin Operators
  API.
- The new password must differ from the current one and must be at least
  `PASSWORD_MIN_LENGTH` characters (10 by default). The length rule is
  enforced here, never on the sign-in form.
- A wrong `currentPassword` returns `401 AUTH_INVALID_CREDENTIALS` with
  the specific sentence "The current password is not correct." — unlike
  sign-in, this caller has already proved who they are, so a precise
  message leaks nothing.
- Side effects on success: password re-hashed with Argon2id, the
  `mustChangePassword` flag cleared, the lockout counter and any active
  lock cleared, the row's `version` incremented, **all other** refresh
  token families revoked, and an `auth.password_changed` audit row written
  that records the field name only — neither password reaches the row.
- The current session survives on purpose: the operator has just proved
  from it that they hold the new password.
request body: ChangePasswordRequest
responses:
  200: ChangePasswordEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/auth/reauth
operationId: reauthenticate
auth: bearer
summary: Re-enter your password to unlock dangerous actions for five minutes.

Stamps the current access token as freshly password-verified. Routes
guarded by a permission marked `isDangerous` refuse to run without such a
stamp.

**Notes:**
- Requires a valid Bearer access token; no permission needed.
- The three dangerous permissions are `operators:delete`,
  `roles:manage-permissions` and `audit:export`. Without a stamp they
  return `403 AUTH_REAUTH_REQUIRED`, which the panel should turn into a
  password prompt rather than an error toast.
- `ADMIN` does **not** skip this requirement, even though it
  short-circuits every other permission check — all three dangerous keys
  are held only by `ADMIN`, so exempting it would make the rule
  unreachable.
- The stamp is bound to the access token's `jti`, so it is lost when the
  token is refreshed or the operator logs out; it is not shared between
  the operator's sessions.
- A wrong password returns `401 AUTH_INVALID_CREDENTIALS` and writes an
  `auth.login_failed` audit row with reason `reauth_password_mismatch`.
- Side effects on success: the five-minute marker is stored and an
  `auth.reauthenticated` audit row is written recording `validUntil`.
request body: ReauthRequest
responses:
  200: ReauthEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload when the reason is attributable to one; additional keys vary by `issue` and are documented on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `invalid_credentials`, `version_mismatch`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `AUTH_INVALID_CREDENTIALS`, `VALIDATION_FAILED`, `RATE_LIMITED`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta`. Non-paginated responses carry exactly these two keys.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### OperatorIdentity
type: object
description: The operator behind the current session, with their live permission set.
properties:
  id:
    type: string
    format: uuid
    description: The operator's internal id (UUID v7).
  username:
    type: string
    description: Sign-in name. Unique across live operators; a soft-deleted operator keeps theirs.
  fullName:
    type: string
    description: Display name, usually Persian.
  roleKey:
    type: string
    description: The operator's **current** panel-role key (`ADMIN`, `ACCOUNTANT`, `VIEWER`, or a custom role's key), re-read live rather than taken from the token.
  mustChangePassword:
    type: boolean
    description: True for an operator created or password-reset by an administrator. The panel must route them to `POST /api/v1/auth/change-password` before allowing any other work.
  permissions:
    type: array
    description: Effective permission keys granted by the role right now. An `ADMIN` operator lists all 47 keys.
    items:
      type: string
required:
  - id
  - username
  - fullName
  - roleKey
  - mustChangePassword
  - permissions

### Session
description: A freshly issued access/refresh token pair plus the operator it belongs to.
allOf:
  -
    type: object
    properties:
      accessToken:
        type: string
        description: Signed JWT (HS256). Send it as the `Authorization: Bearer <accessToken>` header.
      tokenType:
        type: string
        enum:
          - Bearer
        description: Always `Bearer`.
      expiresInSeconds:
        type: integer
        description: Access-token lifetime in seconds; 900 with the default `JWT_ACCESS_TTL=15m`.
      expiresAt:
        type: string
        format: date-time
        description: ISO-8601 UTC instant the access token stops being accepted.
      refreshToken:
        type: string
        description: Opaque random string, stored server-side only as a hash. Single-use: spending it at `POST /api/v1/auth/refresh` revokes it.
      refreshTokenExpiresAt:
        type: string
        format: date-time
        description: ISO-8601 UTC expiry; 7 days out with the default `JWT_REFRESH_TTL=7d`.
      operator:
        $ref: #/components/schemas/OperatorIdentity
    required:
      - accessToken
      - tokenType
      - expiresInSeconds
      - expiresAt
      - refreshToken
      - refreshTokenExpiresAt
      - operator

### SessionEnvelope
type: object
description: Success envelope around a newly issued session.
properties:
  data:
    $ref: #/components/schemas/Session
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### OperatorIdentityEnvelope
type: object
description: Success envelope around the current operator. `data` adds `roleId` to the identity carried inside a session response.
properties:
  data:
    allOf:
      -
        $ref: #/components/schemas/OperatorIdentity
      -
        type: object
        properties:
          roleId:
            type: string
            format: uuid
            description: Internal id of the operator's panel role. Useful for reading the role in the Admin RBAC API; the human-readable key is `roleKey`.
        required:
          - roleId
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### LogoutEnvelope
type: object
description: Success envelope around the result of ending sessions.
properties:
  data:
    type: object
    properties:
      revokedSessions:
        type: integer
        description: How many refresh tokens were still live and have now been revoked. `0` when the sessions were already ended.
      scope:
        type: string
        enum:
          - SESSION
          - ALL
        description: `SESSION` when a `refreshToken` was supplied and only that rotation family was revoked; `ALL` when the body was empty and every session was revoked.
    required:
      - revokedSessions
      - scope
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### ChangePasswordEnvelope
type: object
description: Success envelope around a password change.
properties:
  data:
    type: object
    properties:
      revokedSessions:
        type: integer
        description: How many *other* sessions were signed out. The session this request arrived on is deliberately not counted and stays live.
    required:
      - revokedSessions
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### ReauthEnvelope
type: object
description: Success envelope around a password re-verification.
properties:
  data:
    type: object
    properties:
      reauthenticatedUntil:
        type: string
        format: date-time
        description: ISO-8601 UTC instant the five-minute dangerous-action window closes. Bound to this access token's `jti`.
    required:
      - reauthenticatedUntil
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### LoginRequest
type: object
description: Operator credentials. §4.1 — a party has no username and cannot sign in.
properties:
  username:
    type: string
    minLength: 1
    maxLength: 64
    description: Trimmed before lookup. Matched exactly; not case-folded.
  password:
    type: string
    minLength: 1
    maxLength: 512
    description: Validated here only as 1–512 characters. `PASSWORD_MIN_LENGTH` is enforced where a password is set, never on this form.
required:
  - username
  - password

### RefreshRequest
type: object
description: The refresh token to spend. No `Authorization` header is read by this route.
properties:
  refreshToken:
    type: string
    minLength: 1
    maxLength: 512
    description: The opaque refresh token most recently issued to this session.
required:
  - refreshToken

### LogoutRequest
type: object
description: Optional body. Supplying `refreshToken` narrows the logout to the current session; omitting it (or sending `{}`) revokes every session.
properties:
  refreshToken:
    type: string
    minLength: 1
    maxLength: 512
    description: Present ⇒ only this session's rotation family is revoked. The value itself is not verified; the family revoked is the one named by the access token.

### ChangePasswordRequest
type: object
description: The operator's current password and the one to replace it with.
properties:
  currentPassword:
    type: string
    minLength: 1
    maxLength: 512
    description: Verified against the stored Argon2id hash before anything is written.
  newPassword:
    type: string
    minLength: 1
    maxLength: 512
    description: Must differ from `currentPassword` and be at least `PASSWORD_MIN_LENGTH` characters (10 by default).
required:
  - currentPassword
  - newPassword

### ReauthRequest
type: object
description: The current operator's password, re-entered to unlock dangerous actions.
properties:
  password:
    type: string
    minLength: 1
    maxLength: 512
    description: The operator's own password. Verified against the stored Argon2id hash.
required:
  - password


========================================================================
# Bank Accounts API (app: bank-accounts, version 1.0.0)
========================================================================

Servers: http://localhost:3000

Counterparty bank accounts — the destinations settlement money is actually
sent to — plus the read-only bank catalog they are classified against.

An account is identified by any combination of an account number, an IBAN
(شبا) and a card number; at least one must be present. The **bank is never
chosen by the client**: it is derived from the IBAN's issuer code or the
card's BIN against a seeded registry, and an unrecognised code resolves to a
dedicated `UNKNOWN` row rather than falling through to a numerically adjacent
bank.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. Client loads the bank catalog once from `GET /api/v1/banks`, so it can show
   the detected issuer while an operator types an IBAN.
3. Operator lists a party's accounts with
   `GET /api/v1/parties/{partyId}/bank-accounts`; the default account sorts
   first and identifiers come back masked.
4. Operator adds one with `POST /api/v1/parties/{partyId}/bank-accounts`,
   supplying at least one identifier and the account holder's name.
5. Operator reads or edits a single account through
   `GET`/`PATCH /api/v1/bank-accounts/{id}`, echoing the `ETag` back as
   `If-Match` on the edit.
6. Operator nominates the party's default destination with
   `POST /api/v1/bank-accounts/{id}/set-default`.
7. When a full IBAN is genuinely needed, the operator reveals it through
   `GET /api/v1/bank-accounts/{id}/reveal-iban` — a separately permissioned,
   audited act.
8. An account that is no longer used is soft-deleted with
   `DELETE /api/v1/bank-accounts/{id}` and can be brought back with
   `POST /api/v1/bank-accounts/{id}/restore`.

## Security Notes
- Every endpoint requires a valid Bearer access token. Reading needs
  `bank-accounts:read`, creating `bank-accounts:create`, editing and the
  `/restore` and `/set-default` actions `bank-accounts:update`, deleting
  `bank-accounts:delete`, and revealing a full IBAN the separate
  `bank-accounts:read-sensitive`. `GET /api/v1/banks` requires **no**
  permission — any authenticated operator may read the catalog.
- The seeded `VIEWER` role holds `bank-accounts:read` but deliberately **not**
  `bank-accounts:read-sensitive`: revealing a full IBAN is an audited act, not
  a read.
- **IBAN and card number are masked in every response.** `ibanMasked` looks
  like `IR** **** **** **** **** **34`; `cardNumberMasked` like
  `603799••••••6789`. Neither can be reconstructed. The full IBAN exists at
  exactly one endpoint, and requesting it writes an audit row — the value
  itself is never duplicated into the log.
- `designatedAmountMinor` («مبلغ حساب») is the amount the party wants
  deposited into this account: a **fill target the matching engine reads**,
  not a bank balance and not the accounting balance. It is a non-negative
  decimal **string** in Rial minor units and defaults to `"0"`.
- `isCompanyAccount`, `isExcludedFromMatching` and `matchExclusionReason` are
  **not** editable through `PATCH`. They belong to dedicated routes gated by
  `matching:settings`, a different permission — see the Admin Bank Accounts
  API.
- `bankId` is likewise not accepted on any request. The bank is derived, never
  supplied.
- `accountHolderName` may legitimately differ from the party's own name — a
  spouse's or a company's account — and is never validated against it.
- Deletion is **soft**: the row survives with `deletedAt` set and is hidden
  unless `includeDeleted=true`. It is refused while a non-terminal payment
  still points at the account.
- A payment that has reached `SETTLED`, `REJECTED`, `CANCELLED` or `FAILED`
  is unaffected by any change here: it renders from its own frozen account
  snapshot taken at issue time, never from this row.
- `PATCH` requires `If-Match` carrying the row's `version`, echoed as the
  `ETag` response header by every single-account read and write. A stale value
  is `409 CONCURRENT_MODIFICATION`.
- Every success is the §9.1 envelope `{ data, meta }`; every failure is
  `{ error: { code, message, messageFa, status, details, requestId } }` with
  both an English and a Persian sentence.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## Validation
- An IBAN is checked for structure **and** the ISO 7064 mod-97 checksum.
  Either failure is `400 BANK_ACCOUNT_INVALID_IBAN`, with the specific issue
  in `details[]`.
- IBANs are stored as the 24-digit body without the `IR` prefix; the prefix is
  re-attached for display and for the revealed value.
- A card number is checked with the Luhn algorithm; a failure is
  `400 VALIDATION_FAILED` on the `cardNumber` field.
- A live account may not share an IBAN with another live account
  (`409 CONFLICT`). A **soft-deleted** account does not hold the IBAN, which
  is why a restore can fail if the IBAN was claimed in the meantime.
- Both request schemas are **strict**: an unrecognised key is `400`, not a
  silent drop.

## x-identifier-handling
description: How the three identifiers are validated, stored, masked and used to derive a bank, from `src/common/validation/iban.validator.ts`, `src/common/validation/card.validator.ts` and `src/modules/bank-accounts/bank-account.view.ts`.
rules:
  -
    at_least_one: An account must always carry at least one of accountNumber, iban and cardNumber. Enforced by the request schema, by the update service when an edit would clear the last one, and by a database CHECK constraint.
  -
    iban_validation: Structure plus the ISO 7064 mod-97 checksum. Either failure is 400 BANK_ACCOUNT_INVALID_IBAN, with the specific issue in details[].
  -
    iban_storage: Stored as the 24-digit body without the IR prefix; the prefix is re-attached for the masked display and for the revealed value.
  -
    iban_uniqueness: Unique across live accounts only. A soft-deleted account releases its IBAN, which is why a later restore can fail with 409 CONFLICT.
  -
    card_validation: Luhn. A failure is 400 VALIDATION_FAILED on the cardNumber field.
  -
    bank_derivation: The IBAN issuer code and the card BIN each resolve independently against the seeded registry. An unrecognised code resolves to the UNKNOWN bank rather than falling through to a numerically adjacent one. bankId is never accepted from a client.
  -
    masking: IBAN and card number are masked in every response and in every audit row. accountNumber is not masked. Only the IBAN has a reveal endpoint, and using it is itself audited.

## x-payment-snapshot-independence
description: Why editing or deleting an account does not disturb existing payments, from the payment order's frozen payee-account snapshot.
rule: A payment order freezes a snapshot of its payee account at creation time and renders from that snapshot, never from the live bank_accounts row.
consequence_for_edits: Correcting an IBAN does not retroactively change what an already-issued payment says it was sent to.
consequence_for_deletes: Payments in SETTLED, REJECTED, CANCELLED or FAILED do not block a delete and are unaffected by it. Only non-terminal payments block it, with 409 BANK_ACCOUNT_IN_USE listing up to 20 of them.

## Operations

### GET /api/v1/banks
operationId: listBanks
auth: bearer
summary: List the bank catalog.

Returns every bank in the seeded registry, ordered by `sortOrder`.

**Notes:**
- Requires authentication but **no permission** — any authenticated
  operator may read the catalog, because a client needs it to render an
  IBAN field at all.
- **Not paginated** — `data` is a plain array of 18 rows.
- The list includes the `UNKNOWN` fallback bank, whose `ibanCode` is
  `null` so that it can never be *matched* by an issuer code; it is only
  ever assigned as the resolution of an unrecognised one.
- `ibanCode` is the three-digit issuer segment of an Iranian IBAN;
  `cardBin` is the card prefix, and is `null` for banks whose BIN is not
  recorded.
- Side effects: none.
responses:
  200: BankListEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/parties/{partyId}/bank-accounts
operationId: listPartyBankAccounts
auth: bearer
summary: List a party's bank accounts.

Returns every account belonging to one party, default first, with
identifiers masked.

**Notes:**
- Requires `bank-accounts:read`.
- **Not paginated** — `data` is a plain array. A party has a handful of
  accounts.
- Ordering is `isDefault` descending, then `createdAt` ascending, so the
  default destination is always first.
- Soft-deleted accounts are hidden unless `includeDeleted=true`, which is
  how a candidate for `/restore` is found.
- `ibanMasked` and `cardNumberMasked` are display-only. The full IBAN is
  available from `GET /api/v1/bank-accounts/{id}/reveal-iban` under a
  different permission; there is no endpoint that reveals a card number.
- An unknown `partyId` is `404 RESOURCE_NOT_FOUND` naming `Party` — the
  party is verified before the accounts are read, so an empty array always
  means "this party has none", never "this party does not exist".
- Side effects: none.
responses:
  200: BankAccountListEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/parties/{partyId}/bank-accounts
operationId: createPartyBankAccount
auth: bearer
summary: Add a bank account to a party.

Validates the supplied identifiers, resolves the bank from them, and
attaches the account to the party.

**Notes:**
- Requires `bank-accounts:create`.
- **At least one** of `accountNumber`, `iban` and `cardNumber` is
  required; sending none is `400 VALIDATION_FAILED`. The database enforces
  the same rule, so this check exists to give a legible error rather than
  a raw constraint violation.
- An `iban` is validated by structure **and** ISO 7064 mod-97 checksum;
  either failure is `400 BANK_ACCOUNT_INVALID_IBAN`. It is stored as the
  24-digit body without the `IR` prefix.
- A `cardNumber` is validated with the Luhn algorithm; a failure is
  `400 VALIDATION_FAILED` on that field.
- The **bank is derived**, never supplied: the IBAN's issuer code and the
  card's BIN each resolve independently against the seeded registry, and
  an unrecognised code resolves to the `UNKNOWN` bank rather than to a
  numerically adjacent one. `bankId` in the body is rejected — the schema
  is strict.
- An IBAN already held by another **live** account is `409 CONFLICT`. A
  soft-deleted account does not hold its IBAN.
- `accountHolderName` is required and is **never** checked against the
  party's own name — a spouse's or a company's account is normal.
- `designatedAmountMinor` is a non-negative integer **string** in Rial
  minor units and defaults to `"0"`. A minus sign is rejected by the
  schema.
- `currency` defaults to `"IRR"` and must be exactly three characters when
  supplied.
- `isDefault: true` clears the party's previous default in the same
  transaction.
- `isCompanyAccount` and the matching-exclusion fields are not accepted
  here; they belong to the `matching:settings`-gated routes.
- Sets the `ETag` response header to the new row's `version` (`"1"`).
- Side effects: the account row is created, any previous default is
  cleared when `isDefault` is `true`, and a `bank_account.created` audit
  row is written recording the **masked** identifiers and the resolved
  bank key — never the full values.
request body: CreateBankAccountRequest
responses:
  201: BankAccountEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/bank-accounts/{id}
operationId: getBankAccount
auth: bearer
summary: Retrieve one bank account.

Returns a single account with its identifiers masked, including
soft-deleted ones.

**Notes:**
- Requires `bank-accounts:read`.
- Sets the `ETag` response header to the row's `version`, quoted — e.g.
  `ETag: "2"`. Send that value back as `If-Match` on the subsequent
  `PATCH`.
- Unlike the party listing, this endpoint does **not** hide soft-deleted
  accounts: a deleted row has to remain readable so it can be restored and
  so an audit trail naming it stays followable.
- `bank` is `null` only when the account carries neither an IBAN nor a
  card number for a bank to be derived from.
- Side effects: none.
responses:
  200: BankAccountEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/bank-accounts/{id}
operationId: deleteBankAccount
auth: bearer
summary: Soft-delete a bank account.

Marks the account deleted, provided no live payment still points at it.

**Notes:**
- Requires `bank-accounts:delete`.
- **Soft delete**: the row survives with `deletedAt` set and disappears
  from the party listing unless `includeDeleted=true`. It stays readable
  by id and can be brought back with `/restore`.
- Refused with `409 BANK_ACCOUNT_IN_USE` while **any** non-terminal
  payment order still names this account as its payee account. The
  response lists the referencing payments — up to 20 of them — with their
  id, reference and status in `details[]`, so the operator knows exactly
  what to resolve first.
- Payments that have reached `SETTLED`, `REJECTED`, `CANCELLED` or
  `FAILED` do **not** block the delete and are unaffected by it: each
  renders from its own frozen account snapshot, never from this row.
- Returns `200` with `{ "id": … }`, not `204`.
- Does not require `If-Match`.
- A soft-deleted account releases its IBAN, so the same IBAN can
  immediately be used by a new account — which is why a later `/restore`
  can fail.
- Side effects: `deletedAt` set and a `bank_account.deleted` audit row
  written.
responses:
  200: BankAccountDeletedEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/bank-accounts/{id}
operationId: updateBankAccount
auth: bearer
summary: Edit a bank account's identifiers or details.

Applies a partial update under an optimistic-concurrency precondition,
re-validating and re-deriving the bank whenever an identifier changes.

**Notes:**
- Requires `bank-accounts:update`.
- **`If-Match` is required**. Omitting it is `400 VALIDATION_FAILED` on
  the `If-Match` field; a stale value is `409 CONCURRENT_MODIFICATION`
  carrying both the expected and the actual version. `W/"2"`, `"2"`, `2`
  and `*` are all accepted spellings.
- At least one field must be supplied, and the schema is **strict**: an
  unrecognised key is `400`, not a silent drop.
- The bank is **re-detected** whenever `iban` or `cardNumber` changes, and
  is never taken from the request.
- `accountNumber`, `iban` and `cardNumber` accept `null` to clear them —
  but an edit that would leave the account with **no identifier at all**
  is `400 VALIDATION_FAILED` with issue `no_identifier_remaining`.
- A new IBAN already held by another live account is `409 CONFLICT`.
- `isCompanyAccount`, `isExcludedFromMatching`, `matchExclusionReason`,
  `bankId` and `isDefault` are **not** accepted here. The matching flags
  have their own `matching:settings`-gated routes; the default is moved
  with `POST /api/v1/bank-accounts/{id}/set-default`.
- Editing an account does **not** change how any already-issued payment
  renders: a payment carries a frozen snapshot of the account taken when
  it was created.
- Side effects: the row is updated, `version` incremented,
  `updatedByOperatorId` recorded, and a `bank_account.updated` audit row
  written with a before/after diff in which identifiers appear **masked**.
request body: UpdateBankAccountRequest
responses:
  200: BankAccountEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/bank-accounts/{id}/restore
operationId: restoreBankAccount
auth: bearer
summary: Restore a soft-deleted bank account.

Clears `deletedAt`, provided the account's IBAN has not been claimed in the
meantime.

**Notes:**
- Requires `bank-accounts:update`. Takes no request body.
- Returns `200`, not `201` — nothing is created.
- Restoring an account that is **not** deleted is
  `400 VALIDATION_FAILED` with issue `not_deleted`, rather than a silent
  no-op.
- If another live account has claimed this account's IBAN since the
  delete, the restore is `409 CONFLICT` and nothing changes. Free the IBAN
  on the other account first, or clear it on this one before restoring.
- The account does not regain `isDefault`; nominate it again with
  `/set-default` if that is wanted.
- Sets the `ETag` response header to the row's new `version`.
- Side effects: `deletedAt` cleared, `version` incremented, and a
  `bank_account.restored` audit row written.
responses:
  200: BankAccountEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/bank-accounts/{id}/set-default
operationId: setDefaultBankAccount
auth: bearer
summary: Nominate an account as the party's default destination.

Clears whichever of the party's accounts was default and marks this one
instead, in one transaction.

**Notes:**
- Requires `bank-accounts:update`. Takes no request body.
- Returns `200`, not `201` — nothing is created.
- A **soft-deleted** account cannot be made the default:
  `400 VALIDATION_FAILED` with issue `deleted`. Restore it first.
- Both sides of the swap happen in the same transaction, so the party
  never momentarily has two defaults or none.
- Setting the account that is already default succeeds and is effectively
  a no-op, though it still increments `version`.
- Sets the `ETag` response header to the row's new `version`.
- Side effects: the party's previous default is cleared, this row is
  marked default, `version` incremented on both, and a
  `bank_account.default_set` audit row written.
responses:
  200: BankAccountEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/bank-accounts/{id}/reveal-iban
operationId: revealBankAccountIban
auth: bearer
summary: Reveal an account's full IBAN.

Returns the unmasked IBAN, in both its stored form and a human-readable
grouping, and records that the reveal happened.

**Notes:**
- Requires **`bank-accounts:read-sensitive`**, a permission distinct from
  `bank-accounts:read`. The seeded `VIEWER` role holds the latter but not
  this one: revealing a full IBAN is an audited act, not a read.
- This is the **only** endpoint in the API that returns an unmasked IBAN,
  and there is no equivalent for a card number.
- `iban` is the 24-digit stored body **without** the `IR` prefix;
  `ibanFormatted` re-attaches the prefix and groups the digits in fours
  for a human to read back over the phone.
- Both fields are `null` when the account carries no IBAN at all — the
  request still succeeds with `200`.
- Side effects: a `bank_account.iban_revealed` audit row is written naming
  the operator and the account. The IBAN itself is **not** duplicated into
  the audit row or any log line; it appears masked there, as everywhere
  else.
responses:
  200: IbanRevealEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### BankSummary
type: object
description: The bank an account resolved to. Derived from the IBAN issuer code or the card BIN — never chosen by the client.
properties:
  id:
    type: string
    format: uuid
    nullable: True
    description: Internal bank id. `null` only in the degenerate case of an unresolvable snapshot.
  key:
    type: string
    description: Stable bank key, e.g. `MELLI`, `MELLAT`, or `UNKNOWN` for an unrecognised issuer.
  nameFa:
    type: string
    description: Persian bank name, for display.
  ibanCode:
    type: string
    nullable: True
    description: The three-digit IBAN issuer segment. `null` for the `UNKNOWN` fallback, which therefore can never be *matched* by an issuer code — only assigned as the resolution of an unrecognised one.
required:
  - id
  - key
  - nameFa
  - ibanCode

### Bank
type: object
description: One row of the seeded bank registry. Read-only catalog data.
properties:
  id:
    type: string
    format: uuid
    description: Internal bank id.
  key:
    type: string
    description: Stable bank key, e.g. `MELLI`, `MELLAT`, `UNKNOWN`.
  nameFa:
    type: string
    description: Persian bank name.
  nameEn:
    type: string
    description: English bank name.
  ibanCode:
    type: string
    nullable: True
    description: Three-digit IBAN issuer segment used to derive the bank from an IBAN. `null` for the `UNKNOWN` fallback.
  cardBin:
    type: string
    nullable: True
    description: Card BIN prefix used to derive the bank from a card number. `null` when the BIN is not recorded for this bank.
  isActive:
    type: boolean
    description: Whether the bank is offered in the panel.
  sortOrder:
    type: integer
    description: Display ordering, ascending. The `UNKNOWN` fallback sorts last.
required:
  - id
  - key
  - nameFa
  - nameEn
  - ibanCode
  - cardBin
  - isActive
  - sortOrder

### BankAccount
type: object
description: A counterparty bank account. Identifiers are always masked here; the full IBAN has its own permissioned, audited endpoint.
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7). Stable across a soft delete and restore.
  partyId:
    type: string
    format: uuid
    description: The party that owns this account.
  bank:
    allOf:
      -
        $ref: #/components/schemas/BankSummary
    nullable: True
    description: The derived bank. `null` when the account carries neither an IBAN nor a card number for one to be derived from.
  accountNumber:
    type: string
    nullable: True
    description: The bank's own account number, stored and returned in full — unlike the IBAN and card number, it is not masked. `null` when not recorded.
  ibanMasked:
    type: string
    nullable: True
    description: Display-only mask, e.g. `IR** **** **** **** **** **12`. `null` only when the account carries no IBAN at all — never an empty string. The full value is at `GET /api/v1/bank-accounts/{id}/reveal-iban`.
  cardNumberMasked:
    type: string
    nullable: True
    description: Display-only mask, e.g. `603799••••••6789`. `null` when the account carries no card number. There is **no** endpoint that reveals it in full.
  accountHolderName:
    type: string
    description: The name on the account. May legitimately differ from the party's own name and is never validated against it.
  designatedAmountMinor:
    type: string
    description: «مبلغ حساب» — a non-negative decimal **string** in Rial minor units. The amount the party wants deposited into this account: a fill target the matching engine reads, **not** a bank balance and not the accounting balance. `"0"` when unset.
  isCompanyAccount:
    type: boolean
    description: Whether this account belongs to the business rather than to the counterparty. Read-only here; set through the `matching:settings`-gated route in the Admin Bank Accounts API.
  isExcludedFromMatching:
    type: boolean
    description: When `true`, the matching engine never nominates this account as a destination. Read-only here; set through the `matching:settings`-gated route in the Admin Bank Accounts API.
  matchExclusionReason:
    type: string
    nullable: True
    description: Why the account was excluded. Always `null` when `isExcludedFromMatching` is `false`.
  currency:
    type: string
    description: Three-character currency code. `"IRR"` unless explicitly set otherwise.
  isDefault:
    type: boolean
    description: Whether this is the party's default destination. At most one live account per party carries `true`; moved with the `/set-default` endpoint, never through `PATCH`.
  isActive:
    type: boolean
    description: Whether the account is believed usable. Distinct from soft deletion.
  note:
    type: string
    nullable: True
    description: Free-text internal note, up to 2000 characters.
  version:
    type: integer
    description: Optimistic-concurrency counter. Returned as the `ETag` header and required back as `If-Match` on `PATCH`.
  deletedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant of the soft delete; `null` for a live account. A deleted account releases its IBAN for reuse.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write.
required:
  - id
  - partyId
  - bank
  - accountNumber
  - ibanMasked
  - cardNumberMasked
  - accountHolderName
  - designatedAmountMinor
  - isCompanyAccount
  - isExcludedFromMatching
  - matchExclusionReason
  - currency
  - isDefault
  - isActive
  - note
  - version
  - deletedAt
  - createdAt
  - updatedAt

### IbanReveal
type: object
description: The unmasked IBAN of one account, in stored and human-readable forms.
properties:
  id:
    type: string
    format: uuid
    description: The account the IBAN belongs to, echoed from the path.
  iban:
    type: string
    nullable: True
    description: The 24-digit stored body, **without** the `IR` prefix. `null` when the account carries no IBAN.
  ibanFormatted:
    type: string
    nullable: True
    description: The same value with the `IR` prefix re-attached and grouped in fours — e.g. `IR82 0170 0000 0012 3456 7890 12` — for reading back aloud. `null` when the account carries no IBAN.
required:
  - id
  - iban
  - ibanFormatted

### BankAccountEnvelope
type: object
description: Success envelope around a single bank account.
properties:
  data:
    $ref: #/components/schemas/BankAccount
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### BankAccountListEnvelope
type: object
description: Success envelope around a party's accounts. Not paginated — `meta` carries only `requestId` and `timestamp`.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/BankAccount
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### BankListEnvelope
type: object
description: Success envelope around the bank registry. Not paginated.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/Bank
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### IbanRevealEnvelope
type: object
description: Success envelope around a revealed IBAN.
properties:
  data:
    $ref: #/components/schemas/IbanReveal
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### BankAccountDeletedEnvelope
type: object
description: Success envelope confirming a soft deletion.
properties:
  data:
    type: object
    properties:
      id:
        type: string
        format: uuid
        description: The account id that was soft-deleted, echoed from the path.
    required:
      - id
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### CreateBankAccountRequest
type: object
description: A new account. **Strict**: `bankId`, `isCompanyAccount` and the matching flags are rejected rather than dropped, because the bank is derived and the flags belong to a different permission.
properties:
  accountNumber:
    type: string
    minLength: 1
    maxLength: 40
    description: The bank's own account number. Not masked in responses.
  iban:
    type: string
    minLength: 1
    maxLength: 80
    description: Iranian IBAN (شبا), with or without the `IR` prefix. Validated by structure and ISO 7064 mod-97 checksum. Determines the bank.
  cardNumber:
    type: string
    minLength: 1
    maxLength: 40
    description: Card number, validated with the Luhn algorithm. Its BIN determines the bank when no IBAN is supplied.
  accountHolderName:
    type: string
    minLength: 1
    maxLength: 160
    description: The name on the account. Never checked against the party's own name.
  designatedAmountMinor:
    type: string
    pattern: ^\d+$
    description: «مبلغ حساب» as a **non-negative** integer string in Rial minor units. A minus sign is rejected. Defaults to `"0"`.
  currency:
    type: string
    minLength: 3
    maxLength: 3
    default: IRR
    description: Three-character currency code.
  note:
    type: string
    maxLength: 2000
    nullable: True
    description: Free-text internal note.
  isDefault:
    type: boolean
    default: False
    description: `true` clears the party's previous default in the same transaction and makes this the default destination.
required:
  - accountHolderName

### UpdateBankAccountRequest
type: object
description: Partial update; at least one field must be supplied. **Strict**: `bankId`, `isDefault`, `isCompanyAccount` and the matching-exclusion fields are rejected rather than dropped — each has its own route or is derived.
properties:
  accountNumber:
    type: string
    minLength: 1
    maxLength: 40
    nullable: True
    description: New account number, or `null` to clear it.
  iban:
    type: string
    minLength: 1
    maxLength: 80
    nullable: True
    description: New IBAN, re-validated and used to re-derive the bank, or `null` to clear it. A value held by another live account is `409`.
  cardNumber:
    type: string
    minLength: 1
    maxLength: 40
    nullable: True
    description: New card number, re-validated and used to re-derive the bank, or `null` to clear it.
  accountHolderName:
    type: string
    minLength: 1
    maxLength: 160
    description: New name on the account.
  designatedAmountMinor:
    type: string
    pattern: ^\d+$
    description: New fill target as a non-negative integer string in Rial minor units.
  currency:
    type: string
    minLength: 3
    maxLength: 3
    description: New three-character currency code.
  note:
    type: string
    maxLength: 2000
    nullable: True
    description: New internal note, or `null` to clear it.


========================================================================
# Admin Bank Accounts API (app: bank-accounts-admin, version 1.0.0)
========================================================================

Servers: http://localhost:3000

The three flags on a bank account that govern how the settlement matching
engine treats it: whether the account is excluded from matching altogether,
and whether it belongs to the business rather than to the counterparty.

They are deliberately not part of `PATCH /api/v1/bank-accounts/{id}`. Editing
an IBAN is bookkeeping; deciding that an account may or may not receive
settlement money is a decision with money attached, and the two are gated by
different permissions. Everything else about an account — creating, reading,
editing, deleting, restoring, revealing an IBAN — is in the Bank Accounts API.

## Authentication
Every endpoint requires a Bearer access token whose operator holds
`matching:settings`. In the seeded role set only **ADMIN** holds it;
`ACCOUNTANT` is explicitly withheld it, along with `matching:lock` and
`matching:issue-payments`.

1. Obtain a token pair from `POST /api/v1/auth/login` — see the
   Authentication API. There is no separate staff login endpoint.
2. Send `Authorization: Bearer <accessToken>` on every request.
3. Refresh the pair through the same `POST /api/v1/auth/refresh`.

`matching:settings` is not a dangerous permission, so no password re-entry is
required. A missing, malformed or expired token returns `401`; a valid token
whose operator lacks the permission returns `403 PERMISSION_DENIED`, audited
with `outcome: DENIED`.

## Conventions
- Success bodies are the §9.1 envelope `{ data, meta }` carrying the account's
  **full view** — the same shape `GET /api/v1/bank-accounts/{id}` returns — so
  the panel can re-render the row without a second call.
- No endpoint here takes `If-Match`. These are deliberate switches asserting a
  desired state, not field edits competing with another editor, and blocking
  them on a stale version would add friction without adding safety. All three
  still increment the account's `version`, so a `PATCH` already in flight
  against the old version correctly fails with `409 CONCURRENT_MODIFICATION`.
- All three are `POST`/`DELETE` actions returning `200`, never `201` or `204`
  — nothing is created, and the caller needs the refreshed row.
- IBAN and card number stay **masked** in these responses, exactly as in the
  Bank Accounts API. Nothing here reveals an identifier.
- `designatedAmountMinor` is a non-negative decimal **string** in Rial minor
  units — a fill target for the matching engine, not a balance.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## Effect on matching
- `isExcludedFromMatching: true` means the engine never nominates this account
  as a destination for an allocation. The party may still be matched — through
  one of their other accounts.
- `isCompanyAccount: true` marks the account as the business's own rather than
  a counterparty's. The engine treats company accounts differently when it
  decides where money may go.
- Neither flag unwinds anything already decided. Allocations already made and
  payment orders already issued stand; an issued payment renders from its own
  frozen account snapshot in any case.
- Both are database flags read at board-build time, never constants compiled
  into the solver. Setting one does not itself rebuild the board — trigger
  that with `POST /api/v1/matching/recompute`.
- The account-level exclusion is a **separate control** from the party-level
  one in the Admin Parties API. Excluding a party removes them entirely;
  excluding an account removes only that destination.

## x-matching-flag-scope
description: What each flag reaches and what it does not, from `src/modules/bank-accounts/bank-accounts.service.ts` and the matching eligibility rules.
effects:
  -
    exclusion_scope: The engine will not nominate this account as a destination. The party stays eligible and may be matched through another of their accounts. To remove a party entirely, use POST /api/v1/parties/{id}/matching-exclusion in the Admin Parties API.
  -
    company_flag: Marks the account as the business's own. The engine distinguishes company accounts from counterparty accounts when deciding where an allocation may be sent.
  -
    independence: The two flags are independent of each other and of isDefault and isActive. A default, active, company account can also be excluded from matching.
  -
    existing_work: Neither flag unwinds an existing allocation or an issued payment order. An issued payment renders from its own frozen account snapshot in any case.
  -
    recompute: Setting a flag does not rebuild the matching board. Trigger that with POST /api/v1/matching/recompute.
  -
    not_a_solver_constant: Both are database flags read at board-build time. Neither is compiled into the solver, so a change takes effect on the next build without a deploy.

## Operations

### POST /api/v1/bank-accounts/{id}/matching-exclusion
operationId: adminExcludeBankAccountFromMatching
auth: bearer
summary: Exclude a bank account from settlement matching.

Sets the account's matching exclusion flag together with the reason for
it, and returns the refreshed account.

**Notes:**
- Requires `matching:settings`, held only by `ADMIN` in the seeded roles.
- Returns `200`, not `201` — a flag on an existing account is switched.
- `reason` is **required**, 1–500 characters. There is no way to exclude
  an account silently: the reason is what makes the exclusion auditable
  and reversible by somebody who was not in the room.
- Excluding an already-excluded account succeeds and **overwrites** the
  stored reason with the new one. It is not a conflict.
- While excluded, the matching engine never nominates this account as a
  destination. The party can still be matched through another of their
  accounts; to remove the party entirely, use the Admin Parties API.
- The account remains fully usable elsewhere: it can still be edited,
  nominated as the party's default, and named on a manually created
  payment order.
- Existing allocations and issued payment orders are **not** unwound.
- Side effects: `isExcludedFromMatching` set to `true`,
  `matchExclusionReason` stored, `version` incremented,
  `updatedByOperatorId` recorded, and a `matching.account.excluded` audit
  row written carrying the reason.
request body: SetMatchingExclusionRequest
responses:
  200: BankAccountEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/bank-accounts/{id}/matching-exclusion
operationId: adminRemoveBankAccountMatchingExclusion
auth: bearer
summary: Return a bank account to settlement matching.

Clears the exclusion flag and its reason, and returns the refreshed
account.

**Notes:**
- Requires `matching:settings`. Takes no request body.
- Returns `200` with the account, not `204`: the panel needs the refreshed
  row and the new `version` to keep editing.
- Clearing an exclusion that is not set succeeds. It is not a conflict —
  the endpoint asserts a desired state rather than performing a
  transition.
- `matchExclusionReason` is set to `null` on success. The reason survives
  only in the audit trail.
- The account becomes eligible again from the **next** board build; this
  does not itself recompute the matching board. Trigger that with
  `POST /api/v1/matching/recompute` in the Matching API.
- Side effects: `isExcludedFromMatching` set to `false`,
  `matchExclusionReason` cleared, `version` incremented,
  `updatedByOperatorId` recorded, and a
  `matching.account.exclusion_removed` audit row written.
responses:
  200: BankAccountEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/bank-accounts/{id}/company-flag
operationId: adminSetBankAccountCompanyFlag
auth: bearer
summary: Mark or unmark a bank account as a company account.

Sets `isCompanyAccount` to the supplied value and returns the refreshed
account.

**Notes:**
- Requires `matching:settings` — the same permission as the exclusion
  routes, because it is the same kind of decision about where settlement
  money may go.
- Returns `200`, not `201`.
- The body carries the **desired state**, not a toggle: send
  `{"isCompanyAccount": true}` to mark and `{"isCompanyAccount": false}`
  to unmark. Sending the value the account already holds succeeds.
- `isCompanyAccount` marks the account as belonging to the business rather
  than to the counterparty. The matching engine treats company accounts
  differently when deciding where an allocation may be sent.
- Independent of `isExcludedFromMatching`: an account can be a company
  account and excluded, or either alone.
- Existing allocations and issued payment orders are **not** revisited.
- Side effects: `isCompanyAccount` set, `version` incremented,
  `updatedByOperatorId` recorded, and an audit row written.
request body: SetCompanyFlagRequest
responses:
  200: BankAccountEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### BankSummary
type: object
description: The bank the account resolved to. Derived from the IBAN issuer code or the card BIN — never chosen by a client.
properties:
  id:
    type: string
    format: uuid
    nullable: True
    description: Internal bank id.
  key:
    type: string
    description: Stable bank key, e.g. `MELLI`, `MELLAT`, or `UNKNOWN` for an unrecognised issuer.
  nameFa:
    type: string
    description: Persian bank name, for display.
  ibanCode:
    type: string
    nullable: True
    description: The three-digit IBAN issuer segment. `null` for the `UNKNOWN` fallback.
required:
  - id
  - key
  - nameFa
  - ibanCode

### BankAccount
type: object
description: A counterparty bank account, as returned by every endpoint in this file. Identifiers stay masked; nothing here reveals one.
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7).
  partyId:
    type: string
    format: uuid
    description: The party that owns this account.
  bank:
    allOf:
      -
        $ref: #/components/schemas/BankSummary
    nullable: True
    description: The derived bank. `null` when the account carries neither an IBAN nor a card number.
  accountNumber:
    type: string
    nullable: True
    description: The bank's own account number, returned in full — it is not masked.
  ibanMasked:
    type: string
    nullable: True
    description: Display-only mask, e.g. `IR** **** **** **** **** **47`. `null` when the account carries no IBAN. The full value is behind `bank-accounts:read-sensitive` in the Bank Accounts API.
  cardNumberMasked:
    type: string
    nullable: True
    description: Display-only mask, e.g. `610433••••••1234`. `null` when the account carries no card number. No endpoint reveals it in full.
  accountHolderName:
    type: string
    description: The name on the account. May legitimately differ from the party's own name — and a mismatch is a common reason to exclude an account here.
  designatedAmountMinor:
    type: string
    description: «مبلغ حساب» — a non-negative decimal **string** in Rial minor units. A fill target the matching engine reads, **not** a balance. `"0"` when unset. An excluded account's target is simply never consulted.
  isCompanyAccount:
    type: boolean
    description: `true` ⇒ the account belongs to the business rather than to the counterparty, and the matching engine treats it accordingly. Set by `POST /api/v1/bank-accounts/{id}/company-flag`.
  isExcludedFromMatching:
    type: boolean
    description: `true` ⇒ the matching engine never nominates this account as a destination. The party may still be matched through another account.
  matchExclusionReason:
    type: string
    nullable: True
    description: Why the account was excluded, as supplied on the last exclusion. Always `null` when `isExcludedFromMatching` is `false`; the reason then survives only in the audit trail.
  currency:
    type: string
    description: Three-character currency code. `"IRR"` unless explicitly set otherwise.
  isDefault:
    type: boolean
    description: Whether this is the party's default destination. Independent of the matching flags: a default account can still be excluded from matching.
  isActive:
    type: boolean
    description: Whether the account is believed usable. Distinct from both soft deletion and exclusion.
  note:
    type: string
    nullable: True
    description: Free-text internal note. Distinct from `matchExclusionReason`.
  version:
    type: integer
    description: Optimistic-concurrency counter. Incremented by every endpoint here even though none takes `If-Match`, so a `PATCH` in flight against the old version correctly fails.
  deletedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant of the soft delete; `null` for a live account.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write.
required:
  - id
  - partyId
  - bank
  - accountNumber
  - ibanMasked
  - cardNumberMasked
  - accountHolderName
  - designatedAmountMinor
  - isCompanyAccount
  - isExcludedFromMatching
  - matchExclusionReason
  - currency
  - isDefault
  - isActive
  - note
  - version
  - deletedAt
  - createdAt
  - updatedAt

### BankAccountEnvelope
type: object
description: Success envelope around a single bank account.
properties:
  data:
    $ref: #/components/schemas/BankAccount
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### SetMatchingExclusionRequest
type: object
description: Why this account should never receive settlement money. Recording a reason is mandatory; an unexplained exclusion is unanswerable months later.
properties:
  reason:
    type: string
    minLength: 1
    maxLength: 500
    description: Free text, trimmed. Stored on the account, returned as `matchExclusionReason`, and copied into the audit row.
required:
  - reason

### SetCompanyFlagRequest
type: object
description: The desired state of the company-account marking, not a toggle. Sending the value the account already holds succeeds.
properties:
  isCompanyAccount:
    type: boolean
    description: `true` ⇒ the account belongs to the business rather than to the counterparty.
required:
  - isCompanyAccount


========================================================================
# Files API (app: files, version 1.0.0)
========================================================================

Servers: http://localhost:3000

Retrieval of stored receipt images. Two routes, with deliberately different
security models.

Files in this system live in **private object storage**. Nothing is ever
served from a permanent URL: a client asks for a file, the server checks the
caller's permission and the file's malware-scan status, and mints a fresh,
short-lived signed URL for that one request.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. A payment's `receipt.thumbnailUrl` (from the Payments API) points at
   `GET /api/v1/files/{id}/thumb`.
3. The client calls that endpoint with its Bearer token and receives a signed
   URL plus its lifetime in seconds.
4. The client puts that URL into an `<img src>`. The browser fetches the bytes
   directly from storage without a token, because the signature *is* the
   authorisation.
5. When the URL expires, the client asks for a new one. There is nothing to
   cache and nothing to bookmark.

## Security Notes
- `GET /api/v1/files/{id}/thumb` requires a valid Bearer access token and the
  `payments:read` permission — the same permission that lets you read the
  payment the receipt belongs to.
- `GET /api/v1/files/local-object` requires **no** bearer token, because an
  `<img src>` cannot carry one. It is not therefore unprotected: nothing is
  served without a valid, unexpired signature that this server issued. That
  signature check stands in for object storage refusing an unsigned read.
- The signed URL's lifetime is `RECEIPT_URL_TTL_SECONDS` — 300 seconds by
  default. Treat it as short-lived; there is no permanent link to any file.
- A file whose asynchronous malware scan has not finished is
  `409 RECEIPT_SCAN_PENDING`. A file the scan flagged is
  `409 RECEIPT_SCAN_INFECTED` and will never be served, at either route.
- Thumbnails exist only for **images**. A PDF receipt has none, and asking for
  one is `404 RESOURCE_NOT_FOUND` naming `StoredFile` — the same answer as an
  unknown id, so the route cannot be used to probe which files exist.
- `GET /api/v1/files/local-object` is served with `Cache-Control: no-store`,
  so an expired signature cannot be replayed out of a browser cache.
- Both routes are under the global rate limit of 100 requests per minute per
  IP.

## Storage drivers
- With the `s3` driver, a signed URL points straight at the bucket and
  `GET /api/v1/files/local-object` is never reached in practice.
- With the `local` driver, that route **is** the bucket: it is where the signed
  URLs point, and its signature check is what makes the local driver as
  unreadable-without-authorisation as S3.
- The route is registered unconditionally under both drivers. A
  `local`-driver deployment must not depend on wiring that exists only in a
  dev-mode branch.

## x-signed-url-model
description: How file access is authorised, from `src/modules/files/files.controller.ts` and `src/infrastructure/storage/local-object-signer.ts`.
principles:
  -
    no_permanent_links: The bucket is private and no endpoint returns a lasting URL. Every request for a file mints a fresh signature valid for RECEIPT_URL_TTL_SECONDS.
  -
    permission_checked_at_mint_time: The bearer token and the payments:read permission are checked when the URL is issued, not when the bytes are fetched — the fetch carries no token.
  -
    signature_is_the_authorisation: GET /api/v1/files/local-object is @Public() in the routing sense only. Without a valid, unexpired signature it serves nothing, which is what makes the local driver as unreadable-without-authorisation as S3.
  -
    uniform_refusal: Every signature failure — missing parameter, bad signature, expired timestamp — is 403 PERMISSION_DENIED. The answer never reveals whether the object exists.
  -
    no_store: The object route sets Cache-Control: no-store, so an expired signature cannot be replayed out of a browser cache.
  -
    scan_gate: A file is served only when its malware scan reports clean. PENDING is 409 RECEIPT_SCAN_PENDING; INFECTED is 409 RECEIPT_SCAN_INFECTED, permanently.

## Operations

### GET /api/v1/files/{id}/thumb
operationId: getFileThumbnailUrl
auth: bearer
summary: Get a short-lived signed URL for a receipt's thumbnail.

Mints a fresh signed URL for the 400-pixel preview of a stored receipt,
after checking the caller's permission and the file's scan status.

**Notes:**
- Requires `payments:read` — the same permission that lets you read the
  payment the receipt belongs to. There is no separate file permission.
- Returns a **URL, not the bytes**. Put it into an `<img src>`; the browser
  fetches it directly from storage.
- A new URL is minted on every call. Nothing is stored, nothing is reused,
  and there is no permanent link.
- `expiresInSeconds` comes from `RECEIPT_URL_TTL_SECONDS`, 300 by default.
- A file with **no thumbnail** — every PDF receipt — is
  `404 RESOURCE_NOT_FOUND`, the same answer as an unknown id. The two are
  deliberately indistinguishable.
- A scan still pending is `409 RECEIPT_SCAN_PENDING`; a flagged file is
  `409 RECEIPT_SCAN_INFECTED` and never becomes servable.
- The full-size receipt has its own endpoint,
  `GET /api/v1/payments/{id}/receipt`, addressed by payment rather than by
  file.
- Side effects: none. Unlike revealing an IBAN, fetching a thumbnail URL is
  not itself audited.
responses:
  200: SignedUrlEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/files/local-object
operationId: getLocalObject
auth: public
summary: Serve a stored object against a signed URL.

Returns the raw bytes of a stored object, provided the request carries a
valid, unexpired signature this server issued.

**Notes:**
- **No bearer token.** This route is the destination of the signed URLs the
  other endpoints mint, and its consumer is an `<img src>` or a browser
  navigation, neither of which can carry an `Authorization` header.
- It is not unauthenticated in the meaningful sense: `key`, `expiresAt` and
  `signature` must all be present, the signature must verify against this
  server's key, and `expiresAt` must still be in the future. That check is
  what stands in for object storage refusing an unsigned read.
- Any failure — a missing parameter, a bad signature, an expired
  timestamp — is **`403 PERMISSION_DENIED`**, never `404` or `400`. The
  answer does not reveal whether the object exists.
- Do **not** construct these URLs by hand. Obtain them from
  `GET /api/v1/files/{id}/thumb` or
  `GET /api/v1/payments/{id}/receipt`.
- The response is the raw file bytes with a `Content-Type` derived from the
  object key's extension — **not** the §9.1 JSON envelope.
- `Cache-Control: no-store` is set, so an expired signature cannot be
  replayed from a browser cache.
- Reachable in practice only under the `local` storage driver. With the
  `s3` driver, signed URLs point straight at the bucket and this route is
  never used — but it stays registered either way, so a `local` deployment
  does not depend on dev-only route wiring.
- This endpoint is excluded from the generated interactive API docs, since
  it is a storage mechanism rather than part of the panel's API surface.
- Side effects: none.
responses:
  200: string
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### SignedUrlEnvelope
type: object
description: Success envelope around a freshly minted signed URL. A new one is issued on every call; nothing is cached server-side.
properties:
  data:
    type: object
    properties:
      url:
        type: string
        format: uri
        description: A signed, expiring URL into private storage. Under the `s3` driver it points at the bucket; under `local` it points back at `GET /api/v1/files/local-object`. Either way, put it in an `<img src>` — do not re-sign or rewrite it.
      expiresInSeconds:
        type: integer
        description: How long the URL stays valid, from `RECEIPT_URL_TTL_SECONDS` (300 by default). Ask for a new one rather than caching this.
    required:
      - url
      - expiresInSeconds
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta


========================================================================
# Financial Records API (app: financial-records, version 1.0.0)
========================================================================

Servers: http://localhost:3000

«سوابق مالی» — multi-asset counterparty balances mirrored from the accounting
system: the current book, one record's full snapshot history with per-asset
deltas, and the orphan report.

Two decisions shape every endpoint here, and a client that does not know them
will misread the numbers:

**Orphans are first-class.** A balance whose external code has no party record
in the accounting export is still a real position — in the reference dataset
that is 72.7% of the book, including the single largest position in it. This
module never inner-joins to `parties`; an orphan gets a placeholder identity
and is returned like any other row.

**Coverage is declared, not assumed.** The upstream balance endpoints are a
Top-N leaderboard, not the whole dataset. Every response built from a partial
sync carries `meta.coverage`, and a financial screen that silently shows a
fraction of the data is worse than one that shows nothing.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. Operator lists the book with `GET /api/v1/financial-records`, filtering by
   status, asset, amount range, group, or a Persian-folded name/code search.
3. Client checks `meta.coverage` on that response: when `complete` is `false`,
   the page is a partial view of the book and the panel must say so.
4. Operator opens one record with
   `GET /api/v1/financial-records/{externalCode}` — keyed by the accounting
   code, so an orphan with no party id is openable too.
5. Operator reads the full snapshot series with
   `GET /api/v1/financial-records/{externalCode}/history`, where each entry
   carries per-asset `deltas` against the chronologically previous snapshot.
6. Operator reviews unattributed money with
   `GET /api/v1/financial-records/orphans`, whose `summary` block is the
   evidence to take back to the accounting vendor.

## Security Notes
- Every endpoint requires a valid Bearer access token and the single
  permission `balances:read`. In the seeded role set `ADMIN`, `ACCOUNTANT`
  and `VIEWER` all hold it. Nothing in this module writes.
- There is no ownership scoping: an operator who holds `balances:read` sees
  the whole book. An external code with no snapshot is
  `404 RESOURCE_NOT_FOUND`.
- **`includeOrphans` defaults to `true`.** An operator has to ask explicitly
  to hide the balances with no party record; the API never hides them by
  accident.
- The detail and history endpoints are keyed by **`externalCode`**, the
  accounting system's string key — never by a party id. That is deliberate:
  an orphan has no party id and must still be openable.
- Every monetary and weight value crosses the wire as a **string**. Rial is a
  64-bit integer in minor units; gold and silver weights are three-decimal
  gram figures. Parsing either with `Number()` starts an arithmetic drift
  that never stops.
- Alongside each raw value the server pre-renders a `formatted` string. It is
  rendered once, server-side, so the several panel components that would each
  format the same value differently never get the chance to disagree.
- `headlineStatus` derives from the **Rial line only**. A party can be a Rial
  debtor and a gold creditor at the same time; `hasMixedPosition` flags
  exactly that, and the headline alone is then misleading.
- `invariantOk: false` means the gold invariant did not hold for that
  snapshot — the coin and bullion weights do not reconcile against the
  gold-excluding-coins line. Do not trust that row's gold figures.
- IBANs on the embedded bank accounts are **masked**. Revealing one in full is
  a separately permissioned, audited act; see the Bank Accounts API.
- Every success is the §9.1 envelope `{ data, meta }`; every failure is
  `{ error: { code, message, messageFa, status, details, requestId } }` with
  both an English and a Persian sentence.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## Coverage
- `meta.coverage` is present on `GET /api/v1/financial-records` and on
  `GET /api/v1/financial-records/orphans`, and **absent** whenever the data is
  whole. Its mere presence means "read this".
- It is resolved from the most recent successful or partial balance sync run,
  not guessed per endpoint, so the two endpoints can never disagree about
  whether the book is complete.
- `reason: "TOP_N_ENDPOINT"` — the accounting vendor's balance endpoints
  return a leaderboard, so the book is inherently partial.
  `reason: "SYNC_INCOMPLETE"` — no balance sync has ever succeeded, and
  `recordCount` is `0`.
- The detail and history endpoints carry **no** `coverage`: a single record's
  snapshots are whole or absent, never a sample.

## x-serialization
description: How numbers and dates cross the wire in this module, from `src/common/money/`, `src/common/date/jalali.util.ts` and `src/bootstrap/bigint-json.ts`. Getting these wrong is the most likely way to misread a balance.
rules:
  -
    rial: A 64-bit integer in minor units, serialised as a JSON string. Values in this dataset exceed what a JS number represents exactly. Never Number().
  -
    weights: Three-decimal gram figures, serialised as strings. Arithmetic on them must use a decimal library, never floats.
  -
    counts_and_fx: Also decimal strings, for the same reason.
  -
    formatted: A server-rendered Persian string accompanying each value. Display it; never parse it.
  -
    jalali: observedAtJalali is a Persian calendar string "YYYY/MM/DD HH:MM:SS" for display only. observedAt beside it is the machine-readable ISO-8601 UTC instant.
  -
    nominal_gram_warning: A coin's nominal gram figure is display metadata. Never multiply it by a count to derive a weight.

## x-orphan-policy
description: Why balances with no party record are shown rather than filtered, from `src/modules/financial-records/financial-record.view.ts`.
rule: Nothing in this module filters on party IS NOT NULL. An inner join to parties would silently drop the majority of the book, including its single largest position.
default: includeOrphans defaults to true; hiding them requires an explicit false.
identity: An orphan is given a placeholder party object with id null and a rendered «ناشناس - کد …» name, so the frontend never special-cases a missing party.
interaction: Filtering by groupId excludes orphans implicitly and correctly, since an orphan has no party and therefore no group.

## Operations

### GET /api/v1/financial-records
operationId: listFinancialRecords
auth: bearer
summary: List the current book of counterparty balances.

Returns a page of the latest balance snapshot per accounting code, each
with its Rial headline, its per-asset lines, and the bank accounts
nominated against the code.

**Notes:**
- Requires `balances:read`.
- Only **latest** snapshots are listed; the historical series is behind
  `/history`.
- `includeOrphans` defaults to **`true`**. Sending `false` is the only way
  to narrow the book to codes that have a party record.
- `groupId` naturally excludes orphans, since an orphan has no party and
  therefore no group. That is correct, not an interaction bug.
- `q` searches two things at once: a substring of `externalCode`, and the
  party's name folded through the same Persian normalisation that wrote
  the stored search column — so an Arabic-yeh query finds a Persian-yeh
  name.
- `minAmount` / `maxAmount` are **signed integer strings** in Rial minor
  units, compared against the raw `irrAmount`. Because a debit position is
  negative, `minAmount=0` selects creditors and settled codes.
- `assetKey` matches a code that holds **any** line in that asset; it does
  not restrict which lines come back.
- `meta.coverage` reports whether the underlying balance sync was
  complete. It is **absent** when the book is whole.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100. A page past
  the end returns an empty `data` array, not `404`.
- Side effects: none.
responses:
  200: PaginatedFinancialRecords
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/financial-records/orphans
operationId: getOrphanReport
auth: bearer
summary: Report balances that have no party record.

Returns the unattributed positions, largest first, together with a summary
block quantifying how much of the book cannot currently be attributed to
anyone.

**Notes:**
- Requires `balances:read`.
- This route is matched **before** `/{externalCode}`, so `orphans` is a
  reserved code in this path space.
- Rows are sorted by **absolute** Rial descending: the biggest unexplained
  position is the first thing anyone sees.
- Unlike the other list endpoints, the page descriptor travels inside
  `data.page` rather than in `meta`. The §9.1 `meta` is a fixed contract
  with no room for a domain summary, and forking it for one endpoint is
  how six modules end up with six envelopes. `meta.coverage` still arrives
  through the standard channel.
- `summary.codeRange` against `summary.knownPartyCodeRange` is the point of
  the block: the span of codes the balances reference versus the span the
  party export actually delivers. Both are `null` when there is nothing to
  span.
- `orphanRatio` is the share of latest snapshots that are orphaned, 0–1,
  rounded to four decimal places. It is the same number exported as a
  service metric.
- `snapshotCount` and `firstSeenAt` distinguish a code seen once from one
  that has been unattributed for months.
- `meta.coverage` reports whether the underlying balance sync was
  complete, resolved from the same sync run the book listing uses, so the
  two can never disagree.
- Side effects: none.
responses:
  200: OrphanReportEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/financial-records/{externalCode}
operationId: getFinancialRecord
auth: bearer
summary: Retrieve one financial record by its accounting code.

Returns the latest balance snapshot for one accounting code, with every
asset line, every bank account nominated against it, the party when one
exists, and the last few snapshots inline.

**Notes:**
- Requires `balances:read`.
- Keyed by **`externalCode`**, not by a party id, precisely so that an
  orphan — which has no party id — is openable from the same route.
- A code with no balance snapshot at all is `404 RESOURCE_NOT_FOUND`
  naming `FinancialRecord`.
- `party` is always populated. For an orphan it carries `id: null` and the
  placeholder name «ناشناس - کد …»; the frontend renders it exactly like a
  real party and lets `isOrphaned` drive the badge.
- `recentHistory` is the most recent snapshots for this code, newest
  first, **including this one**. The full series is at `/history`.
- A `COIN` line carries a gram figure and a count at once, so `formatted`
  reads as a human would say it — `"۱۹٫۵۲ گرم + ۲ عدد"`. A line always has
  at least one populated value column.
- This endpoint carries **no** `meta.coverage`: one record's snapshot is
  whole or absent, never a sample.
- Side effects: none.
responses:
  200: FinancialRecordDetailEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/financial-records/{externalCode}/history
operationId: listFinancialRecordHistory
auth: bearer
summary: List one code's snapshot history with per-asset deltas.

Returns the full snapshot series for one accounting code — one entry per
sync run, newest first — each carrying its asset lines and the change in
each asset since the chronologically previous snapshot.

**Notes:**
- Requires `balances:read`.
- A code with no snapshot at all is `404 RESOURCE_NOT_FOUND` naming
  `FinancialRecord`.
- `deltas` is **`null` on the oldest snapshot on file** — there is nothing
  earlier to diff against. Every other entry carries an array.
- The predecessor is resolved against the **whole** series, not against
  the page being returned, so the first row of page 2 still diffs against
  the last row of page 1 rather than reporting `null`.
- There is one delta entry per asset appearing in **either** snapshot: a
  position opened or closed between the two is exactly as visible as one
  that merely changed size. The missing side is treated as zero, which is
  what "opened" and "closed" mean.
- Deltas are computed with exact integer and decimal arithmetic, never by
  subtracting parsed floats, so the same overflow and scale guards that
  protect a balance also protect its delta.
- A delta slot is `null` on both sides when neither snapshot ever populated
  it for that asset — the common case, since an asset keeps the same unit
  for its whole life.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100. This
  endpoint carries **no** `meta.coverage`.
- Side effects: none.
responses:
  200: PaginatedFinancialRecordHistory
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PaginationMeta
type: object
description: The §9.1 success envelope's `meta` for an offset-paginated list. `totalPages` is derived by the envelope interceptor, never by a handler.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total rows matching the filter across all pages.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when `total` is 0.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - page
  - pageSize
  - total
  - totalPages
  - requestId
  - timestamp

### BalanceStatus
type: string
enum:
  - DEBTOR
  - CREDITOR
  - SETTLED
description: A position's direction. At record level it is derived from the **Rial line only**; at line level it is that asset's own direction, which is why the two can disagree.

### ExternalEntity
type: string
enum:
  - BALANCE_DEBTOR
  - BALANCE_CREDITOR
description: Which upstream accounting endpoint this snapshot was fetched from. The two are separate feeds, and a code can appear in either.

### CoverageReason
type: string
enum:
  - TOP_N_ENDPOINT
  - UPSTREAM_PAGE_LIMIT
  - SYNC_INCOMPLETE
  - FILTERED
description: Why a response is not the whole truth. `TOP_N_ENDPOINT` — the accounting vendor's balance endpoints return a leaderboard. `UPSTREAM_PAGE_LIMIT` — the upstream feed stopped paginating. `SYNC_INCOMPLETE` — no balance sync has ever succeeded. `FILTERED` — the source was narrowed before this system saw it.

### Coverage
type: object
description: Present in `meta` **only when the response is partial**; its mere presence means "read this". Resolved from the most recent successful or partial balance sync run, so every endpoint in this module gives the same verdict.
properties:
  complete:
    type: boolean
    description: `false` whenever the response cannot be shown to the operator as the whole truth.
  reason:
    $ref: #/components/schemas/CoverageReason
  recordCount:
    type: integer
    description: How many records the upstream source actually yielded on that run. `0` when no balance sync has ever succeeded.
required:
  - complete

### CoveragePaginationMeta
description: The paginated `meta`, plus the optional `coverage` block this module attaches when the underlying sync was partial.
allOf:
  -
    $ref: #/components/schemas/PaginationMeta
  -
    type: object
    properties:
      coverage:
        $ref: #/components/schemas/Coverage

### CoverageResponseMeta
description: The single-object `meta`, plus the optional `coverage` block. Used by the orphan report, which carries its own page descriptor inside `data`.
allOf:
  -
    $ref: #/components/schemas/ResponseMeta
  -
    type: object
    properties:
      coverage:
        $ref: #/components/schemas/Coverage

### PartyGroupSummary
type: object
description: The counterparty group, when the code has a party record with one.
properties:
  id:
    type: string
    format: uuid
    description: Internal group id; the value to pass as the `groupId` filter.
  externalGid:
    type: string
    description: The accounting system's group key, always a **string**.
  nameFa:
    type: string
    description: Persian group name.
  nameEn:
    type: string
    nullable: True
    description: English group name; `null` for a group nobody has named yet.
required:
  - id
  - externalGid
  - nameFa
  - nameEn

### PartyPhoneSummary
type: object
description: A contact number for the party behind this code. Empty for an orphan.
properties:
  id:
    type: string
    format: uuid
    description: The phone record's internal id.
  e164:
    type: string
    description: Normalised international form, e.g. `+989123456789`.
  display:
    type: string
    description: Persian-digit national rendering, for reading only — never parse it back.
  isPrimary:
    type: boolean
    description: At most one phone per party carries `true`.
  source:
    type: string
    enum:
      - SYNC
      - MANUAL
    description: Who owns the value. Numbers entered in the panel are always `MANUAL`.
required:
  - id
  - e164
  - display
  - isPrimary
  - source

### FinancialRecordParty
type: object
description: The identity behind an accounting code. **Always populated**, including for an orphan — the frontend renders this object the same way in both cases and lets `isOrphaned` on the parent drive the badge.
properties:
  id:
    type: string
    format: uuid
    nullable: True
    description: `null` for an orphan: no party record exists for this code.
  displayName:
    type: string
    description: The party's name, or the server-rendered placeholder «ناشناس - کد …» for an orphan. Never empty.
  firstName:
    type: string
    nullable: True
    description: `null` for an orphan, and for a party whose name was never split.
  lastName:
    type: string
    nullable: True
    description: `null` for an orphan, and for a party whose name was never split.
  group:
    allOf:
      -
        $ref: #/components/schemas/PartyGroupSummary
    nullable: True
    description: `null` for an orphan, and for a party the export gave no group.
  phones:
    type: array
    description: Contact numbers, primary first. Always empty for an orphan.
    items:
      $ref: #/components/schemas/PartyPhoneSummary
required:
  - id
  - displayName
  - firstName
  - lastName
  - group
  - phones

### FinancialRecordAsset
type: object
description: The asset a balance line is denominated in, reduced to what a balance screen needs.
properties:
  key:
    type: string
    description: Stable asset key, e.g. `IRR`, `XAU18`, `COIN_EMAMI_ZIR`. The value the `assetKey` filter takes.
  nameFa:
    type: string
    description: Persian display name of the asset.
  kind:
    type: string
    enum:
      - FIAT_IRR
      - GOLD_WEIGHT
      - GOLD_WEIGHT_EX_COIN
      - SILVER_WEIGHT
      - COIN
      - BULLION
      - FX
    description: What the asset is. A `COIN` line carries a gram figure and a count at once.
  unit:
    type: string
    enum:
      - RIAL
      - GRAM
      - COUNT
      - FX_MAJOR
    description: The unit the line's amounts are measured in.
required:
  - key
  - nameFa
  - kind
  - unit

### FinancialRecordLine
type: object
description: One asset's position within a snapshot. A database row is one *asset*, not one slot: every populated value column is carried verbatim rather than split into synthetic lines.
properties:
  asset:
    $ref: #/components/schemas/FinancialRecordAsset
  amountMinor:
    type: string
    nullable: True
    description: Decimal **string** in Rial minor units, signed. `null` for a line that holds no Rial figure.
  weightGram:
    type: string
    nullable: True
    description: Decimal **string** in grams at three-decimal scale, signed. `null` for a line that holds no weight.
  count:
    type: string
    nullable: True
    description: Decimal **string** count of coin pieces, signed. `null` for a line that holds no count.
  fxAmount:
    type: string
    nullable: True
    description: Decimal **string** in the foreign currency's major unit. `null` for a line that holds no FX figure.
  formatted:
    type: string
    description: Server-rendered Persian summary of every populated column, joined with `+` — e.g. `"۱۹٫۵۲ گرم + ۲ عدد"`. Rendered once here so panel components cannot format the same value differently. Never empty; `"—"` in the impossible case that no column is populated.
  status:
    $ref: #/components/schemas/BalanceStatus
  sourceSlot:
    type: string
    description: Which column of the accounting export's row this line was read from, e.g. `"Value1"`. Kept so a transformation dispute stays diagnosable.
required:
  - asset
  - amountMinor
  - weightGram
  - count
  - fxAmount
  - formatted
  - status
  - sourceSlot

### FinancialRecordLineDelta
type: object
description: The change in one asset's position between two consecutive snapshots. Computed with exact arithmetic, never by subtracting parsed floats.
properties:
  asset:
    $ref: #/components/schemas/FinancialRecordAsset
  deltaAmountMinor:
    type: string
    nullable: True
    description: Signed decimal **string** in Rial minor units. `null` when neither snapshot populated a Rial figure for this asset.
  deltaWeightGram:
    type: string
    nullable: True
    description: Signed decimal **string** in grams. `null` when neither snapshot populated a weight for this asset.
  deltaCount:
    type: string
    nullable: True
    description: Signed decimal **string** count. `null` when neither snapshot populated a count for this asset.
  deltaFxAmount:
    type: string
    nullable: True
    description: Signed decimal **string** in the foreign currency's major unit. `null` when neither snapshot populated an FX figure for this asset.
  formatted:
    type: string
    description: Server-rendered Persian summary of the non-null deltas, joined with `+`.
required:
  - asset
  - deltaAmountMinor
  - deltaWeightGram
  - deltaCount
  - deltaFxAmount
  - formatted

### FinancialRecordBankAccount
type: object
description: A bank account nominated against this accounting code, with its IBAN masked. Full CRUD lives in the Bank Accounts API.
properties:
  id:
    type: string
    format: uuid
    description: The bank account's internal id.
  bank:
    type: object
    nullable: True
    description: `null` when the account's IBAN issuer code matched no known bank.
    properties:
      nameFa:
        type: string
        description: Persian bank name.
    required:
      - nameFa
  ibanMasked:
    type: string
    nullable: True
    description: The IBAN with its middle digits masked. `null` when the account carries no IBAN at all — never an empty string. Revealing the full value is a separately permissioned, audited act.
  accountNumber:
    type: string
    nullable: True
    description: The bank's own account number, when one is recorded.
  accountHolderName:
    type: string
    description: The name on the account, which need not match the party's own name.
  designatedAmountMinor:
    type: string
    description: «مبلغ حساب» as a decimal **string** in Rial minor units — a fill target for the matching engine, **not** a balance. `"0"` when unset.
  isCompanyAccount:
    type: boolean
    description: Whether this account belongs to the business rather than to the counterparty.
  isExcludedFromMatching:
    type: boolean
    description: When `true`, the matching engine never nominates this account as a destination. Distinct from a party-level exclusion.
  isDefault:
    type: boolean
    description: Whether this is the party's default destination account.
required:
  - id
  - bank
  - ibanMasked
  - accountNumber
  - accountHolderName
  - designatedAmountMinor
  - isCompanyAccount
  - isExcludedFromMatching
  - isDefault

### FinancialRecordIrr
type: object
description: The Rial headline of a snapshot, in three renderings of the same number.
properties:
  amountMinor:
    type: string
    description: Signed decimal **string** in Rial minor units — the authoritative value, and the one the `minAmount`/`maxAmount` filters compare against.
  amountToman:
    type: string
    description: The same amount expressed in Toman, as a decimal **string**. A convenience rendering; do not re-derive it client-side.
  formatted:
    type: string
    description: Server-rendered Persian sentence with grouping and the status word.
  status:
    $ref: #/components/schemas/BalanceStatus
required:
  - amountMinor
  - amountToman
  - formatted
  - status

### FinancialRecord
type: object
description: The latest balance snapshot for one accounting code.
properties:
  externalCode:
    type: string
    description: The accounting system's code, a **string**. Always populated, including for an orphan, and the key both detail routes take.
  party:
    $ref: #/components/schemas/FinancialRecordParty
  isOrphaned:
    type: boolean
    description: `true` when no party record exists for this code. Drives the badge; the row itself is returned exactly like any other.
  headlineStatus:
    $ref: #/components/schemas/BalanceStatus
  headlineStatusFa:
    type: string
    description: The headline status in Persian — `بدهکار` / `بستانکار` / `تسویه`.
  hasMixedPosition:
    type: boolean
    description: `true` when the code's position differs in sign across assets — a Rial debtor who is a gold creditor. The headline alone is then misleading.
  invariantOk:
    type: boolean
    description: `false` ⇒ the gold invariant did not hold for this snapshot: the coin and bullion weights do not reconcile against the gold-excluding-coins line. Do not trust this row's gold figures.
  observedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant this snapshot was taken from the accounting export.
  observedAtJalali:
    type: string
    description: The same instant as a Persian (Jalali) calendar string, `"YYYY/MM/DD HH:MM:SS"`, rendered server-side for display only.
  irr:
    $ref: #/components/schemas/FinancialRecordIrr
  lines:
    type: array
    description: One entry per asset this code holds a position in.
    items:
      $ref: #/components/schemas/FinancialRecordLine
  bankAccounts:
    type: array
    description: Accounts nominated against this code, IBANs masked. Empty for an orphan and for a party with none on file.
    items:
      $ref: #/components/schemas/FinancialRecordBankAccount
required:
  - externalCode
  - party
  - isOrphaned
  - headlineStatus
  - headlineStatusFa
  - hasMixedPosition
  - invariantOk
  - observedAt
  - observedAtJalali
  - irr
  - lines
  - bankAccounts

### FinancialRecordSnapshotSummary
type: object
description: A snapshot reduced to headline figures, for the detail view's inline history preview.
properties:
  observedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the snapshot was taken.
  observedAtJalali:
    type: string
    description: The same instant as a Persian (Jalali) string, `"YYYY/MM/DD HH:MM:SS"`.
  headlineStatus:
    $ref: #/components/schemas/BalanceStatus
  irrAmountMinor:
    type: string
    description: Signed decimal **string** in Rial minor units.
  hasMixedPosition:
    type: boolean
    description: Whether the position differed in sign across assets at that moment.
  invariantOk:
    type: boolean
    description: Whether the gold invariant held for that snapshot.
required:
  - observedAt
  - observedAtJalali
  - headlineStatus
  - irrAmountMinor
  - hasMixedPosition
  - invariantOk

### FinancialRecordDetail
description: A financial record plus a preview of its most recent snapshots.
allOf:
  -
    $ref: #/components/schemas/FinancialRecord
  -
    type: object
    properties:
      recentHistory:
        type: array
        description: The most recent snapshots for this code, newest first, **including the one being returned**. The full series is at `/history`.
        items:
          $ref: #/components/schemas/FinancialRecordSnapshotSummary
    required:
      - recentHistory

### FinancialRecordHistoryEntry
type: object
description: One snapshot in the history series, with its change since the previous one.
properties:
  observedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant this snapshot was taken.
  observedAtJalali:
    type: string
    description: The same instant as a Persian (Jalali) string, `"YYYY/MM/DD HH:MM:SS"`.
  headlineStatus:
    $ref: #/components/schemas/BalanceStatus
  headlineStatusFa:
    type: string
    description: The headline status in Persian — `بدهکار` / `بستانکار` / `تسویه`.
  hasMixedPosition:
    type: boolean
    description: Whether the position differed in sign across assets at that moment.
  invariantOk:
    type: boolean
    description: Whether the gold invariant held for this snapshot.
  irr:
    $ref: #/components/schemas/FinancialRecordIrr
  lines:
    type: array
    description: The asset lines as they stood at this snapshot.
    items:
      $ref: #/components/schemas/FinancialRecordLine
  deltas:
    type: array
    nullable: True
    description: The change in each asset since the chronologically previous snapshot. **`null` on the oldest snapshot on file** — there is nothing earlier to diff against. One entry per asset appearing in either snapshot.
    items:
      $ref: #/components/schemas/FinancialRecordLineDelta
required:
  - observedAt
  - observedAtJalali
  - headlineStatus
  - headlineStatusFa
  - hasMixedPosition
  - invariantOk
  - irr
  - lines
  - deltas

### CodeRange
type: object
description: An inclusive span of accounting codes, both ends as **strings**.
properties:
  min:
    type: string
    description: Lowest code in the span.
  max:
    type: string
    description: Highest code in the span.
required:
  - min
  - max

### OrphanBalanceRow
type: object
description: One balance whose accounting code has no party record, with enough history to tell a one-off from a long-standing gap.
properties:
  balanceId:
    type: string
    format: uuid
    description: Internal id of the balance snapshot.
  externalCode:
    type: string
    description: The accounting code, a **string**. Always populated even though there is no party id, and openable at `GET /api/v1/financial-records/{externalCode}`.
  placeholderName:
    type: string
    description: Server-rendered placeholder identity, e.g. «ناشناس - کد ۷۰۰».
  sourceEntity:
    $ref: #/components/schemas/ExternalEntity
  headlineStatus:
    $ref: #/components/schemas/BalanceStatus
  irrAmount:
    type: string
    description: Signed decimal **string** in Rial minor units.
  absIrrAmount:
    type: string
    description: The absolute value of `irrAmount` as a decimal **string** — the sort key, carried explicitly so the client need not re-derive it.
  hasMixedPosition:
    type: boolean
    description: Whether the position differs in sign across assets.
  invariantOk:
    type: boolean
    description: Whether the gold invariant held for this snapshot.
  firstSeenAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant this code was **first** seen in any balance snapshot — how long it has been unattributed.
  lastSeenAt:
    type: string
    format: date-time
    description: ISO-8601 UTC observation time of the latest snapshot for this code.
  snapshotCount:
    type: integer
    description: How many snapshots exist for this code. A code seen once is a different story from one seen 118 times.
required:
  - balanceId
  - externalCode
  - placeholderName
  - sourceEntity
  - headlineStatus
  - irrAmount
  - absIrrAmount
  - hasMixedPosition
  - invariantOk
  - firstSeenAt
  - lastSeenAt
  - snapshotCount

### OrphanSummary
type: object
description: Whole-report figures, not page-scoped. `codeRange` against `knownPartyCodeRange` is the evidence to take back to the accounting vendor.
properties:
  totalOrphans:
    type: integer
    description: Latest snapshots with no party record, across the whole book.
  totalBalances:
    type: integer
    description: Latest snapshots in total — the denominator of `orphanRatio`.
  orphanRatio:
    type: number
    description: `totalOrphans / totalBalances`, between 0 and 1, rounded to four decimal places. The same number exported as a service metric.
  distinctOrphanCodes:
    type: integer
    description: Distinct accounting codes among the orphans.
  codeRange:
    allOf:
      -
        $ref: #/components/schemas/CodeRange
    nullable: True
    description: The span of orphaned codes. `null` when there are none.
  knownPartyCodeRange:
    allOf:
      -
        $ref: #/components/schemas/CodeRange
    nullable: True
    description: The span of codes the party export actually delivered. Side by side with `codeRange` this pair states the vendor question as data. `null` when no party carries a numeric code.
  totalAbsIrrAmount:
    type: string
    description: Sum of `absIrrAmount` across every orphan, as a decimal **string** in Rial minor units — the money nobody can currently attribute.
required:
  - totalOrphans
  - totalBalances
  - orphanRatio
  - distinctOrphanCodes
  - codeRange
  - knownPartyCodeRange
  - totalAbsIrrAmount

### OrphanPage
type: object
description: The page descriptor for the orphan rows. It lives inside `data` because `meta` is a fixed contract with no room for this endpoint's domain summary.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total orphan rows — the same number as `summary.totalOrphans`.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when there are no orphans.
required:
  - page
  - pageSize
  - total
  - totalPages

### FinancialRecordDetailEnvelope
type: object
description: Success envelope around one financial record. Carries no `coverage`: a single record's snapshot is whole or absent, never a sample.
properties:
  data:
    $ref: #/components/schemas/FinancialRecordDetail
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedFinancialRecords
type: object
description: A page of the current book. `meta` may carry `coverage`, and does so whenever the underlying balance sync was partial.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/FinancialRecord
  meta:
    $ref: #/components/schemas/CoveragePaginationMeta
required:
  - data
  - meta

### PaginatedFinancialRecordHistory
type: object
description: A page of one code's snapshot history in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/FinancialRecordHistoryEntry
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### OrphanReportEnvelope
type: object
description: Success envelope around the orphan report. The page descriptor is inside `data`; `coverage` still arrives through `meta`.
properties:
  data:
    type: object
    properties:
      summary:
        $ref: #/components/schemas/OrphanSummary
      orphans:
        type: array
        description: This page of orphan rows, sorted by absolute Rial descending.
        items:
          $ref: #/components/schemas/OrphanBalanceRow
      page:
        $ref: #/components/schemas/OrphanPage
    required:
      - summary
      - orphans
      - page
  meta:
    $ref: #/components/schemas/CoverageResponseMeta
required:
  - data
  - meta


========================================================================
# Health and Metrics API (app: health, version 1.0.0)
========================================================================

Servers: http://localhost:3000

Operational endpoints: two orchestrator probes and one Prometheus scrape
target. None of them requires authentication, and none of them is part of the
panel's API surface — they exist for the platform that runs this service.

The two probes answer deliberately different questions. **Liveness** asks "is
this process alive?" and touches nothing external, because a liveness probe
that fails when Postgres blinks would have the orchestrator kill a healthy
process. **Readiness** asks "can this process serve traffic right now?" and
checks every dependency, so a pod that cannot reach Redis is taken out of the
load balancer instead of returning errors.

## Flow
1. The orchestrator polls `GET /api/v1/health/live` on a fixed schedule.
   A non-`200` means restart the process.
2. The orchestrator polls `GET /api/v1/health/ready`. A `503` means take this
   instance out of rotation but leave it running — it may recover.
3. Prometheus scrapes `GET /metrics` on a timer and stores the time series.
4. When readiness fails, `details[]` in the error body names the dependency
   that is down, so the alert carries a lead rather than just "ready: false".

## Security Notes
- All three endpoints are **unauthenticated**. A probe has no credentials to
  present, and an orchestrator that cannot reach readiness restarts a healthy
  process. Prometheus scrapes on a timer and carries no bearer token.
- All three are **exempt from rate limiting**. Probes run on a fixed schedule
  and must never be throttled into reporting the service unhealthy.
- Exposure is a network concern, not an application one: the metrics port is
  not published outside the cluster. Nothing here returns business data —
  liveness reports uptime and configuration names, readiness reports
  dependency status, and metrics reports counters and histograms.
- `GET /metrics` is mounted at the **root**, outside the `/api/v1` prefix, and
  outside the §9.1 envelope: the Prometheus text exposition format is its own
  content type and wrapping it in JSON would make it unscrapeable.
- The two health probes **do** use the standard envelope, so a failed
  readiness check carries the same `meta.requestId` as everything else and an
  operator can correlate it with the log lines that produced it.

## x-probe-semantics
description: Why the two probes check different things, from `src/modules/health/health.controller.ts` and its indicators.
liveness: Process-only, and deliberately so. It never touches the database, Redis or object storage, because a liveness probe that fails when Postgres is briefly unreachable would have the orchestrator kill a healthy process that would have recovered on its own. A non-answer means restart.
readiness: Checks database, redisCache, objectStorage and syncFreshness. A 503 means take the instance out of the load balancer but leave it running.
sync_freshness: Reports down when no successful sync has completed within 2x the configured SYNC_INTERVAL_CRON. A scheduler that has silently stopped firing breaks no request — every endpoint keeps answering with stale numbers — which is exactly why it needs a probe rather than a bug report. It reports up when sync is disabled by configuration and during the first interval after a fresh start.
failure_detail: Terminus reports a failing check by throwing, and the global error filter turns each failing indicator into a details[] entry. "ready: false" without a reason is a page at 3 a.m. with no lead.
no_throttle: All three routes skip rate limiting. Probes run on a fixed schedule and must never be throttled into reporting the service unhealthy.

## Operations

### GET /api/v1/health/live
operationId: getLiveness
auth: public
summary: Report that the process is alive.

Returns process-level facts only. It is the answer to "should this
container be restarted?".

**Notes:**
- **Unauthenticated** and exempt from rate limiting.
- **Never touches the database, Redis or object storage.** That is the
  whole point: a liveness probe that fails because Postgres is briefly
  unreachable would have the orchestrator kill a process that is working
  fine and would have recovered on its own.
- It therefore always returns `200` while the process is running. If it
  does not answer at all, the process is wedged and should be restarted.
- `status` is the literal `"ok"` — there is no failing value for this
  endpoint.
- `uptimeSeconds` is whole seconds since the process started, useful for
  spotting a crash loop.
- `nodeEnv` and `timezone` are echoed so an operator can confirm which
  configuration a running instance actually booted with.
- Side effects: none.
responses:
  200: LivenessEnvelope
  500: ErrorEnvelope

### GET /api/v1/health/ready
operationId: getReadiness
auth: public
summary: Report whether every dependency is reachable.

Checks PostgreSQL, Redis, object storage and sync freshness, and answers
"should this instance receive traffic?".

**Notes:**
- **Unauthenticated** and exempt from rate limiting.
- Returns `200` when every indicator is `up`, and **`503`** when any is
  down. A `503` means take the instance out of rotation but leave it
  running; it may recover without a restart.
- Four indicators are checked: `database`, `redisCache`, `objectStorage`
  and `syncFreshness`.
- **`syncFreshness` is the unusual one.** It reports down when no
  successful sync has completed within twice the configured sync interval.
  A scheduler that has silently stopped firing breaks no request — every
  endpoint keeps answering, with last Tuesday's numbers — so it needs a
  probe of its own rather than a bug report from an accountant.
- `syncFreshness` reports `up` when synchronisation is **disabled by
  configuration**, and during the first interval after a fresh start.
  Neither is a fault.
- On failure the error body's `details[]` names **each** failing
  indicator with its status and message, so an alert at 3 a.m. carries a
  lead rather than just "ready: false".
- The response uses the standard envelope, so a failed check carries the
  same `requestId` as the log lines that produced it.
- Side effects: none. The checks are reads.
responses:
  200: ReadinessEnvelope
  500: ErrorEnvelope
  503: ErrorEnvelope

### GET /metrics
operationId: getMetrics
auth: public
summary: Expose Prometheus metrics.

Returns the process's metrics in the Prometheus text exposition format.

**Notes:**
- **Unauthenticated** and exempt from rate limiting: Prometheus scrapes on
  a timer and carries no bearer token. Exposure is controlled at the
  network layer — the port is not published outside the cluster.
- Mounted at the **root**, deliberately outside the `/api/v1` prefix, so a
  scrape configuration does not have to know the API's versioning.
- **Not the §9.1 envelope.** The body is Prometheus text with its own
  content type; wrapping it in JSON would make it unscrapeable.
- Carries counters and histograms only — request timings, per-status
  payment counts, locked matching groups, the orphan ratio. No business
  record and no personal data is exposed here.
- Side effects: none.
responses:
  200: string
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### HealthIndicator
type: object
description: One dependency's verdict. Extra keys beyond `status` vary by indicator — a `message` explaining why, a version, a latency.
properties:
  status:
    type: string
    enum:
      - up
      - down
    description: `up` when the dependency answered; `down` when it did not.
  message:
    type: string
    description: Why the indicator reports what it does. Present on a failure, and on `syncFreshness` when it is `up` for a non-obvious reason such as synchronisation being disabled.
required:
  - status
additionalProperties: True

### Liveness
type: object
description: Process-level facts only. Nothing here consults an external dependency.
properties:
  status:
    type: string
    enum:
      - ok
    description: Always `"ok"`. This endpoint has no failing value.
  uptimeSeconds:
    type: integer
    description: Whole seconds since the process started. A value that keeps resetting is a crash loop.
  nodeEnv:
    type: string
    description: The environment the process booted with, e.g. `"production"`. Echoed so an operator can confirm which configuration is actually running.
  timezone:
    type: string
    description: The configured application timezone, e.g. `"Asia/Tehran"`.
required:
  - status
  - uptimeSeconds
  - nodeEnv
  - timezone

### Readiness
type: object
description: The aggregate readiness verdict plus each indicator's own result. `info` holds the indicators that passed, `error` those that failed, and `details` holds all of them.
properties:
  status:
    type: string
    enum:
      - ok
    description: `"ok"` on a `200`. A failing check does not return this shape at all — it returns `503` with the standard error envelope.
  info:
    type: object
    description: The indicators that reported `up`, keyed by indicator name.
    additionalProperties:
      $ref: #/components/schemas/HealthIndicator
  error:
    type: object
    description: The indicators that reported `down`. Empty on a `200`.
    additionalProperties:
      $ref: #/components/schemas/HealthIndicator
  details:
    type: object
    description: Every indicator, passing or failing — the union of `info` and `error`. The four are `database`, `redisCache`, `objectStorage` and `syncFreshness`.
    additionalProperties:
      $ref: #/components/schemas/HealthIndicator
required:
  - status
  - info
  - error
  - details

### LivenessEnvelope
type: object
description: Success envelope around the liveness payload.
properties:
  data:
    $ref: #/components/schemas/Liveness
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### ReadinessEnvelope
type: object
description: Success envelope around the readiness result.
properties:
  data:
    $ref: #/components/schemas/Readiness
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta


========================================================================
# Ingestion API (app: ingestion, version 1.0.0)
========================================================================

Servers: http://localhost:3000

«همگام‌سازی» — the pipeline that pulls parties and balances from the external
accounting system, and the operator surface for watching it.

Three ideas run through every endpoint here:

**Raw payloads are kept verbatim.** Every record the vendor returns is stored
exactly as received — no key renaming, no coercion, no cleanup — and is never
modified after insert. That table is the replay source of truth: it is what
makes it possible to fix a transformer and re-derive the canonical data
without asking the vendor for the same export again.

**A record that cannot be transformed is quarantined, not dropped.** A value
in the wrong field, an asset nobody has classified, a gold total that does not
reconcile — each becomes a quarantine row with both sentences, the offending
field and value, and a typed menu of the actions that make sense for *that*
kind of problem.

**Nothing runs concurrently per entity.** A per-entity advisory lock means a
scheduled sync and a manual one cannot interleave, and neither can interleave
with a bulk replay.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. A scheduled job syncs each entity on its own cron. An operator can force
   one with `POST /api/v1/sync/runs`, which returns the finished run.
3. Operator watches the history with `GET /api/v1/sync/runs`: how many records
   were fetched, inserted, updated, quarantined and failed, and whether the
   coverage was complete.
4. When a run quarantines something, the operator works the queue at
   `GET /api/v1/sync/quarantine`, filtering by reason and open/resolved.
5. Operator opens one row with `GET /api/v1/sync/quarantine/{id}` and reads
   its `availableActions` — the menu that row's reason actually permits.
6. Operator inspects the underlying vendor payload with
   `GET /api/v1/sync/raw-records/{id}` when the quarantine message is not
   enough.
7. Resolving a quarantine row, reprocessing a raw record and bulk-replaying
   are administrator actions — see the Admin Ingestion API.

## Security Notes
- Every endpoint requires a valid Bearer access token. Listing runs, reading
  the quarantine queue and polling a replay need `sync:read`; forcing a run
  needs `sync:trigger`; reading **raw vendor payloads** needs the separate
  `sync:read-raw`.
- `sync:read-raw` is deliberately its own permission, and the seeded `VIEWER`
  role does **not** hold it. A raw payload is the vendor's data verbatim,
  before any masking or projection this API applies elsewhere.
- Resolving quarantine, reprocessing and replaying all need `sync:reprocess`,
  which only `ADMIN` holds — those endpoints are in the Admin Ingestion API.
- The three entities are `PARTY_LIST`, `BALANCE_DEBTOR` and
  `BALANCE_CREDITOR`. Each has its own advisory lock and its own schedule.
- A raw record's `payload` is returned **exactly as the vendor sent it**. Do
  not assume a type for any field: the same party `Code` arrives as a number
  in the party list and as a string in the balance files, which is precisely
  why this API normalises it to a string everywhere else.
- Date-window filters use an **inclusive `from`** and an **exclusive `to`**.
  That is deliberate and consistent across this module.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## Concurrency
- Each entity has one advisory lock. A sync run, a bulk replay and a scheduled
  job all take it, so no two can touch the same entity at once.
- A **manual sync** requested while a run for that entity is in progress is
  **not queued behind the lock**. It is skipped and recorded as a `CANCELLED`
  run with an explanatory `errorSummary` — so the attempt is visible in the
  history rather than silently absorbed.
- A **replay** requested while the lock is held is refused outright with
  `409 SYNC_ALREADY_RUNNING`. Nothing is enqueued.
- The two behaviours differ on purpose: a skipped sync costs nothing and is
  worth recording; a replay is a long job whose selection would be stale by
  the time a lock freed up.

## x-quarantine-actions
description: Which resolution actions each quarantine reason offers, from `src/modules/ingestion/quarantine/quarantine-resolution.ts`. The row publishes its own menu as availableActions; this table is why.
by_reason:
  -
    SUSPECTED_MISPLACED_FIELD: PROMOTE_TO_PHONE turns the misplaced value into a MANUAL phone on the party; DISCARD closes it when the value was not a phone number either and requires a note.
  -
    UNKNOWN_ASSET: CONFIRM_ASSET names the auto-registered asset and clears its pending-review flag; RECLASSIFY_ASSET fixes a wrong kind/metal — silver registered as gold — and re-runs the invariant check.
  -
    INVARIANT_VIOLATION: ACKNOWLEDGE records that it was seen when the repair is not this operator's to make; REPROCESS replays the row through the current transformer.
  -
    ORPHAN_REFERENCE: ACKNOWLEDGE only. The repair arrives with a later party sync, not from this endpoint — which is why no other action is offered.
  -
    SCHEMA_VIOLATION / UNPARSEABLE_NUMBER / AMBIGUOUS_NAME: REPROCESS after a transformer fix, or RESOLVE_WITH_NOTE to close a record that cannot be transformed at all.
note_required: DISCARD, ACKNOWLEDGE and RESOLVE_WITH_NOTE all require a note.
never_deleted: Resolving stamps resolvedAt, resolvedByOperatorId and resolutionNote on the row and audits it as sync.quarantine.resolved. Nothing is removed from the table.

## x-ingestion-concurrency
description: The per-entity advisory lock and how each caller reacts to finding it held, from `src/modules/ingestion/sync/` and the replay service.
lock: One PostgreSQL advisory lock per entity. Scheduled syncs, manual syncs and bulk replays all take it, so no two can touch the same entity at once.
manual_sync_when_held: Not queued. The run is skipped and recorded as CANCELLED with an explanatory errorSummary, and the endpoint still answers 202 with that cancelled row — so the attempt is visible in the history rather than silently absorbed.
replay_when_held: Refused outright with 409 SYNC_ALREADY_RUNNING. Nothing is enqueued, because a long job's selection would be stale by the time a lock freed up.
why_they_differ: A skipped sync costs nothing and is worth recording; a queued replay would run against a selection that no longer describes reality.

## Operations

### GET /api/v1/sync/runs
operationId: listSyncRuns
auth: bearer
summary: List synchronisation runs.

Returns a page of run history with the full record counts for each.

**Notes:**
- Requires `sync:read`.
- Filter by `entity` and `status`. Both are strict enums.
- `status` is `RUNNING`, `SUCCESS`, `PARTIAL`, `FAILED` or `CANCELLED`.
  **`CANCELLED` is the interesting one**: it means a manual run was asked
  for while another run held that entity's lock, so it was skipped rather
  than queued. `errorSummary` explains it.
- `PARTIAL` means the run completed but some records failed or were
  quarantined — a real outcome, not a failure.
- The record counters (`recordsFetched`, `recordsInserted`,
  `recordsUpdated`, `recordsUnchanged`, `recordsQuarantined`,
  `recordsFailed`) account for everything the run saw.
  `recordsUnchanged` counts payloads whose hash matched an existing row,
  which the pipeline skips.
- `orphanCount` is tracked per run so a sudden jump in balances with no
  party record is visible as a number rather than as a support ticket.
- `coverageComplete` is `null` for `PARTY_LIST` — that entity has no
  coverage question — and `false` for balance entities whose upstream
  endpoint is a Top-N leaderboard. It is what the Financial Records API's
  `meta.coverage` is derived from.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100.
- Side effects: none.
responses:
  200: PaginatedSyncRuns
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/sync/runs
operationId: triggerSyncRun
auth: bearer
summary: Trigger a synchronisation run for one entity.

Pulls one entity from the accounting system now, and returns the finished
run row.

**Notes:**
- Requires `sync:trigger`.
- Returns **`202 Accepted`** — but the work is **synchronous**: the
  response is the *finished* `SyncRun`, not a job handle. There is nothing
  to poll.
- `entity` is required and there is no "all" option; call it once per
  entity.
- **If a run for that entity is already in progress, this one is not
  queued behind the lock.** It is skipped and recorded as a `CANCELLED`
  run with an explanatory `errorSummary` — and this endpoint still returns
  `202` with that cancelled row. Check `status` on the response rather
  than assuming success.
- `trigger` on the resulting row is `MANUAL`, and
  `triggeredByOperatorId` names the caller, which is how a forced run is
  told apart from a scheduled one in the history.
- A `FAILED` run carries `errorSummary` and `errorDetail`; the raw
  payloads it did manage to fetch are still stored and replayable.
- Side effects on a run that actually executes: the vendor endpoint is
  called, every returned record is stored verbatim as a raw record,
  transformable records are applied to the canonical tables, untransformable
  ones are quarantined, and the run row records the counts. A completed
  balance sync may also trigger a matching rebuild when
  `autoRecomputeOnSync` is enabled.
request body: TriggerSyncRunRequest
responses:
  202: SyncRunEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope
  503: ErrorEnvelope

### GET /api/v1/sync/raw-records
operationId: listRawRecords
auth: bearer
summary: List raw external records.

Returns a page of the vendor's payloads exactly as they were received.

**Notes:**
- Requires **`sync:read-raw`**, not `sync:read`. The seeded `VIEWER` role
  holds ordinary sync visibility but **not** this: a raw payload is the
  vendor's data before any masking or projection this API applies
  elsewhere.
- Every `payload` here is verbatim — no key renaming, no coercion, no
  cleanup — and is **never modified after insert**. This table is the
  replay source of truth.
- `externalCode` is stored as a **string** even though the vendor sends it
  as a number in the party list and a string in the balance files. That
  discrepancy is the reason the rest of this API treats it as a string
  everywhere.
- `state` tells you what happened to the record: `PENDING` (not yet
  transformed), `PROCESSED`, `FAILED`, `SKIPPED_UNCHANGED` (its hash
  matched an existing row, so nothing needed doing) or `QUARANTINED`.
- `payloadHash` is what makes `SKIPPED_UNCHANGED` and idempotent replay
  possible: an identical re-fetch is recognised rather than re-applied.
- `targetTable` and `targetId` name the canonical row this record produced,
  when it produced one.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100.
- Side effects: none.
responses:
  200: PaginatedRawRecords
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/sync/raw-records/{id}
operationId: getRawRecord
auth: bearer
summary: Retrieve one raw external record.

Returns a single vendor payload verbatim, with its processing state.

**Notes:**
- Requires **`sync:read-raw`**.
- This is the endpoint to open when a quarantine message is not enough:
  it shows exactly what the vendor sent, which is usually where the answer
  is.
- `payload` is untouched. Do not assume a type for any field — the same
  logical value can arrive as a number in one entity and a string in
  another.
- `transformAttempts` counts how many times the pipeline has tried this
  record, including reprocess attempts. `transformError` carries the last
  failure's message.
- The record is **immutable**: there is no `PATCH` or `DELETE` here.
  Reprocessing it is an administrator action in the Admin Ingestion API,
  and even that changes only the processing state, never the payload.
- Side effects: none.
responses:
  200: RawRecordEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/sync/quarantine
operationId: listQuarantine
auth: bearer
summary: List quarantined records.

Returns a page of the records the pipeline could not transform, each with
the actions its reason permits.

**Notes:**
- Requires `sync:read`. Resolving a row needs `sync:reprocess` — see the
  Admin Ingestion API.
- `resolved` filters the queue: `true` for resolved rows only, `false` for
  open ones only, omitted for both. Working the queue means
  `?resolved=false`.
- `from` is an **inclusive** lower bound on `createdAt` and `to` is an
  **exclusive** upper bound.
- `entity` filters through the linked raw record, which answers "did the
  balance sync break, or parties?".
- Every row carries **both sentences** (`message`, `messageFa`), the
  `fieldPath` and `observedValue` that caused it, and the linked raw
  record so the vendor payload is one click away.
- **`availableActions` is the typed menu for that row's reason**, and it is
  the contract: an action absent from the list is refused. A misplaced
  field offers `PROMOTE_TO_PHONE` and `DISCARD`; an unknown asset offers
  `CONFIRM_ASSET` and `RECLASSIFY_ASSET`; a broken invariant offers
  `ACKNOWLEDGE` and `REPROCESS`; an orphan reference offers `ACKNOWLEDGE`
  alone, because its repair arrives with a later party sync rather than
  from an operator; a schema violation offers `REPROCESS` and
  `RESOLVE_WITH_NOTE`.
- Nothing is ever deleted from quarantine. A resolved row keeps
  `resolvedAt`, `resolvedByOperatorId` and `resolutionNote` and stays in
  the table.
- `detail` is free-form context whose shape varies by reason.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100.
- Side effects: none.
responses:
  200: PaginatedQuarantine
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/sync/quarantine/{id}
operationId: getQuarantineEntry
auth: bearer
summary: Retrieve one quarantined record.

Returns a single quarantine row with its reason, its context and the
actions it permits.

**Notes:**
- Requires `sync:read`.
- `availableActions` is the menu **this row's reason** offers. Sending an
  action outside it to the resolve endpoint is `400 VALIDATION_FAILED`, so
  read this list rather than hard-coding a set of buttons.
- A resolved row still returns `200`, with `resolvedAt`,
  `resolvedByOperatorId` and `resolutionNote` populated. Rows are never
  deleted.
- `rawRecord` is the reduced reference; open
  `GET /api/v1/sync/raw-records/{rawRecordId}` for the vendor payload
  itself — which needs the separate `sync:read-raw` permission.
- `observedValue` is the offending value as the vendor sent it, and
  `fieldPath` locates it inside the payload.
- Side effects: none.
responses:
  200: QuarantineEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/sync/replay/{jobId}
operationId: getReplayStatus
auth: bearer
summary: Poll a bulk replay job's progress.

Returns the queue state, progress counters and — once it finishes — the
result of a bulk replay.

**Notes:**
- Requires `sync:read`. **Starting** a replay needs `sync:reprocess`,
  which only `ADMIN` holds — see the Admin Ingestion API.
- This is the endpoint to poll after
  `POST /api/v1/sync/replay` returns `202` with a `jobId`.
- The response is the job's state as the queue reports it: its lifecycle
  state, a `progress` object counting completed records against the total
  the selection matched, and the final result once the job has finished.
- The shape of `progress` and `returnvalue` follows the queue's own
  representation and is not a fixed contract of this API — read it for
  display, not for branching.
- A `jobId` the queue has already evicted returns whatever the queue knows,
  which may be nothing. Job retention is a queue-configuration matter.
- Side effects: none.
responses:
  200: ReplayStatusEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PaginationMeta
type: object
description: The §9.1 success envelope's `meta` for an offset-paginated list. `totalPages` is derived by the envelope interceptor, never by a handler.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total rows matching the filter across all pages.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when `total` is 0.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - page
  - pageSize
  - total
  - totalPages
  - requestId
  - timestamp

### ExternalEntity
type: string
enum:
  - PARTY_LIST
  - BALANCE_DEBTOR
  - BALANCE_CREDITOR
description: Which upstream export a run or record belongs to. Each has its own schedule and its own advisory lock, so the three never block one another.

### SyncTrigger
type: string
enum:
  - SCHEDULED
  - MANUAL
description: What started the run. `MANUAL` runs also carry `triggeredByOperatorId`.

### SyncStatus
type: string
enum:
  - RUNNING
  - SUCCESS
  - PARTIAL
  - FAILED
  - CANCELLED
description: `PARTIAL` — the run completed but some records were quarantined or failed; a real outcome, not a failure. `CANCELLED` — a run was requested while that entity's lock was held, so it was skipped rather than queued; the reason is in `errorSummary`.

### RawState
type: string
enum:
  - PENDING
  - PROCESSED
  - FAILED
  - SKIPPED_UNCHANGED
  - QUARANTINED
description: What happened to a raw record. `SKIPPED_UNCHANGED` means its payload hash matched an existing row, so nothing needed doing — that is the mechanism that makes replay idempotent.

### QuarantineReason
type: string
enum:
  - SCHEMA_VIOLATION
  - UNKNOWN_ASSET
  - INVARIANT_VIOLATION
  - SUSPECTED_MISPLACED_FIELD
  - UNPARSEABLE_NUMBER
  - ORPHAN_REFERENCE
  - AMBIGUOUS_NAME
description: Why the record could not be transformed. The reason determines which actions the row offers — read `availableActions` rather than inferring them.

### QuarantineResolutionAction
type: string
enum:
  - PROMOTE_TO_PHONE
  - DISCARD
  - CONFIRM_ASSET
  - RECLASSIFY_ASSET
  - ACKNOWLEDGE
  - REPROCESS
  - RESOLVE_WITH_NOTE
description: One way of closing a quarantine row. Which of these a given row permits depends on its `reason` and is published as `availableActions`.

### SyncRun
type: object
description: One pull from the external accounting system, with everything that happened to the records it fetched.
properties:
  id:
    type: string
    format: uuid
    description: The run's internal id (UUID v7).
  entity:
    $ref: #/components/schemas/ExternalEntity
  trigger:
    $ref: #/components/schemas/SyncTrigger
  status:
    $ref: #/components/schemas/SyncStatus
  startedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the run began.
  finishedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant it ended; `null` while `RUNNING`.
  durationMs:
    type: integer
    nullable: True
    description: Wall-clock milliseconds; `null` while `RUNNING`.
  endpoint:
    type: string
    description: The upstream path that was called, recorded so a vendor change is traceable.
  httpStatus:
    type: integer
    nullable: True
    description: The upstream HTTP status. `null` when the call never happened — a `CANCELLED` run, or a failure before the request.
  recordsFetched:
    type: integer
    description: Records the vendor returned.
  recordsInserted:
    type: integer
    description: Canonical rows created from them.
  recordsUpdated:
    type: integer
    description: Canonical rows changed.
  recordsUnchanged:
    type: integer
    description: Records whose payload hash matched an existing row, so nothing needed doing.
  recordsQuarantined:
    type: integer
    description: Records held for a human. Each has a row in the quarantine queue.
  recordsFailed:
    type: integer
    description: Records that errored outside the quarantine path.
  orphanCount:
    type: integer
    description: Balances in this run whose external code has no party record. Tracked per run so a sudden jump is visible as a number rather than as a support ticket.
  coverageComplete:
    type: boolean
    nullable: True
    description: Whether this run saw the whole dataset. **`null` for `PARTY_LIST`** — that entity has no coverage question — and `false` for balance entities whose upstream endpoint returns a leaderboard. This is what the Financial Records API's `meta.coverage` is derived from.
  coverageReason:
    type: string
    nullable: True
    description: Why coverage was partial, e.g. `"TOP_N_ENDPOINT"`. `null` when `coverageComplete` is `true` or `null`.
  errorSummary:
    type: string
    nullable: True
    description: One sentence explaining a `FAILED` or `CANCELLED` run. For a `CANCELLED` run this is where "another run held the lock" is recorded.
  errorDetail:
    type: object
    nullable: True
    description: Structured context for a failure. Shape varies by cause.
    additionalProperties: True
  triggeredByOperatorId:
    type: string
    format: uuid
    nullable: True
    description: `null` for a `SCHEDULED` run; the operator's id for a `MANUAL` one.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant of the run row.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write to the run row.
required:
  - id
  - entity
  - trigger
  - status
  - startedAt
  - finishedAt
  - durationMs
  - endpoint
  - httpStatus
  - recordsFetched
  - recordsInserted
  - recordsUpdated
  - recordsUnchanged
  - recordsQuarantined
  - recordsFailed
  - orphanCount
  - coverageComplete
  - coverageReason
  - errorSummary
  - triggeredByOperatorId
  - createdAt
  - updatedAt

### RawRecord
type: object
description: One vendor payload, stored **verbatim** and never modified after insert. This table is the replay source of truth.
properties:
  id:
    type: string
    format: uuid
    description: The raw record's internal id (UUID v7).
  syncRunId:
    type: string
    format: uuid
    description: The run that fetched it, readable at `GET /api/v1/sync/runs`.
  entity:
    $ref: #/components/schemas/ExternalEntity
  externalCode:
    type: string
    nullable: True
    description: The vendor's `Code` for this record, coerced to a **string** on storage — the vendor sends it as a number in the party list and as a string in the balance files. `null` when the record carries none.
  payload:
    type: object
    description: The single record **exactly as received**: no key renaming, no coercion, no cleanup. Do not assume a type for any field. Never modified after insert.
    additionalProperties: True
  payloadHash:
    type: string
    description: SHA-256 of the canonicalised payload. What makes `SKIPPED_UNCHANGED` and idempotent replay possible: an identical re-fetch is recognised rather than re-applied.
  fetchedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the record was received. The bound the replay window uses.
  state:
    $ref: #/components/schemas/RawState
  processedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant the record was transformed; `null` if it never was.
  transformAttempts:
    type: integer
    description: How many times the pipeline has tried this record, including reprocess attempts. A dry-run reprocess does **not** increment it.
  transformError:
    type: string
    nullable: True
    description: The last failure's message; `null` when the record has never failed.
  targetTable:
    type: string
    nullable: True
    description: The canonical table this record produced a row in, e.g. `"party_balances"`.
  targetId:
    type: string
    nullable: True
    description: The canonical row's id, when the record produced one.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write to the record's **processing state**. The payload itself is never rewritten.
required:
  - id
  - syncRunId
  - entity
  - externalCode
  - payload
  - payloadHash
  - fetchedAt
  - state
  - processedAt
  - transformAttempts
  - transformError
  - targetTable
  - targetId
  - createdAt
  - updatedAt

### QuarantineRawRecordRef
type: object
description: The reduced reference to the raw record behind a quarantine row. Open the full payload at `GET /api/v1/sync/raw-records/{id}`, which needs `sync:read-raw`.
properties:
  id:
    type: string
    format: uuid
    description: The raw record's id.
  entity:
    $ref: #/components/schemas/ExternalEntity
  externalCode:
    type: string
    nullable: True
    description: The vendor's code for the record, as a **string**.
  state:
    $ref: #/components/schemas/RawState
  fetchedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the record was received.
  syncRunId:
    type: string
    format: uuid
    description: The run that fetched it.
required:
  - id
  - entity
  - externalCode
  - state
  - fetchedAt
  - syncRunId

### Quarantine
type: object
description: A record the pipeline could not transform, held for a human. Never deleted — resolving stamps it rather than removing it.
properties:
  id:
    type: string
    format: uuid
    description: The quarantine row's internal id (UUID v7).
  reason:
    $ref: #/components/schemas/QuarantineReason
  fieldPath:
    type: string
    nullable: True
    description: Where in the payload the problem is, e.g. `"City"` or `"details[3].Name"`. `null` when the problem is the record as a whole.
  observedValue:
    type: string
    nullable: True
    description: The offending value as the vendor sent it. `null` when there is no single value.
  message:
    type: string
    description: English sentence describing the problem.
  messageFa:
    type: string
    description: The same sentence in Persian, for the panel.
  detail:
    type: object
    nullable: True
    description: Free-form context whose shape varies by `reason` — a normalised phone number for a misplaced field, the auto-registered asset's id for an unknown asset.
    additionalProperties: True
  rawRecordId:
    type: string
    format: uuid
    description: The raw record this row was raised from.
  rawRecord:
    $ref: #/components/schemas/QuarantineRawRecordRef
  resolvedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant the row was resolved; `null` while it is open.
  resolvedByOperatorId:
    type: string
    format: uuid
    nullable: True
    description: Who resolved it; `null` while it is open.
  resolutionNote:
    type: string
    nullable: True
    description: The note supplied at resolution. Mandatory for `DISCARD`, `ACKNOWLEDGE` and `RESOLVE_WITH_NOTE`; `null` for the actions that do not require one.
  availableActions:
    type: array
    description: **The typed menu this row's `reason` permits**, and the contract for resolving it: an action absent from this list is refused with `400`. Read it rather than hard-coding buttons.
    items:
      $ref: #/components/schemas/QuarantineResolutionAction
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the row was raised. What the `from`/`to` filters bound.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write.
required:
  - id
  - reason
  - fieldPath
  - observedValue
  - message
  - messageFa
  - rawRecordId
  - rawRecord
  - resolvedAt
  - resolvedByOperatorId
  - resolutionNote
  - availableActions
  - createdAt
  - updatedAt

### ReplayStatus
type: object
description: A bulk replay job as the queue reports it. The shape follows the queue's own representation — read it for display, not for branching.
properties:
  id:
    type: string
    description: The queue job id, echoed from the path.
  name:
    type: string
    description: The job type in the queue.
  state:
    type: string
    description: The queue's lifecycle state, e.g. `waiting`, `active`, `completed`, `failed`.
  progress:
    type: object
    nullable: True
    description: Counters the job publishes as it runs. `total` is the number of raw rows the selection matched when the job started — the denominator.
    properties:
      total:
        type: integer
        description: Raw records the selection matched.
      completed:
        type: integer
        description: Records replayed so far.
      quarantined:
        type: integer
        description: Records that were quarantined during the replay.
      failed:
        type: integer
        description: Records that errored outside the quarantine path.
    additionalProperties: True
  returnvalue:
    type: object
    nullable: True
    description: The job's final result; `null` until it finishes.
    additionalProperties: True
  failedReason:
    type: string
    nullable: True
    description: Why the job failed, when it did; `null` otherwise.
required:
  - id
  - state
additionalProperties: True

### SyncRunEnvelope
type: object
description: Success envelope around one synchronisation run.
properties:
  data:
    $ref: #/components/schemas/SyncRun
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedSyncRuns
type: object
description: A page of synchronisation runs in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/SyncRun
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### RawRecordEnvelope
type: object
description: Success envelope around one raw vendor payload.
properties:
  data:
    $ref: #/components/schemas/RawRecord
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedRawRecords
type: object
description: A page of raw vendor payloads in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/RawRecord
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### QuarantineEnvelope
type: object
description: Success envelope around one quarantine row.
properties:
  data:
    $ref: #/components/schemas/Quarantine
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedQuarantine
type: object
description: A page of quarantined records in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/Quarantine
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### ReplayStatusEnvelope
type: object
description: Success envelope around a replay job's state.
properties:
  data:
    $ref: #/components/schemas/ReplayStatus
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### TriggerSyncRunRequest
type: object
description: Which entity to pull. There is no "all" option: each entity has its own advisory lock, so a combined run would hold all three and block every scheduled sync for its whole duration.
properties:
  entity:
    $ref: #/components/schemas/ExternalEntity
required:
  - entity


========================================================================
# Admin Ingestion API (app: ingestion-admin, version 1.0.0)
========================================================================

Servers: http://localhost:3000

The three ingestion operations that **change canonical data by re-deriving it
from stored vendor payloads**: resolving a quarantine row, reprocessing a
single raw record, and bulk-replaying a selection of them.

Watching the pipeline — listing runs, reading the quarantine queue, inspecting
raw payloads, polling a replay — is in the Ingestion API and is open to any
operator with `sync:read` or `sync:read-raw`. These three are different: each
one takes data that has already been stored and re-applies it to the live
tables under the *current* transformer. That is exactly the operation you want
when a transformer bug has been fixed, and exactly the operation you do not
want anyone running by accident.

## Authentication
Every endpoint requires a Bearer access token whose operator holds
`sync:reprocess`. In the seeded role set only **ADMIN** holds it —
`ACCOUNTANT` is explicitly withheld it, and `VIEWER` holds neither this nor
`sync:read-raw`.

1. Obtain a token pair from `POST /api/v1/auth/login` — see the
   Authentication API. There is no separate staff login endpoint.
2. Send `Authorization: Bearer <accessToken>` on every request.
3. Refresh the pair through the same `POST /api/v1/auth/refresh`.

`sync:reprocess` is not a *dangerous* permission, so no password re-entry is
required. A missing, malformed or expired token returns `401`; a valid token
whose operator lacks the permission returns `403 PERMISSION_DENIED`, audited
with `outcome: DENIED`.

## Conventions
- Success bodies are the §9.1 envelope `{ data, meta }`.
- **The raw payload is never touched by any of these.** A reprocess or replay
  changes a record's *processing state* and the canonical rows it produces —
  never the stored vendor data, which is the replay source of truth.
- Date-window filters use an **inclusive `from`** and an **exclusive `to`**,
  consistently with the rest of the module.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## Concurrency
- Each entity has one advisory lock, shared by scheduled syncs, manual syncs
  and replays.
- A **replay** requested while that lock is held is refused outright with
  `409 SYNC_ALREADY_RUNNING` — nothing is enqueued. A long job's selection
  would be stale by the time a lock freed up.
- A replay is scoped to **one entity** and has no "all" option, deliberately:
  a combined replay would have to hold all three locks at once and would block
  every scheduled sync for its whole duration. Three explicit calls make that
  cost visible instead of hiding it behind a convenience.
- Single-record reprocessing does not take the lock and can run at any time.

## Dry runs
`POST /api/v1/sync/raw-records/{id}/reprocess?dryRun=true` executes the
**identical write path** inside a transaction that is then rolled back, and
returns the before/after diff of the canonical state instead of applying it.
Nothing is written — not the canonical rows, not `transformAttempts`, not an
audit row. It is the safe way to answer "what would this actually do?" before
committing to a bulk replay.

## x-quarantine-actions
description: Which resolution actions each reason offers, from `src/modules/ingestion/quarantine/quarantine-resolution.ts`. The row publishes its own menu as availableActions; the service — not the schema — enforces it, so the table exists in exactly one place.
by_reason:
  -
    SUSPECTED_MISPLACED_FIELD: PROMOTE_TO_PHONE turns the misplaced value into a MANUAL phone on the party; DISCARD closes it when the value was not a phone number either.
  -
    UNKNOWN_ASSET: CONFIRM_ASSET names the auto-registered asset and clears its pending-review flag; RECLASSIFY_ASSET fixes a wrong kind/metal and re-runs the gold invariant, returning the report as invariantRecheck.
  -
    INVARIANT_VIOLATION: ACKNOWLEDGE records that it was seen when the repair is not this operator's to make; REPROCESS replays the raw row.
  -
    ORPHAN_REFERENCE: ACKNOWLEDGE only. The repair arrives with a later party sync, not from this endpoint — which is why nothing else is offered.
  -
    SCHEMA_VIOLATION / UNPARSEABLE_NUMBER / AMBIGUOUS_NAME: REPROCESS after a transformer fix, or RESOLVE_WITH_NOTE to close a record that cannot be transformed at all.
note_required: DISCARD, ACKNOWLEDGE and RESOLVE_WITH_NOTE all require a note.
never_deleted: Resolving stamps resolvedAt, resolvedByOperatorId and resolutionNote and audits sync.quarantine.resolved. Nothing leaves the table.

## x-replay-safety
description: What protects a bulk replay from doing damage, from `src/modules/ingestion/sync/replay.service.ts` and the reprocess service.
guarantees:
  -
    payload_immutable: A replay re-derives canonical rows from stored payloads. It never modifies a payload, and never re-contacts the vendor.
  -
    idempotent: Replay is idempotent through the payload hash: a record that still produces the same canonical result is recognised rather than re-applied.
  -
    one_entity_at_a_time: entity is required and has no "all" option, because the advisory lock is per-entity and a combined replay would block every scheduled sync for its whole duration.
  -
    refused_not_queued: A replay requested while the lock is held is 409 SYNC_ALREADY_RUNNING and nothing is enqueued — a queued job's selection would be stale by the time a lock freed up.
  -
    bounded: limit caps the selection at 5,000 records, and matchedRecords reports what the selection actually matched at enqueue time.
  -
    dry_run_first: POST /api/v1/sync/raw-records/{id}/reprocess?dryRun=true runs the identical write path inside a rolled-back transaction and returns the canonical diff, writing nothing at all — not even transformAttempts. It is the way to check a transformer fix before replaying in bulk.

## Operations

### POST /api/v1/sync/quarantine/{id}/resolve
operationId: adminResolveQuarantine
auth: bearer
summary: Resolve a quarantined record.

Closes one quarantine row through an action its reason permits, applying
whatever repair that action implies.

**Notes:**
- Requires `sync:reprocess`, held only by `ADMIN` in the seeded roles.
- **The action must be one the row's `reason` offers.** Read
  `availableActions` from `GET /api/v1/sync/quarantine/{id}`; anything
  else is `400 VALIDATION_FAILED`. The schema checks only that the action
  is a *known* one — whether it is *legal for this row* is decided
  against the row itself, so the reason-to-action table lives in exactly
  one place.
- The menus: `PROMOTE_TO_PHONE` / `DISCARD` for a misplaced field;
  `CONFIRM_ASSET` / `RECLASSIFY_ASSET` for an unknown asset;
  `ACKNOWLEDGE` / `REPROCESS` for a broken gold invariant; `ACKNOWLEDGE`
  **alone** for an orphan reference — whose repair arrives with a later
  party sync, not from this endpoint; and `REPROCESS` /
  `RESOLVE_WITH_NOTE` for the schema family.
- **`DISCARD`, `ACKNOWLEDGE` and `RESOLVE_WITH_NOTE` require a `note`.**
  The other actions do something visible; these three only record a
  judgement, so the judgement has to be written down.
- `CONFIRM_ASSET` and `RECLASSIFY_ASSET` take an `asset` payload. What it
  must contain depends on the situation and is decided by the server after
  a database read: naming an already-registered asset needs only `nameEn`,
  while registering an unclassifiable label needs `kind`, `metal` and
  `nameEn`, because nothing about it can be inferred.
- `RECLASSIFY_ASSET` re-runs the gold invariant over every latest balance
  snapshot carrying that asset and returns the report as
  `invariantRecheck` — the same shape the Assets API returns.
- `REPROCESS` replays the linked raw record through the current
  transformer and reports the outcome; it can legitimately end in a fresh
  quarantine if the record still cannot be transformed.
- `PROMOTE_TO_PHONE` creates the phone as `source: MANUAL`, so no later
  sync will touch it, and returns the normalised number as
  `promotedPhoneE164`.
- **Nothing is ever deleted from quarantine.** The row is stamped with
  `resolvedAt`, `resolvedByOperatorId` and `resolutionNote`, and stays in
  the table.
- Resolving an already-resolved row re-stamps it; it is not a conflict.
- Returns `200`, not `201`.
- Side effects: the repair the action implies is applied, the row is
  stamped, and a `sync.quarantine.resolved` audit row is written naming
  the action and the note.
request body: ResolveQuarantineRequest
responses:
  200: ResolveQuarantineEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/sync/raw-records/{id}/reprocess
operationId: adminReprocessRawRecord
auth: bearer
summary: Reprocess one raw record through the current transformer.

Replays a single stored payload through today's transformer without
re-contacting the vendor — or, with `dryRun=true`, predicts what that
would do without writing anything.

**Notes:**
- Requires `sync:reprocess`. Takes no request body.
- **The raw payload itself is never touched.** Only the record's
  processing state and, when it still fails validation, its quarantine
  entry change.
- Returns `200`, not `202` — the work is synchronous.
- The outcome is `PROCESSED` or `QUARANTINED`. A record that still cannot
  be transformed is quarantined again, with the reason and message, and
  that is a successful `200` — the endpoint reports what happened rather
  than failing.
- **`?dryRun=true` writes nothing at all.** It runs the *identical* write
  path inside a transaction that is then rolled back, and returns the
  before/after `diff` of the canonical state instead. Not even
  `transformAttempts` moves, and no audit row is written. Use it to answer
  "what would this actually change?" before committing to a bulk replay.
- The two responses are distinguishable by the `dryRun: true` field, which
  is present only on the prediction.
- A real reprocess increments `transformAttempts` on the record, so the
  history of how many times a stubborn record has been retried stays
  visible.
- This endpoint does **not** take the entity's advisory lock: it touches
  one record and can run while a sync is in progress.
- Side effects on a real run: the canonical rows are re-derived, the
  record's state and `transformAttempts` are updated, a quarantine entry is
  raised or cleared, and an audit row is written. On a dry run: none.
responses:
  200: ReprocessEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/sync/replay
operationId: adminStartReplay
auth: bearer
summary: Bulk-replay stored raw records through the current transformer.

Selects raw records by entity, fetch window and state, and replays them as
a background job with progress.

**Notes:**
- Requires `sync:reprocess`.
- Returns **`202 Accepted`** with a `jobId`. Poll
  `GET /api/v1/sync/replay/{jobId}` for progress and the final result.
- **`entity` is required and there is no "all" option.** The advisory lock
  is per-entity, so a replay spanning all three would hold three locks at
  once and block every scheduled sync for its whole duration. Three
  explicit calls make that cost visible instead of hiding it.
- The job holds **the same per-entity advisory lock** a live sync takes, so
  the two can never interleave. A replay requested while that lock is held
  is refused with `409 SYNC_ALREADY_RUNNING` and **nothing is enqueued** —
  it is not queued behind the lock, because the selection would be stale by
  the time one freed up.
- `from` is an **inclusive** lower bound on `fetchedAt`; `to` is an
  **exclusive** upper bound. Sending `from` later than or equal to `to` is
  `400`.
- `state` narrows the selection to records in one processing state —
  `QUARANTINED` is the usual choice after fixing a transformer.
- `limit` caps the selection at 5,000 records. Omitting it replays
  everything the window matches.
- `matchedRecords` in the response is how many rows the selection matched
  **at enqueue time** — the job's denominator, and worth checking before
  walking away.
- Replay is **idempotent** through the payload hash: a record whose stored
  payload still produces the same canonical result is recognised rather
  than re-applied.
- The vendor is **never re-contacted**. This replays what is already
  stored, which is the whole point of keeping payloads verbatim.
- Side effects: a queue job is enqueued that takes the entity's lock and
  re-derives canonical rows for the matched records; quarantine entries are
  raised or cleared as it goes; each record's state and `transformAttempts`
  are updated; and audit rows are written.
request body: StartReplayRequest
responses:
  202: ReplayEnqueuedEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### ExternalEntity
type: string
enum:
  - PARTY_LIST
  - BALANCE_DEBTOR
  - BALANCE_CREDITOR
description: Which upstream export a record belongs to. Each has its own advisory lock, which is why a replay is scoped to exactly one.

### RawState
type: string
enum:
  - PENDING
  - PROCESSED
  - FAILED
  - SKIPPED_UNCHANGED
  - QUARANTINED
description: A raw record's processing state. `QUARANTINED` is the usual replay selection after a transformer fix.

### QuarantineReason
type: string
enum:
  - SCHEMA_VIOLATION
  - UNKNOWN_ASSET
  - INVARIANT_VIOLATION
  - SUSPECTED_MISPLACED_FIELD
  - UNPARSEABLE_NUMBER
  - ORPHAN_REFERENCE
  - AMBIGUOUS_NAME
description: Why the record could not be transformed. It determines which resolution actions the row permits.

### QuarantineResolutionAction
type: string
enum:
  - PROMOTE_TO_PHONE
  - DISCARD
  - CONFIRM_ASSET
  - RECLASSIFY_ASSET
  - ACKNOWLEDGE
  - REPROCESS
  - RESOLVE_WITH_NOTE
description: One way of closing a quarantine row. Which a given row permits is published as its `availableActions`; sending anything else is `400`.

### AssetKind
type: string
enum:
  - FIAT_IRR
  - GOLD_WEIGHT
  - GOLD_WEIGHT_EX_COIN
  - SILVER_WEIGHT
  - COIN
  - BULLION
  - FX
description: What an asset is. Changing it re-runs the gold invariant check.

### Metal
type: string
enum:
  - GOLD
  - SILVER
  - NONE
description: Which metal the asset is made of. Getting this wrong is what puts silver weight into a gold sum, which is the usual reason for a reclassification.

### QuarantineRawRecordRef
type: object
description: The reduced reference to the raw record behind a quarantine row.
properties:
  id:
    type: string
    format: uuid
    description: The raw record's id.
  entity:
    $ref: #/components/schemas/ExternalEntity
  externalCode:
    type: string
    nullable: True
    description: The vendor's code for the record, as a **string**.
  state:
    $ref: #/components/schemas/RawState
  fetchedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the record was received.
  syncRunId:
    type: string
    format: uuid
    description: The run that fetched it.
required:
  - id
  - entity
  - externalCode
  - state
  - fetchedAt
  - syncRunId

### Quarantine
type: object
description: A quarantine row as returned after resolution — stamped, not deleted.
properties:
  id:
    type: string
    format: uuid
    description: The quarantine row's internal id.
  reason:
    $ref: #/components/schemas/QuarantineReason
  fieldPath:
    type: string
    nullable: True
    description: Where in the payload the problem is; `null` when it is the record as a whole.
  observedValue:
    type: string
    nullable: True
    description: The offending value as the vendor sent it.
  message:
    type: string
    description: English sentence describing the problem.
  messageFa:
    type: string
    description: The same sentence in Persian.
  detail:
    type: object
    nullable: True
    description: Free-form context whose shape varies by `reason`.
    additionalProperties: True
  rawRecordId:
    type: string
    format: uuid
    description: The raw record this row was raised from.
  rawRecord:
    $ref: #/components/schemas/QuarantineRawRecordRef
  resolvedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant the row was resolved. Set by this endpoint.
  resolvedByOperatorId:
    type: string
    format: uuid
    nullable: True
    description: Who resolved it. Set by this endpoint.
  resolutionNote:
    type: string
    nullable: True
    description: The note supplied at resolution — mandatory for `DISCARD`, `ACKNOWLEDGE` and `RESOLVE_WITH_NOTE`, `null` for the actions that do something visible instead.
  availableActions:
    type: array
    description: The menu this row's `reason` permits. Unchanged by resolution — a resolved row still reports what could have been done to it.
    items:
      $ref: #/components/schemas/QuarantineResolutionAction
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the row was raised.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write.
required:
  - id
  - reason
  - fieldPath
  - observedValue
  - message
  - messageFa
  - rawRecordId
  - rawRecord
  - resolvedAt
  - resolvedByOperatorId
  - resolutionNote
  - availableActions
  - createdAt
  - updatedAt

### RawRecord
type: object
description: One vendor payload. A reprocess changes its **processing state** and the canonical rows it produces — never `payload`.
properties:
  id:
    type: string
    format: uuid
    description: The raw record's internal id.
  syncRunId:
    type: string
    format: uuid
    description: The run that fetched it.
  entity:
    $ref: #/components/schemas/ExternalEntity
  externalCode:
    type: string
    nullable: True
    description: The vendor's code, coerced to a **string** on storage.
  payload:
    type: object
    description: The record exactly as received. **Never modified**, including by a reprocess or a replay.
    additionalProperties: True
  payloadHash:
    type: string
    description: SHA-256 of the canonicalised payload — what makes replay idempotent.
  fetchedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the record was received. The bound a replay's `from`/`to` window applies to.
  state:
    $ref: #/components/schemas/RawState
  processedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant it was last transformed successfully.
  transformAttempts:
    type: integer
    description: How many times the pipeline has tried this record. A real reprocess increments it; a **dry run does not**.
  transformError:
    type: string
    nullable: True
    description: The last failure's message; `null` after a successful reprocess.
  targetTable:
    type: string
    nullable: True
    description: The canonical table the record produced a row in.
  targetId:
    type: string
    nullable: True
    description: The canonical row's id.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write to the processing state.
required:
  - id
  - syncRunId
  - entity
  - externalCode
  - payload
  - payloadHash
  - fetchedAt
  - state
  - processedAt
  - transformAttempts
  - transformError
  - targetTable
  - targetId
  - createdAt
  - updatedAt

### InvariantChange
type: object
description: One latest balance snapshot whose gold-invariant verdict flipped as a result of a reclassification.
properties:
  balanceId:
    type: string
    format: uuid
    description: Internal id of the balance snapshot.
  externalCode:
    type: string
    description: The accounting code the snapshot belongs to, as a **string**. Openable at `GET /api/v1/financial-records/{externalCode}`.
  observedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the snapshot was taken.
  invariantOkBefore:
    type: boolean
    description: The verdict before the reclassification.
  invariantOkAfter:
    type: boolean
    description: The verdict now. A `false → true` flip is a repair; a `true → false` flip means the new classification breaks something.
required:
  - balanceId
  - externalCode
  - observedAt
  - invariantOkBefore
  - invariantOkAfter

### InvariantRecheckReport
type: object
description: What a `RECLASSIFY_ASSET` did to already-persisted balances — the same shape the Assets API returns for a classification edit.
properties:
  ran:
    type: boolean
    description: `false` when the resolution did not change `kind` or `metal`.
  scope:
    type: string
    enum:
      - LATEST_SNAPSHOTS
    description: Always `LATEST_SNAPSHOTS`. Historical snapshots record what was believed at the time and are left alone.
  balancesExamined:
    type: integer
    description: Latest snapshots carrying this asset that were re-judged.
  balancesChanged:
    type: array
    description: Only the rows whose verdict actually flipped. Empty means no harm found.
    items:
      $ref: #/components/schemas/InvariantChange
  historicalSnapshotsSkipped:
    type: integer
    description: Non-latest snapshots left untouched, reported rather than hidden.
required:
  - ran
  - scope
  - balancesExamined
  - balancesChanged
  - historicalSnapshotsSkipped

### ResolveQuarantineEnvelope
type: object
description: Success envelope around a resolution. Which optional fields appear depends on the action taken.
properties:
  data:
    type: object
    properties:
      quarantine:
        $ref: #/components/schemas/Quarantine
      action:
        allOf:
          -
            $ref: #/components/schemas/QuarantineResolutionAction
        description: The action that was applied, echoed back.
      promotedPhoneE164:
        type: string
        description: Present only for `PROMOTE_TO_PHONE` — the normalised number that was attached to the party, created as `source: MANUAL`.
      assetId:
        type: string
        format: uuid
        description: Present only for `CONFIRM_ASSET` and `RECLASSIFY_ASSET` — the asset that was named or reclassified.
      invariantRecheck:
        allOf:
          -
            $ref: #/components/schemas/InvariantRecheckReport
        description: Present only for `RECLASSIFY_ASSET`, reporting what the change did to already-persisted balances.
      reprocess:
        type: object
        description: Present only for `REPROCESS` — the outcome of replaying the linked raw record.
        properties:
          outcome:
            type: string
            enum:
              - PROCESSED
              - QUARANTINED
            description: Whether the replay succeeded or quarantined the record again.
          reason:
            type: string
            description: The quarantine reason, present only when the outcome is `QUARANTINED`.
        required:
          - outcome
    required:
      - quarantine
      - action
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### ReprocessEnvelope
type: object
description: Success envelope around a reprocess. A dry run and a real run return different shapes, told apart by the `dryRun` field.
properties:
  data:
    oneOf:
      -
        type: object
        description: A real reprocess — the record's new state.
        properties:
          outcome:
            type: string
            enum:
              - PROCESSED
              - QUARANTINED
            description: What happened. `QUARANTINED` is still a successful `200`: the endpoint reports the outcome rather than failing.
          rawRecord:
            $ref: #/components/schemas/RawRecord
          reason:
            type: string
            description: The quarantine reason, present only when `outcome` is `QUARANTINED`.
          message:
            type: string
            description: The quarantine message, present only when `outcome` is `QUARANTINED`.
        required:
          - outcome
          - rawRecord
      -
        type: object
        description: A dry run — what **would** have happened, with nothing written.
        properties:
          dryRun:
            type: boolean
            enum:
              - True
            description: Always `true`. Its presence is how a client distinguishes a prediction from a real result.
          outcome:
            type: string
            enum:
              - PROCESSED
              - QUARANTINED
            description: The outcome the real run would have had.
          rawRecordId:
            type: string
            format: uuid
            description: The record that was predicted, echoed from the path.
          reason:
            type: string
            description: The quarantine reason it would have been given, when applicable.
          message:
            type: string
            description: The quarantine message it would have been given, when applicable.
          diff:
            type: object
            description: The before/after of the canonical state the run would have produced. Keys vary by entity and by what the record touches.
            additionalProperties: True
        required:
          - dryRun
          - outcome
          - rawRecordId
          - diff
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### ReplayEnqueuedEnvelope
type: object
description: Success envelope around an enqueued bulk replay.
properties:
  data:
    type: object
    properties:
      jobId:
        type: string
        description: Poll this at `GET /api/v1/sync/replay/{jobId}`.
      entity:
        $ref: #/components/schemas/ExternalEntity
      matchedRecords:
        type: integer
        description: How many raw records the selection matched **at enqueue time** — the job's denominator. Worth checking before walking away.
      status:
        type: string
        enum:
          - QUEUED
        description: Always `QUEUED`; the work itself runs in a background worker.
    required:
      - jobId
      - entity
      - matchedRecords
      - status
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### AssetResolutionPayload
type: object
description: The asset fields a `CONFIRM_ASSET` or `RECLASSIFY_ASSET` carries. Every field is optional in the schema; the **server** decides what this particular situation requires, because that decision depends on a database read a schema cannot make. Naming an already-registered asset needs only `nameEn`; registering an unclassifiable label needs `kind`, `metal` and `nameEn`.
properties:
  nameEn:
    type: string
    minLength: 1
    maxLength: 120
    description: English display name for the asset.
  nameFa:
    type: string
    minLength: 1
    maxLength: 120
    description: Persian display name for the asset.
  kind:
    $ref: #/components/schemas/AssetKind
  metal:
    $ref: #/components/schemas/Metal
  coinVintage:
    type: string
    minLength: 1
    maxLength: 16
    nullable: True
    description: Mint year, e.g. `"1386"`, or `null` to clear it.
  nominalGram:
    type: string
    nullable: True
    pattern: ^-?\d+(?:\.\d{1,3})?$
    description: Reference weight as a decimal **string** with at most three fractional digits, or `null`. Display metadata only; never used in arithmetic.
  sortOrder:
    type: integer
    minimum: 0
    maximum: 10000
    description: Display ordering for the asset, ascending.

### ResolveQuarantineRequest
type: object
description: How to close the row. The action must be one its `reason` offers — read `availableActions` first.
properties:
  action:
    $ref: #/components/schemas/QuarantineResolutionAction
  note:
    type: string
    minLength: 1
    maxLength: 2000
    description: Why. **Required** for `DISCARD`, `ACKNOWLEDGE` and `RESOLVE_WITH_NOTE` — the three actions that record a judgement rather than doing something visible.
  asset:
    allOf:
      -
        $ref: #/components/schemas/AssetResolutionPayload
    description: Required by `CONFIRM_ASSET` and `RECLASSIFY_ASSET`; ignored otherwise.
required:
  - action

### StartReplayRequest
type: object
description: Which stored records to replay. `entity` is required and has no "all" option — the advisory lock is per-entity.
properties:
  entity:
    $ref: #/components/schemas/ExternalEntity
  from:
    type: string
    format: date-time
    description: **Inclusive** lower bound on `fetchedAt`.
  to:
    type: string
    format: date-time
    description: **Exclusive** upper bound on `fetchedAt`. Must be later than `from`.
  state:
    allOf:
      -
        $ref: #/components/schemas/RawState
    description: Restrict the selection to one processing state. `QUARANTINED` is the usual choice after fixing a transformer.
  limit:
    type: integer
    minimum: 1
    maximum: 5000
    description: Cap the selection. Omitting it replays everything the window matches.
required:
  - entity


========================================================================
# Matching API (app: matching, version 1.0.0)
========================================================================

Servers: http://localhost:3000

«لیست تطبیق» — the settlement allocation engine. It decides which **debtor**
pays which **creditor**, into which nominated bank account, for how much.

This is not a reconciliation report. It is a plan that gets built, edited by
hand, frozen, and finally turned into real payment orders. The vocabulary is
worth learning before reading any endpoint:

- A **board** is the whole plan, built from one balance snapshot. Exactly one
  board is live at a time.
- A **group** (a "box" in the panel) is one eligible creditor and everything
  allocated towards them. It carries a colour and a state: `SUGGESTED`,
  `EDITED` or `LOCKED`.
- A **target** is one of that creditor's nominated bank accounts, with the
  amount it is designated to receive.
- An **allocation** is one debtor covering part or all of one target.
- The **pool** is the debtors not allocated anywhere.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. Operator triggers a global rebuild with `POST /api/v1/matching/recompute`,
   which returns `202` and a `runId`, and polls
   `GET /api/v1/matching/runs/{runId}` until it completes.
3. Operator reads the plan from `GET /api/v1/matching/board`, optionally with
   `?include=pool,ineligible`, and the KPI header from
   `GET /api/v1/matching/board/stats`.
4. Operator opens one box with `GET /api/v1/matching/groups/{id}` and looks
   for debtors to add through
   `GET /api/v1/matching/groups/{id}/candidates`.
5. Operator edits by hand: `POST /api/v1/matching/allocations` to add,
   `PATCH /api/v1/matching/allocations/{id}` to change an amount,
   `DELETE` to remove, and `POST /api/v1/matching/allocations/{id}/move` to
   move a debtor from one box to another in a single transaction.
6. A single box can be rebuilt on its own with
   `POST /api/v1/matching/groups/{id}/recompute`, which is synchronous.
7. Freezing a plan and issuing payments from it are administrator actions —
   see the Admin Matching API. `GET /api/v1/matching/export` produces the
   board as CSV or XLSX.

## Security Notes
- Every endpoint requires a valid Bearer access token. Reading needs
  `matching:read`; recomputing needs `matching:run`; hand-editing allocations
  needs `matching:allocate`; exporting needs `matching:export`. Freezing,
  unfreezing, issuing payments and changing settings need permissions only
  `ADMIN` holds — those endpoints are in the Admin Matching API.
- Every money value crosses the wire as a **string** in Rial minor units,
  paired with a server-rendered `formatted` Persian string. Never parse either
  with `Number()`.
- IBANs on targets are **masked**. Nothing in this module reveals one.
- Timestamps come back twice: an ISO-8601 UTC field and a `…Jalali` Persian
  calendar string.
- Every enum carries a Persian label beside it (`stateFa`, `qualityFa`,
  `reasonFa`, `availabilityFa` equivalents), rendered server-side so panel
  components cannot disagree.
- `availableActions` on a group is computed **for the calling operator** from
  the group's state *and* their permissions. A `LOCKED` group never lists
  `edit` or `recompute` — not for any permission, not even for `ADMIN`.
- When no board has ever been built, every read here is
  `404 RESOURCE_NOT_FOUND` naming `MatchBoard` with id `live`. Build one with
  `POST /api/v1/matching/recompute`.
- Hand edits run in a **serializable** transaction that re-reads every row it
  judges, so two operators editing the same box cannot both win.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## Invariants
Hand edits are checked against a fixed set of rules. Each has its own status
and error code, so a client can branch without matching on message text:

- **Over-fill** — an allocation that would push a target past its designated
  amount: `422 MATCHING_TARGET_OVERFLOW`.
- **Over-draw** — an allocation larger than the debtor still has available:
  `422 MATCHING_DEBTOR_OVERDRAW`.
- **Dust** — an amount below `minAllocationMinor`:
  `422 MATCHING_BELOW_MIN_ALLOCATION`, unless the request sets
  `allowBelowMinimum: true`, which is recorded in the audit row.
- **Reserved debtor** — a debtor held by a `LOCKED` box:
  `409 MATCHING_DEBTOR_LOCKED`.
- **Locked destination** — any edit touching a `LOCKED` box:
  `409 MATCHING_GROUP_LOCKED`.
- **Money in flight** — an allocation that already has a payment order:
  `409 MATCHING_ALLOCATION_HAS_PAYMENT`. It refuses every mutation, because a
  payment order is no longer a plan.

The three `422`s are deliberate: the request is well-formed and the board is
not in a conflicting state — it is the *amount* that cannot be processed.

## x-matching-invariants
description: The rules every hand edit is checked against, from `src/modules/matching/allocation-invariants.ts`. Each has its own status and code so a client can branch without matching on message text.
rules:
  -
    target_overflow: 422 MATCHING_TARGET_OVERFLOW — the allocation would push a target past its designated amount.
  -
    debtor_overdraw: 422 MATCHING_DEBTOR_OVERDRAW — the allocation is larger than the debtor still has available.
  -
    below_minimum: 422 MATCHING_BELOW_MIN_ALLOCATION — the amount is under minAllocationMinor. Overruled by allowBelowMinimum: true on the request, which is audited.
  -
    debtor_locked: 409 MATCHING_DEBTOR_LOCKED — the debtor is reserved by a frozen box.
  -
    group_locked: 409 MATCHING_GROUP_LOCKED — the source or destination box is frozen.
  -
    has_payment: 409 MATCHING_ALLOCATION_HAS_PAYMENT — a payment order has been issued from this allocation, so it is no longer a plan.
why_422_not_409: The three amount rules return 422 rather than 409 deliberately: the request is well-formed and the board is not in a conflicting state — it is the amount itself that cannot be processed.
serializable: Every mutation runs in one serializable transaction that re-reads the rows it judges, so the answer reflects what is stored rather than what the client last saw. The If-Match precondition is checked inside that transaction too.

## x-lock-semantics
description: What freezing a box does, from `src/modules/matching/group-lock.service.ts` and `state/available-actions.ts`. Locking and unlocking are administrator actions — see the Admin Matching API.
effects:
  -
    invisible_to_solver: A LOCKED box is byte-identical across any recompute, global or per-box.
  -
    debtors_reserved: The debtors it holds are unavailable to every other box. Under the default lockReservesWholeDebtor, their unallocated remainder is reserved too.
  -
    actions_removed: edit and recompute are absent from a LOCKED box's availableActions entirely. No permission restores them, not even ADMIN's allow-all — unlike lock, unlock and issue-payments, which are ordinary permission gates.
  -
    no_money_moves: Locking moves no money. Issuing payment orders is a separate action under a separate permission.
  -
    unlock_state: A box that has been frozen and released lands in EDITED, not back in SUGGESTED.

## Operations

### GET /api/v1/matching/board
operationId: getMatchingBoard
auth: bearer
summary: Retrieve the live matching board.

Returns the whole live plan: every eligible creditor's box with its
targets and allocations, the KPI header, and how stale the plan's basis
is.

**Notes:**
- Requires `matching:read`.
- `?include=pool` adds the unallocated debtors; `?include=ineligible`
  adds the creditors that did not qualify for a box. Both are omitted by
  default because both are expensive; pass them comma-separated to get
  both. An unrecognised token in `include` is ignored.
- `params` is the settings snapshot the board was **actually built with**,
  frozen at build time. It can differ from the live settings at
  `GET /api/v1/matching/settings` — that difference is exactly why
  changing a setting does not silently recompute.
- `isStale` and `staleness` answer "how far behind is this plan": the
  balance snapshot it was built from, the newest snapshot now available,
  and how many boxes have actually drifted as a result.
- `availableActions` on each group is computed for **you**. A `LOCKED`
  group lists only `unlock` and `issue-payments`, and never `edit` or
  `recompute` — that is a property of the state, not of your permissions.
- `basisDrifted` on a group means its creditor's balance has changed since
  the board was built; `driftDetail` then carries the diff and is `null`
  otherwise.
- `fillPercent` and `coveragePercent` are numbers, not strings — they are
  percentages, not money.
- No board at all is `404 RESOURCE_NOT_FOUND` naming `MatchBoard`.
- Not paginated: a board is returned whole.
- Side effects: none.
responses:
  200: BoardEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/matching/board/stats
operationId: getMatchingBoardStats
auth: bearer
summary: Retrieve the board's KPI header.

Returns just the aggregate figures at the top of the matching screen,
without the groups.

**Notes:**
- Requires `matching:read`.
- Exactly the `stats` block embedded in `GET /api/v1/matching/board`,
  served on its own so a dashboard can poll it cheaply.
- `coveragePercent` is `allocated / creditTotal` as a percentage — a
  number, not a money string.
- `byQuality` is a sparse map: a quality with no allocations is absent
  rather than reported as zero.
- `targetsShort` counts targets whose allocated total is below their
  designated amount; `debtorsUnallocated` counts debtors in the pool.
- No board at all is `404 RESOURCE_NOT_FOUND` naming `MatchBoard`.
- Side effects: none.
responses:
  200: BoardStatsEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/matching/pool
operationId: getMatchingPool
auth: bearer
summary: List unallocated debtors and creditors with no box.

Returns the debtors the plan has not used, and the creditors that failed
eligibility, with the reason each one failed.

**Notes:**
- Requires `matching:read`.
- The same two blocks `GET /api/v1/matching/board?include=pool,ineligible`
  embeds, served on their own.
- `available` on a pool debtor is their debt total minus whatever is
  already allocated elsewhere — what a new allocation may draw on.
- `orphanedBalanceCount` reports how many debtor balances could not be
  attributed to a party at all. They are not offered as candidates.
- Each ineligible creditor carries `delta` — designated total minus credit
  total — and a `reason`: `SHORTFALL` (their nominated accounts do not add
  up to what they are owed), `EXCESS` (they add up to more),
  `NO_ACCOUNTS`, `NO_DESIGNATED_AMOUNT`, `EXCLUDED` (an operator excluded
  the party or its accounts), `ORPHANED`, or `INACTIVE`.
- `EXCLUDED` is the actionable one: it is the result of the exclusions set
  through the Admin Parties and Admin Bank Accounts APIs.
- Not paginated.
- No board at all is `404 RESOURCE_NOT_FOUND` naming `MatchBoard`.
- Side effects: none.
responses:
  200: PoolEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/matching/export
operationId: exportMatchingBoard
auth: bearer
summary: Export the matching board as CSV or XLSX.

Streams the whole live board as a downloadable spreadsheet.

**Notes:**
- Requires `matching:export`.
- Matched **before** `/{id}`-shaped sibling routes, so `export` is a
  reserved segment in this path space.
- **Does not return the §9.1 envelope.** The body is the raw file, with
  `Content-Disposition: attachment` and a generated filename.
- `format` defaults to `csv`. CSV is written with a UTF-8 byte-order mark
  so Excel opens Persian text correctly.
- Dates in the file are Jalali; money is written as **text**, not as a
  number, so a spreadsheet cannot round a Rial figure into a float;
  IBANs are masked.
- Takes no filters: the export is the live board as it stands.
- Side effects: the download itself is audited, recording the operator.
- An error raised before the file is produced still returns the ordinary
  JSON error envelope.
responses:
  200: string
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/matching/recompute
operationId: recomputeMatchingBoard
auth: bearer
summary: Queue a global rebuild of the matching board.

Releases every allocation in every non-locked box and redistributes the
whole free pool. Returns immediately with a run id to poll.

**Notes:**
- Requires `matching:run`. Takes no request body.
- Returns **`202 Accepted`**, not `200`: the work is queued and runs in a
  background worker. Poll `GET /api/v1/matching/runs/{runId}` for
  progress.
- **Never touches a `LOCKED` box.** A locked box is byte-identical across
  any recompute, and the debtors it holds stay reserved — under the
  default `lockReservesWholeDebtor`, including their unallocated
  remainder.
- Guarded by a database advisory lock: a second concurrent call is
  `409 MATCHING_RUN_IN_PROGRESS` and enqueues nothing.
- The rebuild uses the settings live **at the moment it runs**, and
  freezes them onto the new board's `params`.
- The first successful run is what creates a live board at all; before
  that, every read in this module is `404`.
- Side effects: a `MatchRun` row created with status `RUNNING`, the
  advisory lock taken, and a job enqueued. When it completes, the previous
  board stops being live and the new one takes over.
responses:
  202: RecomputeQueuedEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/matching/runs
operationId: listMatchingRuns
auth: bearer
summary: List matching runs.

Returns a page of rebuild history, newest first.

**Notes:**
- Requires `matching:read`.
- `scope` distinguishes a `GLOBAL` rebuild from a `GROUP` (single-box)
  one; `groupId` narrows to one box's runs.
- `status` is `RUNNING`, `COMPLETED`, `PARTIAL` or `FAILED`. `PARTIAL`
  means the solver hit its node or time budget and returned the best plan
  it had — a real result, not a failure.
- `nodesVisited` and `durationMs` are `null` while a run is still
  `RUNNING`.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100.
- Side effects: none.
responses:
  200: PaginatedMatchingRuns
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/matching/runs/{id}
operationId: getMatchingRun
auth: bearer
summary: Retrieve one matching run.

Returns one rebuild's live progress or its final outcome.

**Notes:**
- Requires `matching:read`.
- This is the endpoint to poll after `POST /api/v1/matching/recompute`
  returns `202`.
- While `status` is `RUNNING`, `nodesVisited`, `durationMs` and
  `finishedAt` are all `null`.
- `status: "PARTIAL"` with `isPartialResult: true` means the solver hit
  its node or time budget and stopped with the best plan it had found. The
  board is usable; raising `nodeBudget` or `timeBudgetMs` in the matching
  settings may produce a better one.
- `errorCode` is populated only for a `FAILED` run and carries the catalog
  code that stopped it.
- Side effects: none.
responses:
  200: MatchingRunEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/matching/groups/{id}
operationId: getMatchingGroup
auth: bearer
summary: Retrieve one matching box in full.

Returns one creditor's box: their identity, every nominated account, and
every allocation against it.

**Notes:**
- Requires `matching:read`.
- `availableActions` is computed for **you** from the box's state and your
  permissions. A `LOCKED` box lists only `unlock` and `issue-payments`;
  `edit` and `recompute` are absent from the table entirely, so no
  permission — not even `ADMIN`'s allow-all — brings them back.
- `shortfall` is `targetTotal − allocated`, and `fillPercent` is that as a
  percentage. A box can be over-covered only by a deliberate hand edit.
- `basisDrifted: true` means the creditor's balance has moved since the
  board was built; `driftDetail` then carries the diff and is `null`
  otherwise.
- `lockedAt`, `lockedByOperatorId` and `lockNote` are populated only while
  the box is `LOCKED`.
- An allocation with a non-null `paymentOrderId` has already become a
  payment order and refuses every further mutation.
- Side effects: none.
responses:
  200: GroupEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/matching/groups/{id}/candidates
operationId: listMatchingCandidates
auth: bearer
summary: List debtors that could be allocated to this box.

Returns the debtor picker's feed for one box: who is available, who is
blocked, and why.

**Notes:**
- Requires `matching:read`.
- **Blocked debtors are returned, not filtered out.** `isSelectable` and
  `blockReasonFa` say why a row cannot be picked, so the operator sees
  "already in another box" instead of an unexplained absence.
- `availability` is one of: `AVAILABLE`; `IN_OTHER_GROUP` (allocated
  elsewhere, but movable); `LOCKED_ELSEWHERE` (reserved by a frozen box —
  not movable); `IN_THIS_GROUP`; `EXCLUDED`; `FULLY_ALLOCATED`. It accepts
  a single value or an array to filter on.
- `suggestedAmount` is what the picker pre-fills:
  `min(debtor available, target remaining)`.
- `q` is folded through the shared Persian normalisation before matching
  the stored search column, so an Arabic-yeh-stored debtor is found by a
  Persian-yeh query.
- `sort=closest-to-target` orders by `|available − remaining|` ascending —
  the debtor who most nearly fills the gap comes first. The default sort
  is the service's own ordering.
- `currentGroup` is populated for a debtor already allocated somewhere and
  names that box, its creditor and its state — which is what tells the
  operator whether a move is possible.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100.
- Side effects: none.
responses:
  200: PaginatedCandidates
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/matching/groups/{id}/recompute
operationId: recomputeMatchingGroup
auth: bearer
summary: Rebuild one matching box synchronously.

Releases this box's allocations and re-solves it alone, against the free
pool plus the debtors it just released.

**Notes:**
- Requires `matching:run`. Takes no request body.
- **Synchronous**: returns `200` with the rebuilt box, not `202`. It still
  writes a `MatchRun` row with `scope: "GROUP"`, so the history is
  complete.
- **Never takes a debtor from another box.** The pool it draws on is the
  free pool plus whatever this box released — a per-box recompute cannot
  damage a neighbour.
- Refused with `409 MATCHING_GROUP_LOCKED` on a `LOCKED` box. Unlock it
  first.
- `isPartialResult: true` means the solver hit its node or time budget for
  this box and returned the best plan it had.
- `nodesVisited` and `durationMs` report what the solver actually spent,
  so an operator can tell a hard box from a slow one.
- After the rebuild the box's state is `SUGGESTED` again if the solver
  replaced everything, and its `version` has moved either way.
- Side effects: this box's allocations deleted and rebuilt, a `MatchRun`
  row written, the box's `version` incremented, and an audit row written.
responses:
  200: GroupRecomputeEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/matching/allocations
operationId: createMatchingAllocation
auth: bearer
summary: Allocate a debtor to a target by hand.

Adds one debtor to one of a box's nominated accounts, for a stated amount,
after re-checking every invariant inside a serializable transaction.

**Notes:**
- Requires `matching:allocate`.
- The transaction **re-reads the rows it judges**, so the answer reflects
  what is actually stored rather than what the client last saw. Two
  operators racing on the same target cannot both succeed.
- Invariants, each with its own code: over-filling the target is
  `422 MATCHING_TARGET_OVERFLOW`; exceeding the debtor's available balance
  is `422 MATCHING_DEBTOR_OVERDRAW`; an amount below `minAllocationMinor`
  is `422 MATCHING_BELOW_MIN_ALLOCATION`; a debtor reserved by a frozen box
  is `409 MATCHING_DEBTOR_LOCKED`; a frozen destination is
  `409 MATCHING_GROUP_LOCKED`.
- `allowBelowMinimum: true` deliberately overrules the dust rule. It has to
  be typed on the request that makes the exception, and it is recorded in
  the audit row as `belowMinimumOverride` — it cannot be set by accident.
- The new row is written `isManual: true` with `quality: "MANUAL"`, and the
  destination box moves to `EDITED`.
- Returns `201` with both the allocation and the box it now sits in, so the
  panel can re-render the box without a second call. The `ETag` header
  carries the **allocation's** version, which is what `PATCH` and `DELETE`
  require back.
- The schema is **strict**: an unrecognised key is `400`.
- Side effects: the allocation row created, the box's state and `version`
  moved, and an audit row written.
request body: CreateAllocationRequest
responses:
  201: AllocationMutationEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  422: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/matching/allocations/{id}
operationId: deleteMatchingAllocation
auth: bearer
summary: Remove an allocation.

Deletes one allocation and returns the box it left.

**Notes:**
- Requires `matching:allocate`.
- **`If-Match` is required here as well as on `PATCH`** — unusually for a
  delete. A cross-box move keeps the allocation's id, so an id alone no
  longer tells a stale browser tab which box it is deleting from; the
  version does.
- This is a **hard** delete. A suggestion nobody wants is not history worth
  keeping — the audit row is.
- Refused with `409 MATCHING_ALLOCATION_HAS_PAYMENT` once a payment order
  has been issued for it, and with `409 MATCHING_GROUP_LOCKED` if the box
  is frozen.
- Returns `200` with the deleted id and the box's new state, not `204`:
  the panel needs the refreshed box to render the gap that opened.
- The box moves to `EDITED` and its `shortfall` grows. Nothing is
  auto-refilled.
- Side effects: the allocation row deleted, the box's state and `version`
  moved, and an audit row written.
responses:
  200: AllocationDeletionEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/matching/allocations/{id}
operationId: updateMatchingAllocation
auth: bearer
summary: Change an allocation's amount.

Re-sizes one allocation under an optimistic-concurrency precondition,
re-checking the same invariants a creation does.

**Notes:**
- Requires `matching:allocate`.
- **`If-Match` is required**, carrying the **allocation's** `version` — not
  the box's. A stale value is `409 CONCURRENT_MODIFICATION`.
- The precondition is checked **inside** the serializable transaction, so a
  retry is judged against what is actually stored rather than against a
  value read a moment earlier.
- Same invariant set as creation: over-fill `422`, over-draw `422`, dust
  `422` unless `allowBelowMinimum`, reserved debtor `409`, locked box
  `409`.
- Once the allocation carries a `paymentOrderId` it refuses **every**
  mutation with `409 MATCHING_ALLOCATION_HAS_PAYMENT` — money in flight is
  not a plan any more. Cancel the payment order first.
- The box moves to `EDITED` and both the allocation and the box get new
  `version`s. The response `ETag` carries the allocation's.
- The schema is **strict**: only `amountMinor` and `allowBelowMinimum` are
  accepted. The debtor and the target cannot be changed here — moving a
  debtor is `POST /api/v1/matching/allocations/{id}/move`.
- Side effects: the allocation updated, the box's state and `version`
  moved, and an audit row written.
request body: UpdateAllocationAmountRequest
responses:
  200: AllocationMutationEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  422: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/matching/allocations/{id}/move
operationId: moveMatchingAllocation
auth: bearer
summary: Move a debtor from one box to another.

Removes the debtor from the box they are in and places them in the box you
name — in **one endpoint and one transaction**.

**Notes:**
- Requires `matching:allocate`. `If-Match` is required, carrying the
  allocation's `version`.
- This exists as a single operation on purpose. A client-orchestrated
  delete-then-create can half-fail and leave a debtor allocated twice or
  not at all; one transaction cannot.
- Omit `amountMinor` and the server moves
  `min(debtor.available + this allocation, destination remaining)` — which
  is what the panel's «انتقال» button sends. Supplying it is the partial-move
  case.
- The allocation **keeps its id** across the move. That is why `If-Match`
  is required on `DELETE` as well.
- Both boxes move to `EDITED`, both `version`s are bumped, and **two**
  audit rows are written under one `requestId`.
- **The hole left at the source is not auto-refilled.** The response
  carries both sides, each with its new `shortfall` and a pre-rendered
  Persian `noticeFa` — `null` when that side is fully covered. Explicit
  beats magic; refill it with a per-box recompute if you want to.
- Every invariant applies to the destination: over-fill `422`, over-draw
  `422`, dust `422` unless `allowBelowMinimum`, a frozen source or
  destination `409 MATCHING_GROUP_LOCKED`, a reserved debtor
  `409 MATCHING_DEBTOR_LOCKED`, and a payment already issued
  `409 MATCHING_ALLOCATION_HAS_PAYMENT`.
- Returns `200`, not `201` — nothing is created.
- The schema is **strict**: only `toTargetId`, `amountMinor` and
  `allowBelowMinimum` are accepted.
request body: MoveAllocationRequest
responses:
  200: AllocationMoveEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  422: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/matching/settings
operationId: getMatchingSettings
auth: bearer
summary: Read the live matching settings.

Returns the parameters the **next** board build will use.

**Notes:**
- Requires `matching:read`. Changing them needs `matching:settings`, which
  only `ADMIN` holds — see the Admin Matching API.
- Environment variables are the boot default; the settings table is the
  live value. This endpoint returns the live value.
- These are **not** necessarily the parameters the current board was built
  with. Compare against `params` on `GET /api/v1/matching/board`: a
  difference means a recompute is warranted.
- Money-valued settings (`toleranceMinor`, `eligibilityToleranceMinor`,
  `minAllocationMinor`) come back as the standard money shape with a
  decimal **string** and a Persian rendering. `0` means "exact".
- `nodeBudget` and `timeBudgetMs` bound the solver. Hitting either
  produces a `PARTIAL` run rather than a failure.
- `lockReservesWholeDebtor` decides whether freezing a box reserves the
  debtors' *whole* balance or only the part allocated. It defaults to
  reserving the whole debtor.
- Side effects: none.
responses:
  200: MatchingSettingsEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PaginationMeta
type: object
description: The §9.1 success envelope's `meta` for an offset-paginated list. `totalPages` is derived by the envelope interceptor, never by a handler.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total rows matching the filter across all pages.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when `total` is 0.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - page
  - pageSize
  - total
  - totalPages
  - requestId
  - timestamp

### Money
type: object
description: A Rial amount. The authoritative value is `minor`, a decimal **string** in minor units; `formatted` is the server-rendered Persian display string. Never parse either with `Number()`.
properties:
  minor:
    type: string
    description: Signed decimal string in Rial minor units, e.g. `"48250000000"`.
  formatted:
    type: string
    description: Persian rendering with digit grouping, e.g. `"۴٬۸۲۵٬۰۰۰٬۰۰۰ تومان"`.
required:
  - minor
  - formatted

### MatchGroupState
type: string
enum:
  - SUGGESTED
  - EDITED
  - LOCKED
description: `SUGGESTED` — as the solver built it. `EDITED` — a human has touched it. `LOCKED` — frozen: invisible to the solver, its debtors reserved, and neither editable nor recomputable by anyone.

### MatchQuality
type: string
enum:
  - EXACT_SINGLE
  - EXACT_COMBINATION
  - WITHIN_TOLERANCE
  - PARTIAL
  - MANUAL
description: How the allocation came about. `EXACT_SINGLE` — one debtor covers the target exactly. `EXACT_COMBINATION` — several do, together. `WITHIN_TOLERANCE` — within the configured tolerance. `PARTIAL` — covers only part of it. `MANUAL` — a human placed it.

### MatchGroupAction
type: string
enum:
  - edit
  - recompute
  - lock
  - unlock
  - issue-payments
description: An action open on a box. `edit` and `recompute` are absent from a `LOCKED` box entirely — no permission restores them. `lock`, `unlock` and `issue-payments` are permission-gated in the ordinary way.

### IneligibleReason
type: string
enum:
  - SHORTFALL
  - EXCESS
  - NO_ACCOUNTS
  - NO_DESIGNATED_AMOUNT
  - EXCLUDED
  - ORPHANED
  - INACTIVE
description: Why a creditor got no box. `SHORTFALL` / `EXCESS` — their nominated accounts' designated amounts do not add up to what they are owed. `NO_ACCOUNTS` / `NO_DESIGNATED_AMOUNT` — nothing to pay into. `EXCLUDED` — an operator excluded the party or its accounts. `ORPHANED` — no party record behind the balance. `INACTIVE` — the party is inactive.

### Availability
type: string
enum:
  - AVAILABLE
  - IN_OTHER_GROUP
  - LOCKED_ELSEWHERE
  - IN_THIS_GROUP
  - EXCLUDED
  - FULLY_ALLOCATED
description: Whether a candidate debtor can be picked. `IN_OTHER_GROUP` is movable; `LOCKED_ELSEWHERE` is reserved by a frozen box and is not.

### MatchRunScope
type: string
enum:
  - GLOBAL
  - GROUP
description: Whether the run rebuilt the whole board or one box.

### MatchRunStatus
type: string
enum:
  - RUNNING
  - COMPLETED
  - PARTIAL
  - FAILED
description: `PARTIAL` is a real result, not a failure: the solver hit its node or time budget and returned the best plan it had.

### Allocation
type: object
description: One debtor covering part or all of one nominated account.
properties:
  id:
    type: string
    format: uuid
    description: Internal id. **Preserved across a cross-box move**, which is why every mutation requires `If-Match`.
  debtorPartyId:
    type: string
    format: uuid
    description: The debtor's party id.
  debtorExternalCode:
    type: string
    description: The debtor's accounting code, as a **string**.
  debtorName:
    type: string
    description: The debtor's name **snapshotted** when the allocation was made, so the plan keeps reading correctly after a rename.
  amount:
    $ref: #/components/schemas/Money
  quality:
    $ref: #/components/schemas/MatchQuality
  qualityFa:
    type: string
    description: The quality in Persian, rendered server-side.
  isManual:
    type: boolean
    description: `true` for an allocation a human created or moved; the solver never sets it.
  isPartial:
    type: boolean
    description: `true` when this allocation covers only part of its target.
  paymentOrderId:
    type: string
    format: uuid
    nullable: True
    description: The payment order issued from this allocation, once there is one. Non-null makes the row refuse **every** mutation.
  version:
    type: integer
    description: Optimistic-concurrency counter for this allocation. Returned as the `ETag` header on every mutation and required back as `If-Match`.
required:
  - id
  - debtorPartyId
  - debtorExternalCode
  - debtorName
  - amount
  - quality
  - qualityFa
  - isManual
  - isPartial
  - paymentOrderId
  - version

### Target
type: object
description: One of the creditor's nominated bank accounts, with the amount it is designated to receive and what has been allocated towards it.
properties:
  id:
    type: string
    format: uuid
    description: The target's id on this board — the value `targetId` refers to when allocating.
  bankAccountId:
    type: string
    format: uuid
    description: The underlying bank account, readable through the Bank Accounts API.
  bankNameFa:
    type: string
    nullable: True
    description: Persian bank name at build time; `null` when none was resolved.
  ibanMasked:
    type: string
    nullable: True
    description: The account's IBAN, masked. Nothing in this module reveals it.
  accountNumber:
    type: string
    nullable: True
    description: The account number, when one is recorded. Not masked.
  holderName:
    type: string
    description: The name on the account, which need not match the creditor's own name.
  target:
    $ref: #/components/schemas/Money
  allocated:
    $ref: #/components/schemas/Money
  allocations:
    type: array
    description: The debtors allocated towards this account.
    items:
      $ref: #/components/schemas/Allocation
  version:
    type: integer
    description: Write counter for this target row.
required:
  - id
  - bankAccountId
  - bankNameFa
  - accountNumber
  - holderName
  - target
  - allocated
  - allocations
  - version

### GroupCreditor
type: object
description: The creditor a box belongs to, with enough contact detail to call them.
properties:
  partyId:
    type: string
    format: uuid
    description: The creditor's party id.
  externalCode:
    type: string
    description: The creditor's accounting code, as a **string**.
  name:
    type: string
    description: The creditor's display name.
  group:
    type: object
    nullable: True
    description: The creditor's counterparty group; `null` when they have none.
    properties:
      id:
        type: string
        format: uuid
        description: Internal group id.
      nameFa:
        type: string
        description: Persian group name.
    required:
      - id
      - nameFa
  phones:
    type: array
    description: The creditor's contact numbers, primary first.
    items:
      type: object
      properties:
        e164:
          type: string
          description: Normalised international form, e.g. `+989365300484`.
        label:
          type: string
          nullable: True
          description: Operator-chosen label, e.g. `همراه`; `null` when unset.
        isPrimary:
          type: boolean
          description: At most one number per party carries `true`.
      required:
        - e164
        - label
        - isPrimary
required:
  - partyId
  - externalCode
  - name
  - group
  - phones

### Group
type: object
description: One creditor's box: what they are owed, where it should go, and who is paying it.
properties:
  id:
    type: string
    format: uuid
    description: The box's id on the live board.
  colorKey:
    type: integer
    description: The colour slot the panel paints this box with, assigned at build time so a box keeps its colour across a re-render.
  state:
    $ref: #/components/schemas/MatchGroupState
  stateFa:
    type: string
    description: The state in Persian, rendered server-side.
  basisDrifted:
    type: boolean
    description: `true` when the creditor's balance has changed since the board was built, so this box's numbers no longer reflect reality.
  driftDetail:
    type: array
    nullable: True
    description: The diff behind `basisDrifted`. Populated only when it is `true`; `null` otherwise.
    items:
      type: object
      additionalProperties: True
  creditor:
    $ref: #/components/schemas/GroupCreditor
  creditTotal:
    $ref: #/components/schemas/Money
  targetTotal:
    $ref: #/components/schemas/Money
  allocated:
    $ref: #/components/schemas/Money
  shortfall:
    $ref: #/components/schemas/Money
  fillPercent:
    type: number
    description: `allocated / targetTotal` as a percentage. A number, not money.
  targets:
    type: array
    description: The creditor's nominated accounts, each with its allocations.
    items:
      $ref: #/components/schemas/Target
  lockedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant the box was frozen; `null` when it is not `LOCKED`.
  lockedAtJalali:
    type: string
    nullable: True
    description: The same instant as a Persian (Jalali) string; `null` when not locked.
  lockedByOperatorId:
    type: string
    format: uuid
    nullable: True
    description: Who froze the box; `null` when it is not `LOCKED`.
  lockNote:
    type: string
    nullable: True
    description: Why it was frozen, as supplied at lock time. Stored on the box; the unlock note by contrast lives only in the audit trail.
  availableActions:
    type: array
    description: What **you** may do to this box, from its state and your permissions. A `LOCKED` box never lists `edit` or `recompute`.
    items:
      $ref: #/components/schemas/MatchGroupAction
  version:
    type: integer
    description: Optimistic-concurrency counter for the box. Required as `If-Match` by the lock and unlock endpoints in the Admin Matching API.
required:
  - id
  - colorKey
  - state
  - stateFa
  - basisDrifted
  - driftDetail
  - creditor
  - creditTotal
  - targetTotal
  - allocated
  - shortfall
  - fillPercent
  - targets
  - lockedAt
  - lockedAtJalali
  - lockedByOperatorId
  - lockNote
  - availableActions
  - version

### IneligibleCreditor
type: object
description: A creditor that got no box, with the reason and the arithmetic behind it.
properties:
  id:
    type: string
    format: uuid
    description: The ineligibility record's id on this board.
  partyId:
    type: string
    format: uuid
    nullable: True
    description: `null` when the balance has no party record at all — the `ORPHANED` case.
  externalCode:
    type: string
    description: The creditor's accounting code, as a **string**.
  name:
    type: string
    description: The creditor's name, or the placeholder for an orphaned code.
  creditTotal:
    $ref: #/components/schemas/Money
  designatedTotal:
    $ref: #/components/schemas/Money
  delta:
    $ref: #/components/schemas/Money
  reason:
    $ref: #/components/schemas/IneligibleReason
  reasonFa:
    type: string
    description: The reason in Persian, rendered server-side.
required:
  - id
  - partyId
  - externalCode
  - name
  - creditTotal
  - designatedTotal
  - delta
  - reason
  - reasonFa

### PoolDebtor
type: object
description: A debtor the plan has not used, or has only partly used.
properties:
  partyId:
    type: string
    format: uuid
    description: The debtor's party id.
  externalCode:
    type: string
    description: The debtor's accounting code, as a **string**.
  name:
    type: string
    description: The debtor's display name.
  debtTotal:
    $ref: #/components/schemas/Money
  allocated:
    $ref: #/components/schemas/Money
  available:
    $ref: #/components/schemas/Money
required:
  - partyId
  - externalCode
  - name
  - debtTotal
  - allocated
  - available

### BoardStats
type: object
description: The board's KPI header. Percentages are numbers; totals are money strings.
properties:
  creditorsEligible:
    type: integer
    description: Creditors that got a box.
  creditorsIneligible:
    type: integer
    description: Creditors that did not, for any of the `IneligibleReason` values.
  creditTotal:
    $ref: #/components/schemas/Money
  allocated:
    $ref: #/components/schemas/Money
  coveragePercent:
    type: number
    description: `allocated / creditTotal` as a percentage.
  targetsTotal:
    type: integer
    description: Nominated accounts across every box.
  targetsFilled:
    type: integer
    description: Targets whose allocated total meets their designated amount.
  targetsShort:
    type: integer
    description: Targets still below their designated amount.
  debtorsUsed:
    type: integer
    description: Distinct debtors appearing in at least one allocation.
  debtorsUnallocated:
    type: integer
    description: Debtors in the pool.
  groupsLocked:
    type: integer
    description: Boxes currently frozen.
  partialAllocations:
    type: integer
    description: Allocations covering only part of their target.
  byQuality:
    type: object
    description: Allocation counts per quality. **Sparse**: a quality with no allocations is absent rather than zero.
    additionalProperties:
      type: integer
required:
  - creditorsEligible
  - creditorsIneligible
  - creditTotal
  - allocated
  - coveragePercent
  - targetsTotal
  - targetsFilled
  - targetsShort
  - debtorsUsed
  - debtorsUnallocated
  - groupsLocked
  - partialAllocations
  - byQuality

### BoardStaleness
type: object
description: How far behind the plan is, and how much of it that actually touches — the two numbers needed to decide whether today's drift is worth chasing.
properties:
  basisObservedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the balance snapshot the board was built from.
  basisObservedAtJalali:
    type: string
    description: The same instant as a Persian (Jalali) string.
  latestObservedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant of the newest balance snapshot now available; `null` when there is none newer.
  latestObservedAtJalali:
    type: string
    nullable: True
    description: The same instant as a Persian (Jalali) string; `null` when there is none.
  driftedGroupCount:
    type: integer
    description: How many boxes actually have `basisDrifted: true`.
required:
  - basisObservedAt
  - basisObservedAtJalali
  - latestObservedAt
  - latestObservedAtJalali
  - driftedGroupCount

### Board
type: object
description: The whole live plan. Exactly one board is live at a time.
properties:
  id:
    type: string
    format: uuid
    description: The board's id. Changes with every global rebuild.
  basisObservedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the balance snapshot this plan was built from.
  basisObservedAtJalali:
    type: string
    description: The same instant as a Persian (Jalali) string.
  isStale:
    type: boolean
    description: `true` when a newer balance snapshot exists than the one this plan used.
  staleness:
    $ref: #/components/schemas/BoardStaleness
  engineVersion:
    type: string
    description: Version of the solver that produced this board, so a plan built by an older engine stays identifiable.
  params:
    type: object
    description: The matching settings **frozen at build time**. Compare against `GET /api/v1/matching/settings` to see whether the live settings have since moved.
    additionalProperties: True
  stats:
    $ref: #/components/schemas/BoardStats
  groups:
    type: array
    description: One box per eligible creditor.
    items:
      $ref: #/components/schemas/Group
  pool:
    allOf:
      -
        $ref: #/components/schemas/Pool
    description: Present only when `?include=pool` was requested.
  ineligibleCreditors:
    type: array
    description: Present only when `?include=ineligible` was requested.
    items:
      $ref: #/components/schemas/IneligibleCreditor
  version:
    type: integer
    description: Write counter for the board row.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write to the board.
  updatedAtJalali:
    type: string
    description: The same instant as a Persian (Jalali) string.
required:
  - id
  - basisObservedAt
  - basisObservedAtJalali
  - isStale
  - staleness
  - engineVersion
  - params
  - stats
  - groups
  - version
  - updatedAt
  - updatedAtJalali

### Pool
type: object
description: The debtors the plan has not used.
properties:
  unallocatedDebtors:
    type: array
    items:
      $ref: #/components/schemas/PoolDebtor
  orphanedBalanceCount:
    type: integer
    description: Debtor balances with no party record behind them. They are never offered as candidates.
required:
  - unallocatedDebtors
  - orphanedBalanceCount

### MatchingRun
type: object
description: One rebuild, with its live progress or its final outcome.
properties:
  id:
    type: string
    format: uuid
    description: The run's id — the value returned by a recompute and polled here.
  boardId:
    type: string
    format: uuid
    description: The board this run produced or modified.
  scope:
    $ref: #/components/schemas/MatchRunScope
  groupId:
    type: string
    format: uuid
    nullable: True
    description: The box rebuilt, for a `GROUP` run; `null` for a `GLOBAL` one.
  status:
    $ref: #/components/schemas/MatchRunStatus
  nodesVisited:
    type: integer
    nullable: True
    description: Search nodes the solver explored. `null` while the run is `RUNNING`. Equal to `nodeBudget` on a run that exhausted it.
  durationMs:
    type: integer
    nullable: True
    description: Wall-clock milliseconds the run took. `null` while it is `RUNNING`.
  isPartialResult:
    type: boolean
    description: `true` when the solver stopped at its node or time budget and returned the best plan it had found.
  errorCode:
    type: string
    nullable: True
    description: Catalog code that stopped the run. Populated only for a `FAILED` run.
  triggeredByOperatorId:
    type: string
    format: uuid
    nullable: True
    description: The operator who started the run; `null` for one the system started, such as an automatic recompute after a sync.
  startedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the run began.
  startedAtJalali:
    type: string
    description: The same instant as a Persian (Jalali) string.
  finishedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant the run ended; `null` while it is `RUNNING`.
  finishedAtJalali:
    type: string
    nullable: True
    description: The same instant as a Persian (Jalali) string; `null` while running.
required:
  - id
  - boardId
  - scope
  - groupId
  - status
  - nodesVisited
  - durationMs
  - isPartialResult
  - errorCode
  - triggeredByOperatorId
  - startedAt
  - startedAtJalali
  - finishedAt
  - finishedAtJalali

### Candidate
type: object
description: A debtor offered to the picker for one box. Blocked candidates are returned too, with the reason.
properties:
  partyId:
    type: string
    format: uuid
    description: The debtor's party id — the value to send as `debtorPartyId`.
  externalCode:
    type: string
    description: The debtor's accounting code, as a **string**.
  displayName:
    type: string
    description: The debtor's display name.
  group:
    type: object
    nullable: True
    description: The debtor's counterparty group; `null` when they have none.
    properties:
      id:
        type: string
        format: uuid
        description: Internal group id.
      nameFa:
        type: string
        description: Persian group name.
    required:
      - id
      - nameFa
  debtTotal:
    $ref: #/components/schemas/Money
  available:
    $ref: #/components/schemas/Money
  availability:
    $ref: #/components/schemas/Availability
  currentGroup:
    type: object
    nullable: True
    description: The box this debtor is already allocated to, when there is one. Its `state` is what tells the operator whether a move is possible.
    properties:
      id:
        type: string
        format: uuid
        description: The other box's id.
      creditorName:
        type: string
        description: The other box's creditor.
      state:
        $ref: #/components/schemas/MatchGroupState
      allocated:
        $ref: #/components/schemas/Money
    required:
      - id
      - creditorName
      - state
      - allocated
  suggestedAmount:
    $ref: #/components/schemas/Money
  isSelectable:
    type: boolean
    description: Whether the picker may offer this row as a choice.
  blockReasonFa:
    type: string
    nullable: True
    description: Why the row cannot be picked, in Persian. `null` exactly when `isSelectable` is `true`.
required:
  - partyId
  - externalCode
  - displayName
  - group
  - debtTotal
  - available
  - availability
  - currentGroup
  - suggestedAmount
  - isSelectable
  - blockReasonFa

### AllocationMoveSide
type: object
description: One side of a cross-box move. `shortfall` repeats the box's own figure deliberately, so a client need not dig it out of the embedded box.
properties:
  group:
    $ref: #/components/schemas/Group
  shortfall:
    $ref: #/components/schemas/Money
  noticeFa:
    type: string
    nullable: True
    description: Pre-rendered Persian sentence naming the creditor and the gap — e.g. «حساب «مریم رضایی‌فر» اکنون … کسری دارد.» `null` when this side is fully covered.
required:
  - group
  - shortfall
  - noticeFa

### MatchingSettings
type: object
description: The parameters the next board build will use. Environment variables are the boot default; these are the live values.
properties:
  toleranceMinor:
    allOf:
      -
        $ref: #/components/schemas/Money
    description: How far an allocation total may sit from a target and still count. `0` means exact.
  eligibilityToleranceMinor:
    allOf:
      -
        $ref: #/components/schemas/Money
    description: How far a creditor's designated total may sit from their credit total and still qualify for a box. `0` means exact.
  maxDebtorsPerAccount:
    type: integer
    minimum: 1
    maximum: 8
    description: How many debtors the solver may combine into one account. The panel may set 1–8; the boot default accepts a wider range.
  minAllocationMinor:
    allOf:
      -
        $ref: #/components/schemas/Money
    description: The dust floor. An allocation below it is `422` unless the request sets `allowBelowMinimum`.
  allowPartial:
    type: boolean
    description: Whether the solver may leave a target partly covered.
  lockReservesWholeDebtor:
    type: boolean
    description: Whether freezing a box reserves its debtors' **whole** balance or only the part allocated. Defaults to the whole debtor.
  nodeBudget:
    type: integer
    description: Search nodes the solver may explore before returning a partial result.
  timeBudgetMs:
    type: integer
    description: Milliseconds the solver may spend before returning a partial result.
  autoRecomputeOnSync:
    type: boolean
    description: Whether a completed balance sync automatically triggers a global rebuild. Off by default.
required:
  - toleranceMinor
  - eligibilityToleranceMinor
  - maxDebtorsPerAccount
  - minAllocationMinor
  - allowPartial
  - lockReservesWholeDebtor
  - nodeBudget
  - timeBudgetMs
  - autoRecomputeOnSync

### BoardEnvelope
type: object
description: Success envelope around the live board.
properties:
  data:
    $ref: #/components/schemas/Board
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### BoardStatsEnvelope
type: object
description: Success envelope around the board's KPI header.
properties:
  data:
    $ref: #/components/schemas/BoardStats
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PoolEnvelope
type: object
description: Success envelope around the debtor pool and the ineligible creditors. Not paginated.
properties:
  data:
    type: object
    properties:
      unallocatedDebtors:
        type: array
        items:
          $ref: #/components/schemas/PoolDebtor
      orphanedBalanceCount:
        type: integer
        description: Debtor balances with no party record behind them.
      ineligibleCreditors:
        type: array
        items:
          $ref: #/components/schemas/IneligibleCreditor
    required:
      - unallocatedDebtors
      - orphanedBalanceCount
      - ineligibleCreditors
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### GroupEnvelope
type: object
description: Success envelope around one matching box.
properties:
  data:
    $ref: #/components/schemas/Group
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### GroupRecomputeEnvelope
type: object
description: Success envelope around a per-box rebuild, with the solver's statistics.
properties:
  data:
    type: object
    properties:
      group:
        $ref: #/components/schemas/Group
      runId:
        type: string
        format: uuid
        description: The `MatchRun` this rebuild recorded, readable at `GET /api/v1/matching/runs/{id}`.
      isPartialResult:
        type: boolean
        description: `true` when the solver stopped at its node or time budget.
      nodesVisited:
        type: integer
        description: Search nodes explored for this box.
      durationMs:
        type: integer
        description: Wall-clock milliseconds the rebuild took.
    required:
      - group
      - runId
      - isPartialResult
      - nodesVisited
      - durationMs
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### RecomputeQueuedEnvelope
type: object
description: Success envelope around a queued global rebuild.
properties:
  data:
    type: object
    properties:
      runId:
        type: string
        format: uuid
        description: Poll this at `GET /api/v1/matching/runs/{runId}`.
      status:
        type: string
        enum:
          - QUEUED
        description: Always `QUEUED`; the run itself starts in a background worker.
    required:
      - runId
      - status
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### MatchingRunEnvelope
type: object
description: Success envelope around one matching run.
properties:
  data:
    $ref: #/components/schemas/MatchingRun
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedMatchingRuns
type: object
description: A page of matching runs in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/MatchingRun
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### PaginatedCandidates
type: object
description: A page of candidate debtors in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/Candidate
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### AllocationMutationEnvelope
type: object
description: Success envelope around a created or re-sized allocation, plus the box it sits in — so the panel can re-render without a second call.
properties:
  data:
    type: object
    properties:
      allocation:
        $ref: #/components/schemas/Allocation
      group:
        $ref: #/components/schemas/Group
    required:
      - allocation
      - group
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### AllocationDeletionEnvelope
type: object
description: Success envelope around a deleted allocation. There is no row left to return, so the id it had is the receipt.
properties:
  data:
    type: object
    properties:
      id:
        type: string
        format: uuid
        description: The allocation id that was deleted.
      group:
        $ref: #/components/schemas/Group
    required:
      - id
      - group
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### AllocationMoveEnvelope
type: object
description: Success envelope around a cross-box move. Both sides come back, each with its new shortfall and notice.
properties:
  data:
    type: object
    properties:
      allocation:
        $ref: #/components/schemas/Allocation
      from:
        $ref: #/components/schemas/AllocationMoveSide
      to:
        $ref: #/components/schemas/AllocationMoveSide
    required:
      - allocation
      - from
      - to
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### MatchingSettingsEnvelope
type: object
description: Success envelope around the live matching settings.
properties:
  data:
    $ref: #/components/schemas/MatchingSettings
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### CreateAllocationRequest
type: object
description: A hand-placed allocation. **Strict** — an unrecognised key is `400`.
properties:
  targetId:
    type: string
    format: uuid
    description: The nominated account to allocate towards, from a box's `targets[].id`.
  debtorPartyId:
    type: string
    format: uuid
    description: The debtor to allocate, from the candidates feed.
  amountMinor:
    type: string
    pattern: ^\d+$
    description: A **positive integer string** in Rial minor units. Zero is rejected as `400`; a value the invariants refuse is `422`.
  allowBelowMinimum:
    type: boolean
    default: False
    description: Overrule the dust rule for this request only. Recorded in the audit row as `belowMinimumOverride`.
required:
  - targetId
  - debtorPartyId
  - amountMinor

### UpdateAllocationAmountRequest
type: object
description: A new amount for an existing allocation. **Strict**: the debtor and target cannot be changed here — use the move endpoint.
properties:
  amountMinor:
    type: string
    pattern: ^\d+$
    description: The new amount, as a positive integer string in Rial minor units.
  allowBelowMinimum:
    type: boolean
    default: False
    description: Overrule the dust rule for this request only. Audited.
required:
  - amountMinor

### MoveAllocationRequest
type: object
description: The destination for a cross-box move. **Strict**: only these three keys are accepted.
properties:
  toTargetId:
    type: string
    format: uuid
    description: The nominated account to move the debtor to.
  amountMinor:
    type: string
    pattern: ^\d+$
    description: How much to move. **Omit it** and the server moves `min(debtor.available + this allocation, destination remaining)`, which is the panel's default. Supplying it is the partial-move case.
  allowBelowMinimum:
    type: boolean
    default: False
    description: Overrule the dust rule for this request only. Audited.
required:
  - toTargetId


========================================================================
# Admin Matching API (app: matching-admin, version 1.0.0)
========================================================================

Servers: http://localhost:3000

The three decisions in the settlement matching engine that have money
attached: **freezing** a plan, **issuing payment orders** from it, and
**changing the engine's parameters**. Everything else about matching —
reading the board, rebuilding it, editing allocations by hand, exporting it —
is in the Matching API and is open to any operator holding `matching:*` day-to-day
permissions.

The split is deliberate. Building and editing a plan is accounting work.
Freezing one reserves debtors away from every other creditor; issuing turns
the plan into real third-party transfer instructions; and changing the
parameters changes what every future plan looks like. Those three sit one
level up.

## Authentication
Every endpoint requires a Bearer access token whose operator holds
`matching:lock`, `matching:issue-payments` or `matching:settings`. In the
seeded role set only **ADMIN** holds any of the three — `ACCOUNTANT` is
explicitly withheld all of them.

1. Obtain a token pair from `POST /api/v1/auth/login` — see the
   Authentication API. There is no separate staff login endpoint.
2. Send `Authorization: Bearer <accessToken>` on every request.
3. Refresh the pair through the same `POST /api/v1/auth/refresh`.

None of these three is a *dangerous* permission, so no password re-entry is
required. A missing, malformed or expired token returns `401`; a valid token
whose operator lacks the permission returns `403 PERMISSION_DENIED`, audited
with `outcome: DENIED`.

## Conventions
- Success bodies are the §9.1 envelope `{ data, meta }`.
- Every money value is a **string** in Rial minor units paired with a
  server-rendered Persian `formatted` string. Never parse either with
  `Number()`.
- Timestamps come back twice: an ISO-8601 UTC field and a `…Jalali` Persian
  calendar string.
- Lock and unlock require **`If-Match`** carrying the **group's** `version` —
  not an allocation's. A stale value is `409 CONCURRENT_MODIFICATION`.
- Issuing payments requires an **`Idempotency-Key`** header, and is safe to
  retry with or without the same key.
- `PATCH /api/v1/matching/settings` takes neither precondition: it is a
  settings write, not a row edit, and every change is audited with
  before/after.
- Freezing, unfreezing and issuing all return the **full group view** — the
  same shape `GET /api/v1/matching/groups/{id}` returns — so the panel can
  re-render the box without a second call.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## What locking means
- A `LOCKED` box is **invisible to the solver**: it comes out byte-identical
  from any recompute, global or per-box.
- The debtors it holds are **reserved** against every other box. Under the
  default `lockReservesWholeDebtor`, that includes their unallocated
  remainder, not just the amount allocated here.
- `edit` and `recompute` disappear from the box's `availableActions`
  **entirely**. No permission restores them — not even `ADMIN`'s allow-all.
  That is what makes the lock a real freeze rather than a permission gate.
- Locking moves **no money**. Issuing payment orders is the separate action
  below, under a separate permission.
- A box that has been frozen and released lands in `EDITED`, never back in
  `SUGGESTED`.

## x-issuance-idempotency
description: The two independent layers that make issuing payments safe to retry, from `src/modules/matching/issue-payments.service.ts`.
layers:
  -
    idempotency_key: The standard 24-hour replay every idempotent-create endpoint in this API uses. Same key plus same body returns the original response; same key plus a different body is 409 IDEMPOTENCY_KEY_CONFLICT.
  -
    allocation_payment_order_id: The durable fact underneath. Once an allocation carries a paymentOrderId it is reported in `skipped` regardless of which key asks — including a fresh key on a call that never saw the first response. Retry safety therefore does not depend on the caller reusing a key at all.
permission_check_first: markAsIssued is checked against payments:transition before any database work, so a permission refusal writes nothing.

## x-lock-semantics
description: What freezing a box does, from `src/modules/matching/group-lock.service.ts` and `state/available-actions.ts`.
effects:
  -
    invisible_to_solver: A LOCKED box is byte-identical across any recompute.
  -
    debtors_reserved: Its debtors are unavailable to every other box. Under the default lockReservesWholeDebtor, their unallocated remainder is reserved too — so locking one box can make several others short.
  -
    actions_removed: edit and recompute vanish from availableActions. No permission restores them, not even ADMIN's allow-all.
  -
    no_money_moves: Locking creates no payment order. Issuing is the separate action under matching:issue-payments.
  -
    unlock_state: A released box lands in EDITED, never back in SUGGESTED.
  -
    notes: The lock note is stored on the box; the unlock note is audited at NOTICE and not stored.

## Operations

### POST /api/v1/matching/groups/{id}/lock
operationId: adminLockMatchingGroup
auth: bearer
summary: Freeze a matching box.

Moves the box to `LOCKED`, reserving its debtors and making it immune to
every recompute.

**Notes:**
- Requires `matching:lock`, held only by `ADMIN` in the seeded roles.
- **`If-Match` is required**, carrying the **group's** `version` from
  `GET /api/v1/matching/groups/{id}`. Omitting it is
  `400 VALIDATION_FAILED`; a stale value is `409 CONCURRENT_MODIFICATION`.
- Locking an already-locked box is `409 MATCHING_GROUP_LOCKED` with the
  specific message "This match is already locked." — not a silent no-op.
- `note` is optional but strongly advised: it is stored on the box as
  `lockNote`, returned by every subsequent read, and copied into the audit
  row.
- After locking, `availableActions` contains only `unlock` and
  `issue-payments`. `edit` and `recompute` are gone and cannot be restored
  by any permission.
- The debtors in this box become unavailable to every other box. Under the
  default `lockReservesWholeDebtor`, their **whole** remaining balance is
  reserved, not just what is allocated here — so locking one box can make
  several others short.
- Locking moves no money and creates no payment order.
- Returns `200`, not `201`, with the full group view and an updated `ETag`.
- The schema is **strict**: only `note` is accepted.
- Side effects: `state` set to `LOCKED`, `lockedAt`, `lockedByOperatorId`
  and `lockNote` recorded, `version` incremented, the locked-group gauge
  moved, and an audit row written with the state change and the note.
request body: LockGroupRequest
responses:
  200: GroupEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/matching/groups/{id}/unlock
operationId: adminUnlockMatchingGroup
auth: bearer
summary: Release a frozen matching box.

Moves the box out of `LOCKED`, returning its reserved debtors to the pool.

**Notes:**
- Requires `matching:lock` — the same permission as locking — and the same
  **`If-Match`** on the group's `version`.
- Unlocking a box that is not `LOCKED` is
  `409 MATCHING_GROUP_NOT_LOCKED`.
- The box lands in **`EDITED`**, never back in `SUGGESTED`: it has been
  through a human decision and should not be presented as a fresh
  suggestion.
- `lockedAt`, `lockedByOperatorId` and `lockNote` are cleared.
- `note` is optional and, unlike the lock note, is **audited but not
  stored** — the reason for releasing lives in the audit trail, at
  `NOTICE` level, because it makes debtors available to other boxes again.
- Releasing does not recompute anything. The freed debtors become
  available to the **next** build; trigger one with
  `POST /api/v1/matching/recompute` or the per-box recompute.
- Returns `200` with the full group view and an updated `ETag`.
- The schema is **strict**: only `note` is accepted.
- Side effects: `state` set to `EDITED`, the lock fields cleared,
  `version` incremented, the locked-group gauge moved, and a `NOTICE`
  audit row written.
request body: UnlockGroupRequest
responses:
  200: GroupEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/matching/groups/{id}/issue-payments
operationId: adminIssueMatchingPayments
auth: bearer
summary: Issue payment orders from a frozen matching box.

Creates one payment order per allocation in the box, with the destination
account frozen from the box's own target snapshot.

**Notes:**
- Requires `matching:issue-payments`, held only by `ADMIN` in the seeded
  roles.
- The box **must be `LOCKED`**. Anything else is
  `409 MATCHING_GROUP_NOT_LOCKED` with the current state in `details[]`.
  Freeze it first — a plan that can still change is not an instruction.
- **`Idempotency-Key` is required.** Omitting it is
  `400 VALIDATION_FAILED` on that field.
- Idempotency works at **two levels**, and the second is the one that
  matters: the key gives the usual 24-hour replay, but each allocation's
  own `paymentOrderId` is the durable fact. Once set, that allocation is
  reported in `skipped` regardless of which key asks — including a fresh
  key on a call that never saw the first response. Retrying can never
  double-issue.
- Each created order is `settlementType: "THIRD_PARTY"`, with the payer
  being the allocated debtor, the payee the box's creditor, and
  `payeeAccountSnapshot` frozen from the target — not read live from the
  bank account.
- `markAsIssued: true` writes each order as `ISSUED` instead of `DRAFT`,
  and additionally requires the caller to hold **`payments:transition`**.
  Without it the call is `403 PERMISSION_DENIED` naming that permission,
  and **nothing is written** — the check runs before any database work.
- `totalMinor` is the sum of what was issued on this call, not the box's
  total: a partial retry reports only the new orders.
- Returns `200`, not `201`. A call in which every allocation was already
  issued still returns `200`, with an empty `issued` array and every id in
  `skipped`.
- Each issued payment order then lives in the Payments API and follows its
  state machine from there.
- The schema is **strict**: only `markAsIssued` is accepted.
- Side effects: one payment order per allocation created with a fresh
  `PAY-<jalali year>-<sequence>` reference, each allocation stamped with
  its `paymentOrderId`, the payment status counter moved, and audit rows
  written.
request body: IssuePaymentsRequest
responses:
  200: IssuePaymentsEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/matching/settings
operationId: adminUpdateMatchingSettings
auth: bearer
summary: Change the matching engine's parameters.

Updates the live settings the **next** board build will use, and says
whether a recompute is now warranted.

**Notes:**
- Requires `matching:settings`, held only by `ADMIN` in the seeded roles.
  Reading them needs only `matching:read` — see the Matching API.
- **Does not recompute the board.** `recomputeRequired` in the response
  tells the client a recompute is now warranted, and
  `recomputeHintFa` carries a ready-made Persian sentence for the panel to
  show. The existing board keeps reporting the **old** `params` it was
  actually built with, which is the honest thing for it to do.
- `recomputeRequired` is `false` when the request resubmitted the live
  values verbatim — a no-op edit does not ask for a rebuild.
- Environment variables are the boot default; this endpoint writes the
  live value, which wins from the next build onwards.
- Partial update: any subset of the nine fields may be sent. The schema is
  **strict**, so an unrecognised key is `400`.
- Money-valued settings are **non-negative integer strings** in Rial minor
  units, and `0` is explicitly allowed — it means "exact".
- `maxDebtorsPerAccount` is limited to **1–8** here, deliberately narrower
  than the range the boot default accepts: this is the range an operator
  may dial the live value into through the panel.
- `nodeBudget` and `timeBudgetMs` must be positive. Raising them lets the
  solver work longer before returning a `PARTIAL` result.
- `autoRecomputeOnSync: true` makes a completed balance sync trigger a
  global rebuild on its own. Off by default.
- Returns `200`, not `201`.
- Side effects: the changed settings persisted, and a
  `matching.settings.updated` audit row written with before/after values
  for each field that moved.
request body: UpdateMatchingSettingsRequest
responses:
  200: UpdateMatchingSettingsEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### Money
type: object
description: A Rial amount. The authoritative value is `minor`, a decimal **string** in minor units; `formatted` is the server-rendered Persian display string.
properties:
  minor:
    type: string
    description: Decimal string in Rial minor units, e.g. `"48250000000"`.
  formatted:
    type: string
    description: Persian rendering with digit grouping, e.g. `"۴٬۸۲۵٬۰۰۰٬۰۰۰ تومان"`.
required:
  - minor
  - formatted

### MatchGroupState
type: string
enum:
  - SUGGESTED
  - EDITED
  - LOCKED
description: `SUGGESTED` — as the solver built it. `EDITED` — a human has touched it, which is also where a released box lands. `LOCKED` — frozen: invisible to the solver and its debtors reserved.

### MatchQuality
type: string
enum:
  - EXACT_SINGLE
  - EXACT_COMBINATION
  - WITHIN_TOLERANCE
  - PARTIAL
  - MANUAL
description: How the allocation came about. `MANUAL` means a human placed it.

### MatchGroupAction
type: string
enum:
  - edit
  - recompute
  - lock
  - unlock
  - issue-payments
description: An action open on a box. `edit` and `recompute` are absent from a `LOCKED` box entirely — no permission restores them.

### Allocation
type: object
description: One debtor covering part or all of one nominated account.
properties:
  id:
    type: string
    format: uuid
    description: Internal id, preserved across a cross-box move.
  debtorPartyId:
    type: string
    format: uuid
    description: The debtor's party id — the payer on any payment order issued from this row.
  debtorExternalCode:
    type: string
    description: The debtor's accounting code, as a **string**.
  debtorName:
    type: string
    description: The debtor's name snapshotted when the allocation was made.
  amount:
    $ref: #/components/schemas/Money
  quality:
    $ref: #/components/schemas/MatchQuality
  qualityFa:
    type: string
    description: The quality in Persian, rendered server-side.
  isManual:
    type: boolean
    description: `true` for an allocation a human created or moved.
  isPartial:
    type: boolean
    description: `true` when this allocation covers only part of its target.
  paymentOrderId:
    type: string
    format: uuid
    nullable: True
    description: The payment order issued from this allocation. **This is the durable idempotency fact**: once set, the allocation is skipped by any further issuance call, whatever key asks.
  version:
    type: integer
    description: Optimistic-concurrency counter for this allocation.
required:
  - id
  - debtorPartyId
  - debtorExternalCode
  - debtorName
  - amount
  - quality
  - qualityFa
  - isManual
  - isPartial
  - paymentOrderId
  - version

### Target
type: object
description: One of the creditor's nominated bank accounts. Its snapshot is what a payment order's frozen destination is taken from.
properties:
  id:
    type: string
    format: uuid
    description: The target's id on this board.
  bankAccountId:
    type: string
    format: uuid
    description: The underlying bank account.
  bankNameFa:
    type: string
    nullable: True
    description: Persian bank name at build time; `null` when none was resolved.
  ibanMasked:
    type: string
    nullable: True
    description: The account's IBAN, masked. Nothing here reveals it.
  accountNumber:
    type: string
    nullable: True
    description: The account number, when one is recorded. Not masked.
  holderName:
    type: string
    description: The name on the account.
  target:
    $ref: #/components/schemas/Money
  allocated:
    $ref: #/components/schemas/Money
  allocations:
    type: array
    description: The debtors allocated towards this account.
    items:
      $ref: #/components/schemas/Allocation
  version:
    type: integer
    description: Write counter for this target row.
required:
  - id
  - bankAccountId
  - bankNameFa
  - accountNumber
  - holderName
  - target
  - allocated
  - allocations
  - version

### GroupCreditor
type: object
description: The creditor a box belongs to — the payee on every order issued from it.
properties:
  partyId:
    type: string
    format: uuid
    description: The creditor's party id.
  externalCode:
    type: string
    description: The creditor's accounting code, as a **string**.
  name:
    type: string
    description: The creditor's display name.
  group:
    type: object
    nullable: True
    description: The creditor's counterparty group; `null` when they have none.
    properties:
      id:
        type: string
        format: uuid
        description: Internal group id.
      nameFa:
        type: string
        description: Persian group name.
    required:
      - id
      - nameFa
  phones:
    type: array
    description: The creditor's contact numbers, primary first.
    items:
      type: object
      properties:
        e164:
          type: string
          description: Normalised international form, e.g. `+989365300484`.
        label:
          type: string
          nullable: True
          description: Operator-chosen label; `null` when unset.
        isPrimary:
          type: boolean
          description: At most one number per party carries `true`.
      required:
        - e164
        - label
        - isPrimary
required:
  - partyId
  - externalCode
  - name
  - group
  - phones

### Group
type: object
description: One creditor's box, as returned by every endpoint in this file so the panel can re-render without a second call.
properties:
  id:
    type: string
    format: uuid
    description: The box's id on the live board.
  colorKey:
    type: integer
    description: The colour slot the panel paints this box with.
  state:
    $ref: #/components/schemas/MatchGroupState
  stateFa:
    type: string
    description: The state in Persian, rendered server-side.
  basisDrifted:
    type: boolean
    description: `true` when the creditor's balance has changed since the board was built. Worth checking before freezing a plan.
  driftDetail:
    type: array
    nullable: True
    description: The diff behind `basisDrifted`; `null` when it is `false`.
    items:
      type: object
      additionalProperties: True
  creditor:
    $ref: #/components/schemas/GroupCreditor
  creditTotal:
    $ref: #/components/schemas/Money
  targetTotal:
    $ref: #/components/schemas/Money
  allocated:
    $ref: #/components/schemas/Money
  shortfall:
    $ref: #/components/schemas/Money
  fillPercent:
    type: number
    description: `allocated / targetTotal` as a percentage. A number, not money.
  targets:
    type: array
    description: The creditor's nominated accounts, each with its allocations.
    items:
      $ref: #/components/schemas/Target
  lockedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant the box was frozen; `null` once released.
  lockedAtJalali:
    type: string
    nullable: True
    description: The same instant as a Persian (Jalali) string; `null` once released.
  lockedByOperatorId:
    type: string
    format: uuid
    nullable: True
    description: Who froze the box; `null` once released.
  lockNote:
    type: string
    nullable: True
    description: The note supplied at lock time. **Stored** on the box, unlike the unlock note, which is only audited. Cleared on release.
  availableActions:
    type: array
    description: What **you** may do to this box. A `LOCKED` box lists only `unlock` and `issue-payments`.
    items:
      $ref: #/components/schemas/MatchGroupAction
  version:
    type: integer
    description: Optimistic-concurrency counter for the box — the value the lock and unlock endpoints require as `If-Match`.
required:
  - id
  - colorKey
  - state
  - stateFa
  - basisDrifted
  - driftDetail
  - creditor
  - creditTotal
  - targetTotal
  - allocated
  - shortfall
  - fillPercent
  - targets
  - lockedAt
  - lockedAtJalali
  - lockedByOperatorId
  - lockNote
  - availableActions
  - version

### IssuedPaymentRef
type: object
description: One payment order created by this call, linked back to the allocation it came from.
properties:
  allocationId:
    type: string
    format: uuid
    description: The allocation this order was issued from.
  paymentOrderId:
    type: string
    format: uuid
    description: The new payment order, readable through the Payments API.
  reference:
    type: string
    description: The order's human-readable reference, `PAY-<jalali year>-<sequence>`.
  status:
    type: string
    enum:
      - DRAFT
      - ISSUED
    description: `DRAFT` unless `markAsIssued` was requested — which additionally requires `payments:transition`.
  amount:
    $ref: #/components/schemas/Money
required:
  - allocationId
  - paymentOrderId
  - reference
  - status
  - amount

### MatchingSettings
type: object
description: The parameters the next board build will use.
properties:
  toleranceMinor:
    allOf:
      -
        $ref: #/components/schemas/Money
    description: How far an allocation total may sit from a target and still count. `0` means exact.
  eligibilityToleranceMinor:
    allOf:
      -
        $ref: #/components/schemas/Money
    description: How far a creditor's designated total may sit from their credit total and still qualify for a box. `0` means exact.
  maxDebtorsPerAccount:
    type: integer
    description: How many debtors the solver may combine into one account.
  minAllocationMinor:
    allOf:
      -
        $ref: #/components/schemas/Money
    description: The dust floor below which an allocation is refused with `422`.
  allowPartial:
    type: boolean
    description: Whether the solver may leave a target partly covered.
  lockReservesWholeDebtor:
    type: boolean
    description: Whether freezing a box reserves its debtors' **whole** balance or only the part allocated.
  nodeBudget:
    type: integer
    description: Search nodes the solver may explore before returning a partial result.
  timeBudgetMs:
    type: integer
    description: Milliseconds the solver may spend before returning a partial result.
  autoRecomputeOnSync:
    type: boolean
    description: Whether a completed balance sync automatically triggers a global rebuild.
required:
  - toleranceMinor
  - eligibilityToleranceMinor
  - maxDebtorsPerAccount
  - minAllocationMinor
  - allowPartial
  - lockReservesWholeDebtor
  - nodeBudget
  - timeBudgetMs
  - autoRecomputeOnSync

### GroupEnvelope
type: object
description: Success envelope around one matching box.
properties:
  data:
    $ref: #/components/schemas/Group
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### IssuePaymentsEnvelope
type: object
description: Success envelope around an issuance. `issued` and `skipped` together account for every allocation in the box.
properties:
  data:
    type: object
    properties:
      issued:
        type: array
        description: The payment orders created by **this** call. Empty on a full retry.
        items:
          $ref: #/components/schemas/IssuedPaymentRef
      skipped:
        type: array
        description: Allocation ids that already carried a payment order and were therefore left alone. This is what makes the endpoint safe to retry without reusing the key.
        items:
          type: string
          format: uuid
      totalMinor:
        allOf:
          -
            $ref: #/components/schemas/Money
        description: The sum of what was issued on **this** call — not the box's total. `"0"` on a full retry.
      group:
        $ref: #/components/schemas/Group
    required:
      - issued
      - skipped
      - totalMinor
      - group
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### UpdateMatchingSettingsEnvelope
type: object
description: Success envelope around a settings change, with the recompute hint the panel should show.
properties:
  data:
    type: object
    properties:
      settings:
        $ref: #/components/schemas/MatchingSettings
      recomputeRequired:
        type: boolean
        description: `true` when a value actually moved, so the live board no longer reflects the settings. `false` when the request resubmitted the live values verbatim.
      recomputeHintFa:
        type: string
        nullable: True
        description: Ready-made Persian sentence for the panel to show, or `null` when `recomputeRequired` is `false`.
    required:
      - settings
      - recomputeRequired
      - recomputeHintFa
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### LockGroupRequest
type: object
description: Optional body. **Strict** — only `note` is accepted. The note is stored on the box and repeated in the audit row.
properties:
  note:
    type: string
    minLength: 1
    maxLength: 2000
    description: Why this plan was frozen. Returned by every later read as `lockNote`.

### UnlockGroupRequest
type: object
description: Optional body. **Strict** — only `note` is accepted. Unlike the lock note, this one is **audited but not stored**.
properties:
  note:
    type: string
    minLength: 1
    maxLength: 2000
    description: Why the plan was released. Written to the audit trail at `NOTICE` level; never returned by a later read.

### IssuePaymentsRequest
type: object
description: Optional body. **Strict** — only `markAsIssued` is accepted. Send `{}` (or omit the body) to create the orders as drafts.
properties:
  markAsIssued:
    type: boolean
    default: False
    description: Write each new order as `ISSUED` instead of `DRAFT`. Additionally requires the caller to hold `payments:transition`; without it the whole call is `403` and nothing is written.

### UpdateMatchingSettingsRequest
type: object
description: Partial update of the live engine parameters. **Strict** — an unrecognised key is `400`. Every field is optional; sending the live values verbatim is a no-op that reports `recomputeRequired: false`.
properties:
  toleranceMinor:
    type: string
    pattern: ^\d+$
    description: Non-negative integer string in Rial minor units. `"0"` means exact matching.
  eligibilityToleranceMinor:
    type: string
    pattern: ^\d+$
    description: Non-negative integer string in Rial minor units. `"0"` means exact.
  maxDebtorsPerAccount:
    type: integer
    minimum: 1
    maximum: 8
    description: How many debtors the solver may combine into one account. Limited to 1–8 here — deliberately narrower than the boot default's range.
  minAllocationMinor:
    type: string
    pattern: ^\d+$
    description: The dust floor, as a non-negative integer string in Rial minor units. `"0"` disables it.
  allowPartial:
    type: boolean
    description: Whether the solver may leave a target partly covered.
  lockReservesWholeDebtor:
    type: boolean
    description: Whether freezing a box reserves its debtors' whole balance. Turning it off frees remainders for other boxes but makes a frozen plan less insulated.
  nodeBudget:
    type: integer
    minimum: 1
    description: Search nodes before the solver returns a partial result.
  timeBudgetMs:
    type: integer
    minimum: 1
    description: Milliseconds before the solver returns a partial result.
  autoRecomputeOnSync:
    type: boolean
    description: Whether a completed balance sync triggers a global rebuild on its own.


========================================================================
# Admin Operators API (app: operators-admin, version 1.0.0)
========================================================================

Servers: http://localhost:3000

Management of **operators** — the panel staff who sign in and hold
permissions. §4.1 — this is not the counterparty list: a *party* is an
external entity mirrored from the accounting system, never signs in, and is
managed through the Parties API. The route segment is `operators` for exactly
that reason.

## Authentication
Every endpoint requires a Bearer access token whose operator holds the
`operators:*` permission for the action. In the seeded role set only **ADMIN**
holds any of them; `ACCOUNTANT` and `VIEWER` hold none.

1. Obtain a token pair from `POST /api/v1/auth/login` — see the
   Authentication API. There is no separate staff login endpoint.
2. Send `Authorization: Bearer <accessToken>` on every request.
3. Refresh the pair through the same `POST /api/v1/auth/refresh`.
4. Before `DELETE /api/v1/operators/{id}`, call `POST /api/v1/auth/reauth`
   with the password — `operators:delete` is a dangerous permission and
   refuses to run without a re-authentication in the last five minutes.

A missing, malformed or expired token returns `401`. A valid token whose
operator lacks the permission returns `403 PERMISSION_DENIED`, and every such
denial is written to the audit log with `outcome: DENIED`.

## Conventions
- Success bodies are the §9.1 envelope `{ data, meta }`; the list adds
  `page`/`pageSize`/`total`/`totalPages` to `meta`.
- Timestamps (`createdAt`, `updatedAt`, `lastLoginAt`, `lockedUntil`,
  `deletedAt`) are **ISO-8601 UTC** strings with milliseconds, e.g.
  `"2026-08-21T09:27:33.104Z"`. `null` means the event never happened.
- `passwordHash` is **never** serialised. The response type is an explicit
  projection precisely so that the hash has to be added to leak, rather than
  removed to stay safe.
- A generated password is returned as `temporaryPassword` **once**, in the
  body of the request that created it. It is not persisted in plaintext, not
  logged, and not written to the audit row.
- `PATCH /api/v1/operators/{id}` requires `If-Match` carrying the row's
  `version`, echoed as the `ETag` response header on every single-operator
  read. A stale value is `409 CONCURRENT_MODIFICATION`.
- Deletion is **soft**: the row survives with `deletedAt` set and `status`
  forced to `DISABLED`, so every audit entry naming this operator keeps its
  snapshotted username. Soft-deleted rows are hidden from the list unless
  `includeDeleted=true`.
- `username` is immutable and `status` is not a patchable field. Renaming
  would make the audit trail read as two people; status moves through
  `/suspend` and `/activate` so each transition is an auditable act.
- Two lockouts apply to every account-disabling action: an operator may not
  act on their **own** account (`409 OPERATOR_SELF_ACTION`), and the **last
  active ADMIN** may not be deleted, suspended, or moved to another role
  (`409 OPERATOR_LAST_ADMIN`).
- Validation failures are `400 VALIDATION_FAILED` with one `details[]` entry
  per offending field.

## x-account-lockouts
description: The two refusals that exist to stop an administrator locking the whole panel out, from `src/modules/operators/operators.service.ts`.
rules:
  -
    self_action: OPERATOR_SELF_ACTION (409) — an operator may not delete or suspend their own account. Password reset and profile edits on your own account are allowed.
  -
    last_admin: OPERATOR_LAST_ADMIN (409) — the last operator who is ACTIVE, not soft-deleted, and holding the ADMIN role key may not be deleted, suspended, or moved to a non-ADMIN role.
  -
    dangerous_permission: operators:delete requires POST /api/v1/auth/reauth within the last five minutes. ADMIN is not exempt: all three dangerous permissions are held only by ADMIN, so exempting it would make the rule unreachable.

## Operations

### GET /api/v1/operators
operationId: adminListOperators
auth: bearer
summary: List panel operators.

Returns a page of operators ordered by `createdAt` ascending, each with
its role summary and its current lockout state.

**Notes:**
- Requires `operators:read`.
- Soft-deleted operators are hidden unless `includeDeleted=true`.
- `search` is a plain case-insensitive substring match over `username`,
  `email` and `fullName`. It deliberately does **not** use the Persian
  search folding applied to parties: operator usernames are ASCII by
  policy and there is no folded column to compare against.
- `includeDeleted` accepts the literal strings `true` / `false` only; any
  other value is `400`. The other filters coerce loosely.
- `failedLoginCount`, `lockedUntil` and `isLocked` are on every row so an
  administrator can see why a colleague cannot sign in without opening
  the audit log.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100. A page
  past the end returns an empty `data` array, not `404`.
- Side effects: none.
responses:
  200: PaginatedOperators
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/operators
operationId: adminCreateOperator
auth: bearer
summary: Create a panel operator.

Creates a staff account against an existing panel role, and either
accepts a password or generates a one-time one.

**Notes:**
- Requires `operators:create`.
- Omit `password` and the server generates a 16-character one-time value
  from an unambiguous alphabet (no `0`/`O`, no `1`/`l`/`I`) and returns it
  as `temporaryPassword` in **this response and nowhere else** — not in a
  log line, not in the audit row. This is the recommended path: a
  generated password cannot be one the creating administrator already
  knows.
- A supplied `password` must satisfy `PASSWORD_MIN_LENGTH` (10 by
  default) or the request is `400 VALIDATION_FAILED`.
- `mustChangePassword` defaults to **`true`** — whatever the administrator
  typed is treated as a one-time value.
- `username` must be 3–64 characters of lowercase letters, digits, dot,
  dash or underscore, starting and ending alphanumerically. ASCII by
  policy: a username is typed on a keyboard whose layout may have just
  been switched, and a Persian identifier with two visually identical
  spellings is a support call. The Persian goes in `fullName`.
- A username already taken — including by a **soft-deleted** operator —
  is `409 OPERATOR_USERNAME_TAKEN`.
- An unknown `roleId` is `404 RESOURCE_NOT_FOUND` naming `PanelRole`.
- Side effects on success: the operator row is created, an
  `operator.created` audit row is written (recording the field names only,
  never the password), and the RBAC cache is primed for the new id.
request body: CreateOperatorRequest
responses:
  201: OperatorWithTemporaryPasswordEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/operators/{id}
operationId: adminGetOperator
auth: bearer
summary: Retrieve one panel operator.

Returns a single operator, including soft-deleted ones.

**Notes:**
- Requires `operators:read`.
- Sets the `ETag` response header to the row's `version`, quoted — e.g.
  `ETag: "2"`. Send that value back as `If-Match` on the subsequent
  `PATCH`.
- Unlike the list, this endpoint does **not** hide soft-deleted
  operators: an audit trail naming a deleted operator has to remain
  followable.
- `isLocked` is computed against the request's clock, so it can be `false`
  while `lockedUntil` still holds a past timestamp.
- Side effects: none.
responses:
  200: OperatorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/operators/{id}
operationId: adminDeleteOperator
auth: bearer
summary: Soft-delete an operator.

Marks the operator deleted, forces their status to `DISABLED`, and revokes
every session they hold. Returns the deleted row rather than `204`, so the
panel can render the resulting state without a re-read.

**Notes:**
- Requires `operators:delete`, which is a **dangerous** permission:
  `POST /api/v1/auth/reauth` must have succeeded on this access token
  within the last five minutes, otherwise the call is
  `403 AUTH_REAUTH_REQUIRED`. `ADMIN` is not exempt from this.
- Never a hard delete. The row is retained so that every audit entry
  naming this operator keeps its snapshotted username.
- Refused for **your own account** (`409 OPERATOR_SELF_ACTION`) and for
  the **last active ADMIN** (`409 OPERATOR_LAST_ADMIN`).
- Deleting an already-deleted operator succeeds and simply re-stamps
  `deletedAt`; it is not treated as a conflict.
- Does not require `If-Match`.
- Side effects: `deletedAt` set, `status` forced to `DISABLED`, `version`
  incremented, **every** refresh-token family for this operator revoked
  (a soft-deleted operator holding a live refresh token would still be a
  working account), an `operator.deleted` audit row written, and the RBAC
  cache invalidated.
responses:
  200: OperatorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/operators/{id}
operationId: adminUpdateOperator
auth: bearer
summary: Update an operator's display name, email or role.

Applies a partial update under an optimistic-concurrency precondition.
Only three fields are editable here.

**Notes:**
- Requires `operators:update`.
- **`If-Match` is required**, not merely honoured when present. Omitting it
  is `400 VALIDATION_FAILED` on the `If-Match` field; a stale value is
  `409 CONCURRENT_MODIFICATION` carrying both the expected and the actual
  version. `W/"2"`, `"2"`, `2` and `*` are all accepted spellings.
- At least one of `fullName`, `email`, `roleId` must be sent.
- `username` is **not** editable: it is snapshotted into every audit row
  this operator has ever caused, and renaming it would leave the trail
  reading as two different people.
- `status` is **not** editable either; it moves through `/suspend` and
  `/activate` so that each transition is an auditable act rather than a
  field assignment.
- Moving the **last active ADMIN** to a non-ADMIN role is refused with
  `409 OPERATOR_LAST_ADMIN` — it locks everyone out exactly as
  effectively as deleting them. Moving them to another ADMIN-keyed role is
  allowed.
- An unknown `roleId` is `404 RESOURCE_NOT_FOUND` naming `PanelRole`.
- Side effects on success: `version` incremented, `updatedByOperatorId`
  recorded, an `operator.updated` audit row written with a before/after
  diff of the changed fields, and the target's cached RBAC stamp
  invalidated so a role change bites on their very next request.
- The response carries the refreshed row and an updated `ETag` header.
request body: UpdateOperatorRequest
responses:
  200: OperatorEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/operators/{id}/suspend
operationId: adminSuspendOperator
auth: bearer
summary: Suspend an operator and end their sessions immediately.

Sets `status` to `SUSPENDED` and revokes every live refresh token the
operator holds.

**Notes:**
- Requires `operators:update`. Takes no request body.
- Returns `200`, not `201` — nothing is created.
- The suspended operator is refused on their **next request**, not when
  their access token expires: the authentication guard re-reads live
  status on every call.
- Refused for your own account (`409 OPERATOR_SELF_ACTION`) and for the
  last active ADMIN (`409 OPERATOR_LAST_ADMIN`).
- Suspending an already-suspended operator succeeds and is idempotent in
  effect, though it still increments `version` and writes an audit row.
- Side effects: `status` set to `SUSPENDED`, `version` incremented, every
  refresh-token family revoked, an `operator.suspended` audit row written
  recording the status change and the number of sessions revoked, and the
  RBAC cache invalidated.
responses:
  200: OperatorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/operators/{id}/activate
operationId: adminActivateOperator
auth: bearer
summary: Reactivate a suspended operator and clear their lockout.

Sets `status` back to `ACTIVE` and clears the failed-sign-in counter and
any lock still in force.

**Notes:**
- Requires `operators:update`. Takes no request body.
- Returns `200`. The lockout is cleared deliberately: reactivating
  somebody who is locked out and cannot say why is not reactivating them.
- Sessions are **not** restored. The operator must sign in again — the
  suspension revoked every refresh token.
- Unlike suspend and delete, this has no self-action or last-admin guard:
  re-enabling an account can never lock the panel.
- This does not clear `mustChangePassword`; only a password change does.
- A soft-deleted operator can be set back to `ACTIVE` here, but
  `deletedAt` remains set and the account still cannot sign in.
- Side effects: `status` set to `ACTIVE`, `failedLoginCount` reset to 0,
  `lockedUntil` cleared, `version` incremented, an `operator.activated`
  audit row written, and the RBAC cache invalidated.
responses:
  200: OperatorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/operators/{id}/reset-password
operationId: adminResetOperatorPassword
auth: bearer
summary: Reset an operator's password and end their sessions.

Replaces the operator's password with a supplied or server-generated
value, forces a change at next sign-in, clears the lockout, and revokes
every session.

**Notes:**
- Requires `operators:reset-password`.
- Omit `newPassword` and the server generates a 16-character one-time
  value, returned as `temporaryPassword` in **this response only**. It is
  never persisted in plaintext, never logged, never audited.
- A supplied `newPassword` must satisfy `PASSWORD_MIN_LENGTH` (10 by
  default), or the request is `400 VALIDATION_FAILED`.
- The lockout counter is cleared as well. "I am locked out" and "I have
  forgotten my password" arrive as the same phone call, and a reset that
  leaves the lock in place has not fixed anything the caller can see.
- There is no self-action guard: an administrator may reset their own
  password here, though `POST /api/v1/auth/change-password` is the normal
  route for that and does not revoke the current session.
- Side effects: password re-hashed, `mustChangePassword` set to `true`,
  `failedLoginCount` reset to 0, `lockedUntil` cleared, `version`
  incremented, **every** refresh-token family for the target revoked, and
  an `operator.password_reset` audit row written that records the field
  name only.
request body: ResetOperatorPasswordRequest
responses:
  200: OperatorWithTemporaryPasswordEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PaginationMeta
type: object
description: The §9.1 success envelope's `meta` for an offset-paginated list. `totalPages` is derived by the envelope interceptor, never by a handler.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total rows matching the filter across all pages.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when `total` is 0.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - page
  - pageSize
  - total
  - totalPages
  - requestId
  - timestamp

### OperatorStatus
type: string
enum:
  - ACTIVE
  - SUSPENDED
  - DISABLED
description: `ACTIVE` may sign in. `SUSPENDED` is an administrative hold. `DISABLED` is set automatically on soft deletion. Only `ACTIVE` passes the authentication guard.

### RoleSummary
type: object
description: The panel role this operator holds, reduced to what a staff list needs.
properties:
  id:
    type: string
    format: uuid
    description: Internal role id; the value to send as `roleId` when reassigning.
  key:
    type: string
    description: Stable role key, e.g. `ADMIN`, `ACCOUNTANT`, `VIEWER`.
  nameFa:
    type: string
    description: Persian display name, shown in the panel.
required:
  - id
  - key
  - nameFa

### Operator
type: object
description: A panel staff account. `passwordHash` is deliberately absent from this projection and is never serialised anywhere in the API.
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7). Stable for the row's whole life, including after soft deletion.
  username:
    type: string
    description: Sign-in name; 3–64 characters of lowercase letters, digits, dot, dash and underscore. Immutable after creation, because it is snapshotted into every audit row this operator causes.
  fullName:
    type: string
    description: Display name, usually Persian. This is where the Persian goes, not in `username`.
  email:
    type: string
    format: email
    nullable: True
    description: Optional contact address. `null` when never set or explicitly cleared.
  status:
    $ref: #/components/schemas/OperatorStatus
  mustChangePassword:
    type: boolean
    description: `true` after creation with an administrator-set or generated password, and after any password reset. Cleared only by `POST /api/v1/auth/change-password`.
  role:
    $ref: #/components/schemas/RoleSummary
  lastLoginAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant of the last successful sign-in; `null` if they never have.
  lastLoginIp:
    type: string
    nullable: True
    description: Client IP recorded at the last successful sign-in; `null` if unavailable.
  failedLoginCount:
    type: integer
    description: Consecutive failed sign-in attempts. Not reset when a lock expires — that is what makes the lockout escalate on repeat. Cleared by a successful sign-in, a password change, a reset, or reactivation.
  lockedUntil:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant the current lock expires. May hold a **past** timestamp once the lock has lapsed; read `isLocked` for the live answer.
  isLocked:
    type: boolean
    description: Computed server-side against the request's clock — `lockedUntil` is in the future.
  deletedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant of soft deletion; `null` for a live operator. A soft-deleted operator is hidden from the list by default but stays readable by id.
  version:
    type: integer
    description: Optimistic-concurrency counter, incremented on every write. Returned as the `ETag` header and required back as `If-Match` on `PATCH`.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write of any kind.
required:
  - id
  - username
  - fullName
  - email
  - status
  - mustChangePassword
  - role
  - lastLoginAt
  - lastLoginIp
  - failedLoginCount
  - lockedUntil
  - isLocked
  - deletedAt
  - version
  - createdAt
  - updatedAt

### OperatorEnvelope
type: object
description: Success envelope around a single operator.
properties:
  data:
    $ref: #/components/schemas/Operator
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### OperatorWithTemporaryPasswordEnvelope
type: object
description: Success envelope around an operator plus, when the server generated one, the one-time password. The only place that value ever appears.
properties:
  data:
    type: object
    properties:
      operator:
        $ref: #/components/schemas/Operator
      temporaryPassword:
        type: string
        description: Present **only** when no password was supplied in the request. 16 characters from an unambiguous alphabet (no `0`/`O`, no `1`/`l`/`I`), because it will be read aloud or copied off a screen at least once. Shown here and nowhere else.
    required:
      - operator
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedOperators
type: object
description: A page of operators in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/Operator
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### CreateOperatorRequest
type: object
description: A new staff account. Omitting `password` is the recommended path: the server then generates a one-time value the creating administrator cannot already know.
properties:
  username:
    type: string
    minLength: 3
    maxLength: 64
    pattern: ^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$
    description: Lowercase letters, digits, dot, dash and underscore; must start and end alphanumerically. ASCII by policy — the Persian belongs in `fullName`.
  fullName:
    type: string
    minLength: 1
    maxLength: 160
    description: Display name, usually Persian.
  email:
    type: string
    format: email
    maxLength: 254
    description: Optional contact address.
  roleId:
    type: string
    format: uuid
    description: Internal id of an existing panel role. Unknown ids are `404`.
  password:
    type: string
    minLength: 1
    maxLength: 512
    description: Optional initial password. Must meet `PASSWORD_MIN_LENGTH` (10 by default). Omit it to have the server generate one and return it once.
  mustChangePassword:
    type: boolean
    default: True
    description: Whether the operator must change their password at first sign-in. Defaults to `true`: whatever the administrator typed is a one-time value.
required:
  - username
  - fullName
  - roleId

### UpdateOperatorRequest
type: object
description: Partial update; any subset of these three fields may be sent, but at least one must be. `username` and `status` are deliberately not editable here.
properties:
  fullName:
    type: string
    minLength: 1
    maxLength: 160
    description: New display name.
  email:
    type: string
    format: email
    maxLength: 254
    nullable: True
    description: New contact address, or `null` to clear it.
  roleId:
    type: string
    format: uuid
    description: Reassign the operator to another panel role. Moving the last active ADMIN out of an ADMIN-keyed role is `409 OPERATOR_LAST_ADMIN`.

### ResetOperatorPasswordRequest
type: object
description: Optional body. Send `{}` (or omit the body) to have the server generate and return a one-time password.
properties:
  newPassword:
    type: string
    minLength: 1
    maxLength: 512
    description: The password to set. Must meet `PASSWORD_MIN_LENGTH` (10 by default). Omit to have one generated.


========================================================================
# Parties API (app: parties, version 1.0.0)
========================================================================

Servers: http://localhost:3000

The counterparty list — «لیست کاربران» in the panel sidebar. A **party** is an
external entity mirrored from the accounting system: it never signs in, holds
no permission, and has balances. §4.1 — the other population in this system is
the *operator* (panel staff), which is unrelated and lives in the Admin
Operators API. The route segment is `parties` for exactly that reason; there
is no `/users` anywhere in this API.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. Operator searches or filters the list with
   `GET /api/v1/parties?q=…&groupId=…`, which ranks matches by relevance.
3. Operator opens one row with `GET /api/v1/parties/{id}`, which adds the
   party's note, address, bank-account summary, latest balance headline and
   payment counts, and returns the row's `version` in the `ETag` header.
4. Operator corrects the locally owned fields with
   `PATCH /api/v1/parties/{id}`, echoing that `ETag` back as `If-Match`.
5. Operator manages contact numbers under
   `/api/v1/parties/{id}/phones` — list, add, edit, soft-delete, and nominate
   one as primary.
6. Excluding a party from the matching engine is a separate, administrator-only
   act; see the Admin Parties API.

## Security Notes
- Every endpoint requires a valid Bearer access token. Reads need
  `parties:read`; edits need `parties:update`; anything under `/phones` needs
  `parties:manage-phones`. In the seeded role set `ACCOUNTANT` holds all
  three and `VIEWER` holds only `parties:read`.
- There is no ownership scoping here: an operator who holds the permission
  sees every party. An unknown id is `404 RESOURCE_NOT_FOUND`.
- **Only local fields are editable.** `displayName`, `externalCode`,
  `groupId`, `city`, `address` and `nationalId` are mirrored from the
  accounting system and are read-only. The update schema is strict, so
  attempting to set one is `400 VALIDATION_FAILED` rather than a silent drop.
- Editing any of `firstName`, `lastName` or `honorific` flips `nameSource` to
  `MANUAL`, which makes the party **permanently** immune to the next sync's
  name-split re-parse. The response says so explicitly via
  `nameSourceChanged`, because the caller could not otherwise tell that a
  routine correction had that consequence.
- `nationalId` is **masked** in every response (only the tail is shown), the
  same treatment an IBAN gets. There is no reveal endpoint for it.
- Phone numbers are stored normalised to E.164 while `rawInput` keeps exactly
  what was typed. `display` is a Persian-digit national rendering
  (`۰۹۱۲۳۴۵۶۷۸۹`) intended for reading, never for parsing back.
- A duplicate phone on the **same** party is `409 PHONE_DUPLICATE`. The same
  number on a **different** party is allowed and is not an error — two family
  members sharing a handset is a real situation.
- Phones created here are always `source: MANUAL`, which is what guarantees no
  sync will ever touch, deactivate or overwrite them.
- Deleting a phone is a **soft** delete: the row survives with `deletedAt` set
  and disappears from the list.
- Search is normalised through the same Persian folding that wrote the stored
  `search_name` column, so an Arabic-yeh query matches a Persian-yeh name and
  vice versa. Sending `q` overrides `sort`; relevance always wins.
- Every success is the §9.1 envelope `{ data, meta }`; every failure is
  `{ error: { code, message, messageFa, status, details, requestId } }` with
  both an English and a Persian sentence.
- Timestamps are ISO-8601 UTC strings with milliseconds. Monetary values in
  the embedded balance summary are decimal **strings** in Rial minor units;
  never parse them with `Number()`.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## x-field-ownership
description: Which columns the accounting sync owns and which the panel owns, from `src/modules/parties/dto/party.dto.ts` and the party ingest service. The split is the single most consequential thing to understand about this module.
mirrored_read_only:
  -
    displayName: overwritten by the accounting export on every sync run
  -
    externalCode: the accounting system's key; the join column for balances
  -
    groupId: the party's group, mirrored from the export
  -
    city: mirrored
  -
    address: mirrored
  -
    nationalId: mirrored, and masked in every response
  -
    isActive: mirrored
locally_owned:
  -
    firstName: editable; setting it flips nameSource to MANUAL
  -
    lastName: editable; setting it flips nameSource to MANUAL
  -
    honorific: editable; setting it flips nameSource to MANUAL
  -
    nature: editable; does not affect nameSource
  -
    note: editable; never sent to the accounting system
  -
    phones: fully operator-owned; every row created here is source MANUAL
name_source_rule: Once nameSource is MANUAL the sync's name-split re-parse skips this party permanently. The PATCH response reports the flip as nameSourceChanged so the caller is not left to infer it.

## Operations

### GET /api/v1/parties
operationId: listParties
auth: bearer
summary: List and search parties.

Returns a page of parties with their group and phone summaries. Supports
a relevance-ranked Persian search plus structural filters.

**Notes:**
- Requires `parties:read`.
- `q` is folded through the same Persian normalisation that wrote the
  stored `search_name` column, so an Arabic-yeh query finds a
  Persian-yeh name and vice versa. Results are ranked exact match first,
  then prefix, then substring/trigram similarity, with a stable
  tiebreaker so paging never reshuffles a tied rank.
- `sort` is **ignored when `q` is supplied** — search relevance always
  wins. Without `q` the default ordering is the `sort` value, or the
  service's own default when that is absent too.
- Filters combine with AND. An unknown enum value on `nature` or
  `nameConfidence` is `400`, not silently ignored.
- `hasPhone` and `isActive` coerce loosely: `"true"`, `"1"` and similar
  truthy strings are accepted.
- `phones` on each row is the reduced summary shape (id, E.164, Persian
  display, primary flag, source), not the full phone record. Read
  `/api/v1/parties/{id}/phones` for everything else.
- `hasBalance` says whether any balance snapshot exists for this party's
  external code; it does not say what the balance is.
- `nationalId` is not on the list projection at all — only on the detail
  view, and masked there.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100. A page
  past the end returns an empty `data` array, not `404`.
- Side effects: none.
responses:
  200: PaginatedParties
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/parties/{id}
operationId: getParty
auth: bearer
summary: Retrieve one party with its financial and contact context.

Returns a single party plus everything the detail screen needs: the note
and address fields, a masked national id, a bank-account count, the
headline of the latest balance snapshot, and how many payments name this
party on each side.

**Notes:**
- Requires `parties:read`.
- Sets the `ETag` response header to the row's `version`, quoted — e.g.
  `ETag: "3"`. Send that value back as `If-Match` on the subsequent
  `PATCH`.
- `nationalId` is **masked**: only the tail is returned, and there is no
  endpoint that reveals it in full. It is `null` when the accounting
  export never carried one.
- `latestBalance` is `null` when no balance snapshot exists for this
  party's external code. When present, `irrAmount` is a decimal **string**
  in Rial minor units and `headlineStatus` derives from the Rial line
  only — a party can be a Rial debtor and a gold creditor at once.
- `bankAccounts` is a count and a "has a default" flag, not the accounts
  themselves. Read them from
  `GET /api/v1/parties/{partyId}/bank-accounts`.
- `paymentCounts` counts payment orders in which this party is the payer
  and the payee, in every state including drafts.
- `isExcludedFromMatching` and `matchExclusionReason` are read here but
  set through the administrator-only endpoints in the Admin Parties API.
- Side effects: none.
responses:
  200: PartyDetailEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/parties/{id}
operationId: updateParty
auth: bearer
summary: Edit a party's locally owned fields.

Applies a partial update to the five fields this system owns, under an
optimistic-concurrency precondition, and reports whether the edit made the
party immune to sync's name re-parse.

**Notes:**
- Requires `parties:update`.
- **`If-Match` is required**. Omitting it is `400 VALIDATION_FAILED` on
  the `If-Match` field; a stale value is `409 CONCURRENT_MODIFICATION`
  carrying both the expected and the actual version. `W/"3"`, `"3"`, `3`
  and `*` are all accepted spellings.
- Only `firstName`, `lastName`, `honorific`, `nature` and `note` are
  accepted. The schema is **strict**: sending `displayName`,
  `externalCode`, `groupId`, `city`, `address` or `nationalId` is
  `400 VALIDATION_FAILED`, not a silent drop — those are mirrored from the
  accounting export and the next sync would overwrite them anyway.
- At least one field must be supplied.
- `firstName`, `lastName`, `honorific` and `note` accept `null` to clear
  the value.
- Editing any of `firstName`, `lastName` or `honorific` on a party whose
  `nameSource` is not already `MANUAL` flips it to `MANUAL`, which makes
  the party **permanently** immune to the next sync's name-split re-parse.
  `nameSourceChanged: true` in the response says this request is what did
  it; it is `false` on every later edit of the same party.
- Side effects: `version` incremented, `updatedByOperatorId` recorded, and
  a `party.name_overridden` audit row written with a before/after diff and
  the `nameSourceChanged` flag.
- The response carries the refreshed detail view and an updated `ETag`.
request body: UpdatePartyRequest
responses:
  200: PartyUpdateEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/parties/{id}/phones
operationId: listPartyPhones
auth: bearer
summary: List a party's contact numbers.

Returns every live phone on the party, primary first and then oldest
first, in the full record shape rather than the summary embedded on a
party row.

**Notes:**
- Requires `parties:manage-phones` — note this is the *manage* permission,
  not `parties:read`. A `VIEWER` cannot open this list even though the
  reduced phone summary is visible on the party row.
- **Not paginated** — `data` is a plain array. A party has a handful of
  numbers.
- Soft-deleted phones are excluded.
- Ordering is `isPrimary` descending, then `createdAt` ascending, so the
  primary number is always first.
- Compared with the summary on a party row, this shape adds `rawInput`,
  `label`, `note`, `isActive`, `normalizationApplied`, `version` and the
  timestamps.
- Side effects: none.
responses:
  200: PartyPhoneListEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/parties/{id}/phones
operationId: createPartyPhone
auth: bearer
summary: Add a contact number to a party.

Normalises an Iranian mobile number to E.164, keeps the original text
verbatim, and attaches it to the party as an operator-owned record.

**Notes:**
- Requires `parties:manage-phones`.
- `rawInput` is stored exactly as typed; `e164` is the normalised form and
  `normalizationApplied` records which transformations were applied
  (Persian/Arabic digit folding, space stripping, national-to-E.164).
- Persian and Arabic-Indic digits are accepted. A blank value is rejected
  by the schema, never silently coerced into "no row".
- A value that cannot be read as an Iranian mobile number is
  `400 VALIDATION_FAILED` with the offending text echoed in
  `details[].value`.
- A number already live on **this** party is `409 PHONE_DUPLICATE`. The
  same number on a **different** party is allowed — two people sharing a
  handset is a real situation, not a data error.
- Re-adding a number that was previously soft-deleted on this party
  revives that row rather than creating a second one; its `id` and
  `createdAt` are the original ones.
- The number is always created `source: MANUAL` and never `isPrimary`;
  nominate it with `POST /api/v1/parties/{id}/phones/{phoneId}/set-primary`.
- Side effects: the phone row is created or revived, and a
  `party.phone_added` audit row is written.
request body: CreatePartyPhoneRequest
responses:
  201: PartyPhoneEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/parties/{id}/phones/{phoneId}
operationId: deletePartyPhone
auth: bearer
summary: Remove a contact number.

Soft-deletes the phone and returns the id that was removed.

**Notes:**
- Requires `parties:manage-phones`.
- **Soft delete**: the row is retained with `deletedAt` set. It disappears
  from `GET /api/v1/parties/{id}/phones` and from the summary on the party
  row, but re-adding the same number later revives this record rather than
  creating a new one.
- Returns `200` with `{ "id": … }`, not `204`, so the panel can confirm
  which record was removed.
- Deleting a phone that is currently `isPrimary` leaves the party with no
  primary number; the API does not promote another one.
- Side effects: `deletedAt` set and a `party.phone_removed` audit row
  written.
responses:
  200: PhoneDeletedEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/parties/{id}/phones/{phoneId}
operationId: updatePartyPhone
auth: bearer
summary: Edit a contact number.

Applies a partial update to one phone: its number, label, note or active
flag.

**Notes:**
- Requires `parties:manage-phones`.
- At least one field must be supplied.
- Does **not** take `If-Match`; a phone is a small, single-owner record
  and the optimistic-concurrency rule is applied to the party itself, not
  to each of its numbers.
- Supplying `rawInput` re-normalises the number. An unreadable value is
  `400 VALIDATION_FAILED`; a value that normalises onto a **different**
  live number of the same party is `409 PHONE_DUPLICATE`. Re-sending the
  number the row already holds is fine.
- `label` and `note` accept `null` to clear them.
- `isPrimary` is **not** editable here — use
  `POST /api/v1/parties/{id}/phones/{phoneId}/set-primary`, which clears
  the previous primary in the same transaction.
- `source` stays `MANUAL`; nothing here can hand a number back to sync.
- Side effects: the row is updated and a `party.phone_updated` audit row
  is written.
request body: UpdatePartyPhoneRequest
responses:
  200: PartyPhoneEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/parties/{id}/phones/{phoneId}/set-primary
operationId: setPrimaryPartyPhone
auth: bearer
summary: Nominate a contact number as the party's primary.

Clears whichever number was primary and marks this one instead, in one
transaction.

**Notes:**
- Requires `parties:manage-phones`. Takes no request body.
- Returns `200`, not `201` — nothing is created.
- Both sides of the swap happen in the same transaction, which is what
  keeps the one-primary-per-party partial unique index satisfied at every
  instant.
- Setting the number that is already primary succeeds and is effectively a
  no-op.
- Side effects: the previous primary is cleared, this row is marked
  primary, and a `party.phone_primary_set` audit row is written.
responses:
  200: PartyPhoneEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PaginationMeta
type: object
description: The §9.1 success envelope's `meta` for an offset-paginated list. `totalPages` is derived by the envelope interceptor, never by a handler.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total rows matching the filter across all pages.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when `total` is 0.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - page
  - pageSize
  - total
  - totalPages
  - requestId
  - timestamp

### PartyNature
type: string
enum:
  - PERSON
  - ORGANIZATION
  - LEDGER_ACCOUNT
  - SELF
  - UNKNOWN
description: What kind of counterparty this row represents. `LEDGER_ACCOUNT` is an internal accounting account rather than a real party; `SELF` is the business itself; `UNKNOWN` is the default until somebody classifies it.

### NameConfidence
type: string
enum:
  - HIGH
  - MEDIUM
  - LOW
  - UNPARSED
description: How reliably the mirrored display name could be split into first and last name. `UNPARSED` means no split was attempted. Filter on `LOW` and `UNPARSED` to find the rows a human should review.

### FieldSource
type: string
enum:
  - SYNC
  - MANUAL
description: Who owns the value. `SYNC` means the accounting export writes it on every run; `MANUAL` means an operator set it and the pipeline will never touch it again.

### BalanceStatus
type: string
enum:
  - DEBTOR
  - CREDITOR
  - SETTLED
description: The party's position. Derived from the Rial line only, so a party may be a `DEBTOR` here while holding a gold credit — `hasMixedPosition` flags exactly that case.

### PartyGroupSummary
type: object
description: The counterparty group this party belongs to (صندوق / ويترين داران / خانگي and anything sync has met since). §4.1 — not a panel role.
properties:
  id:
    type: string
    format: uuid
    description: Internal group id; the value to pass as the `groupId` filter.
  externalGid:
    type: string
    description: The accounting system's group key, always a **string** (`"1"`, `"4"`, `"5"`), never an integer.
  nameFa:
    type: string
    description: Persian group name, kept in step with the accounting export on every sync.
  nameEn:
    type: string
    nullable: True
    description: English group name; `null` for a group sync auto-discovered and nobody has named yet.
required:
  - id
  - externalGid
  - nameFa
  - nameEn

### PartyPhoneSummary
type: object
description: The reduced phone shape embedded on a party row. The full record lives under `/phones`.
properties:
  id:
    type: string
    format: uuid
    description: The phone record's internal id.
  e164:
    type: string
    description: Normalised international form, e.g. `+989123456789`. This is the machine-readable value.
  display:
    type: string
    description: Persian-digit national rendering, e.g. `۰۹۱۲۳۴۵۶۷۸۹`. For reading only — never parse this back into a number.
  isPrimary:
    type: boolean
    description: At most one phone per party carries `true`, enforced by a partial unique index.
  source:
    $ref: #/components/schemas/FieldSource
required:
  - id
  - e164
  - display
  - isPrimary
  - source

### Party
type: object
description: A counterparty as the list screen renders it. Fields fall into two ownerships: mirrored from the accounting export (read-only here) and locally owned (editable).
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7). The identifier every other endpoint in this file takes.
  externalCode:
    type: string
    description: The accounting system's `Code` for this party, a **string**. This is the key the Financial Records API is addressed by. Read-only.
  displayName:
    type: string
    description: The name as the accounting export gives it. Always populated, and the value the panel's name column falls back to. Read-only here.
  firstName:
    type: string
    nullable: True
    description: Locally owned. `null` when the display name could not be split.
  lastName:
    type: string
    nullable: True
    description: Locally owned. `null` when the display name could not be split.
  honorific:
    type: string
    nullable: True
    description: Locally owned, e.g. `آقای` / `خانم`. `null` when unset.
  nameConfidence:
    $ref: #/components/schemas/NameConfidence
  nameSource:
    $ref: #/components/schemas/FieldSource
  group:
    allOf:
      -
        $ref: #/components/schemas/PartyGroupSummary
    nullable: True
    description: `null` when the export carried no group for this party.
  phones:
    type: array
    description: Live phone numbers in summary form, primary first. Empty when none are on file.
    items:
      $ref: #/components/schemas/PartyPhoneSummary
  nature:
    $ref: #/components/schemas/PartyNature
  isActive:
    type: boolean
    description: Mirrored from the accounting export. Read-only here.
  hasBalance:
    type: boolean
    description: Whether any balance snapshot exists for this party's `externalCode`. Computed, read-only, and says nothing about the amount.
  version:
    type: integer
    description: Optimistic-concurrency counter. Returned as the `ETag` header on the detail read and required back as `If-Match` on `PATCH`.
required:
  - id
  - externalCode
  - displayName
  - firstName
  - lastName
  - honorific
  - nameConfidence
  - nameSource
  - group
  - phones
  - nature
  - isActive
  - hasBalance
  - version

### PartyBalanceSummary
type: object
description: The headline of the party's most recent balance snapshot. Read-only.
properties:
  headlineStatus:
    $ref: #/components/schemas/BalanceStatus
  hasMixedPosition:
    type: boolean
    description: `true` when the party's position differs in sign across assets — a Rial debtor who is a gold creditor, for instance. The headline alone is then misleading and the panel should say so.
  irrAmount:
    type: string
    description: Decimal **string** in Rial minor units, signed. Negative means a debit position under the configured polarity. Never parse with `Number()`.
  observedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the snapshot was taken from the accounting export.
required:
  - headlineStatus
  - hasMixedPosition
  - irrAmount
  - observedAt

### PartyBankAccountSummary
type: object
description: How many bank accounts the party has, not the accounts themselves. Read them from `GET /api/v1/parties/{partyId}/bank-accounts`.
properties:
  count:
    type: integer
    description: Live (not soft-deleted) bank accounts on this party.
  hasDefault:
    type: boolean
    description: Whether one of them is nominated as the default destination.
required:
  - count
  - hasDefault

### PartyDetail
description: A party plus everything the detail screen needs beyond the list projection.
allOf:
  -
    $ref: #/components/schemas/Party
  -
    type: object
    properties:
      note:
        type: string
        nullable: True
        description: Locally owned free-text note, up to 2000 characters. Never seen by the accounting system.
      nationalId:
        type: string
        nullable: True
        description: **Masked**: only the tail is returned, e.g. `"******7412"`. There is no endpoint that reveals it in full. `null` when the export never carried one.
      address:
        type: string
        nullable: True
        description: Mirrored from the accounting export. Read-only here.
      city:
        type: string
        nullable: True
        description: Mirrored from the accounting export. Read-only here.
      isExcludedFromMatching:
        type: boolean
        description: When `true`, this party never appears as a creditor or a debtor on the matching board. Set through the Admin Parties API.
      matchExclusionReason:
        type: string
        nullable: True
        description: Why the party was excluded; `null` whenever `isExcludedFromMatching` is `false`.
      bankAccounts:
        $ref: #/components/schemas/PartyBankAccountSummary
      latestBalance:
        allOf:
          -
            $ref: #/components/schemas/PartyBalanceSummary
        nullable: True
        description: `null` when no balance snapshot exists for this party's `externalCode`.
      paymentCounts:
        type: object
        description: How many payment orders name this party on each side, in every state.
        properties:
          asPayer:
            type: integer
            description: Payment orders where this party is the payer.
          asPayee:
            type: integer
            description: Payment orders where this party is the payee.
        required:
          - asPayer
          - asPayee
      createdAt:
        type: string
        format: date-time
        description: ISO-8601 UTC instant the party row was first created locally.
      updatedAt:
        type: string
        format: date-time
        description: ISO-8601 UTC instant of the last write of any kind, sync or operator.
    required:
      - note
      - nationalId
      - address
      - city
      - isExcludedFromMatching
      - matchExclusionReason
      - bankAccounts
      - latestBalance
      - paymentCounts
      - createdAt
      - updatedAt

### PartyPhone
type: object
description: The full phone record. Always `source: MANUAL` when created through this API, which is what guarantees the sync pipeline will never touch it.
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7). Stable across a soft delete and a later revival.
  partyId:
    type: string
    format: uuid
    description: The party this number belongs to.
  e164:
    type: string
    description: Normalised international form, e.g. `+989123456789`.
  display:
    type: string
    description: Persian-digit national rendering, e.g. `۰۹۱۲۳۴۵۶۷۸۹`. For reading only — never parse this back.
  rawInput:
    type: string
    description: Exactly what was typed, kept verbatim so a normalisation mistake stays diagnosable.
  label:
    type: string
    nullable: True
    description: Operator-chosen label, e.g. `همراه` / `منزل`. `null` when unset.
  isPrimary:
    type: boolean
    description: At most one live phone per party carries `true`. Changed only through the `/set-primary` endpoint.
  source:
    $ref: #/components/schemas/FieldSource
  isActive:
    type: boolean
    description: Whether the number is believed reachable. An inactive number is still listed; only a soft delete removes it from the list.
  normalizationApplied:
    type: string
    nullable: True
    description: Comma-separated record of the transformations applied to `rawInput` to reach `e164`. `null` when the input needed none.
  note:
    type: string
    nullable: True
    description: Free-text note about this number, up to 500 characters.
  version:
    type: integer
    description: Write counter for this row. Not used as a precondition on any endpoint here.
  deletedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant of the soft delete. Always `null` in these responses — a deleted phone is not returned.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant. Preserved when a soft-deleted number is revived.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write.
required:
  - id
  - partyId
  - e164
  - display
  - rawInput
  - label
  - isPrimary
  - source
  - isActive
  - normalizationApplied
  - note
  - version
  - deletedAt
  - createdAt
  - updatedAt

### PartyDetailEnvelope
type: object
description: Success envelope around one party's detail view.
properties:
  data:
    $ref: #/components/schemas/PartyDetail
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PartyUpdateEnvelope
type: object
description: Success envelope around an edited party. `nameSourceChanged` is the consequential side effect the caller could not otherwise infer.
properties:
  data:
    type: object
    properties:
      party:
        $ref: #/components/schemas/PartyDetail
      nameSourceChanged:
        type: boolean
        description: `true` exactly when **this** request flipped `nameSource` to `MANUAL`, making the party permanently immune to sync's name-split re-parse. `false` on every subsequent edit of the same party.
    required:
      - party
      - nameSourceChanged
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PartyPhoneEnvelope
type: object
description: Success envelope around one phone record.
properties:
  data:
    $ref: #/components/schemas/PartyPhone
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PartyPhoneListEnvelope
type: object
description: Success envelope around a party's phone numbers. Not paginated — `meta` carries only `requestId` and `timestamp`.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/PartyPhone
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PhoneDeletedEnvelope
type: object
description: Success envelope confirming a phone soft-deletion.
properties:
  data:
    type: object
    properties:
      id:
        type: string
        format: uuid
        description: The phone id that was soft-deleted, echoed from the path.
    required:
      - id
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedParties
type: object
description: A page of parties in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/Party
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### UpdatePartyRequest
type: object
description: Partial update of the locally owned fields. **Strict**: any other key — `displayName`, `externalCode`, `groupId`, `city`, `address`, `nationalId` — is rejected with `400`, not silently dropped. At least one field must be supplied.
properties:
  firstName:
    type: string
    minLength: 1
    maxLength: 120
    nullable: True
    description: Given name, or `null` to clear it. Setting this flips `nameSource` to `MANUAL`.
  lastName:
    type: string
    minLength: 1
    maxLength: 120
    nullable: True
    description: Family name, or `null` to clear it. Setting this flips `nameSource` to `MANUAL`.
  honorific:
    type: string
    minLength: 1
    maxLength: 60
    nullable: True
    description: Honorific such as `آقای` / `خانم`, or `null` to clear it. Setting this flips `nameSource` to `MANUAL`.
  nature:
    $ref: #/components/schemas/PartyNature
  note:
    type: string
    maxLength: 2000
    nullable: True
    description: Internal note, or `null` to clear it. Does not affect `nameSource`.

### CreatePartyPhoneRequest
type: object
description: A new contact number. Always stored as `source: MANUAL`, and never as the primary.
properties:
  rawInput:
    type: string
    minLength: 1
    maxLength: 32
    description: The number as typed. Persian and Arabic-Indic digits, spaces and dashes are accepted and folded; a blank value is rejected outright.
  label:
    type: string
    minLength: 1
    maxLength: 60
    description: Optional label, e.g. `همراه` / `منزل`.
  note:
    type: string
    maxLength: 500
    description: Optional internal note about this number.
required:
  - rawInput

### UpdatePartyPhoneRequest
type: object
description: Partial update of one phone. At least one field must be supplied. `isPrimary` is deliberately absent — use the `/set-primary` endpoint.
properties:
  rawInput:
    type: string
    minLength: 1
    maxLength: 32
    description: Replacement number; re-normalised on save. A clash with another live number is `409`.
  label:
    type: string
    minLength: 1
    maxLength: 60
    nullable: True
    description: New label, or `null` to clear it.
  note:
    type: string
    maxLength: 500
    nullable: True
    description: New note, or `null` to clear it.
  isActive:
    type: boolean
    description: Whether the number is believed reachable. Does not remove it from the list.


========================================================================
# Admin Parties API (app: parties-admin, version 1.0.0)
========================================================================

Servers: http://localhost:3000

Administrator-only control over whether a counterparty takes part in
settlement matching at all. Everything else about a party — searching,
reading, editing local fields, managing phone numbers — is in the Parties
API and is open to any operator holding the `parties:*` permissions.

Excluding a party is not an edit to the party's own record in any meaningful
sense: it removes them from the settlement engine's universe. That is why it
sits behind a `matching:*` permission rather than `parties:update`.

## Authentication
Both endpoints require a Bearer access token whose operator holds
`matching:settings`. In the seeded role set only **ADMIN** holds it —
`ACCOUNTANT` is explicitly withheld it, along with `matching:lock` and
`matching:issue-payments`, because all three are decisions with money
attached.

1. Obtain a token pair from `POST /api/v1/auth/login` — see the
   Authentication API. There is no separate staff login endpoint.
2. Send `Authorization: Bearer <accessToken>` on every request.
3. Refresh the pair through the same `POST /api/v1/auth/refresh`.

`matching:settings` is not a dangerous permission, so no password re-entry is
required. A missing, malformed or expired token returns `401`; a valid token
whose operator lacks the permission returns `403 PERMISSION_DENIED`, audited
with `outcome: DENIED`.

## Conventions
- Success bodies are the §9.1 envelope `{ data, meta }`, carrying the party's
  **full detail view** — the same shape `GET /api/v1/parties/{id}` returns —
  so the panel can re-render the row without a second call.
- Neither endpoint takes `If-Match`. An exclusion is a deliberate,
  idempotent-in-effect switch rather than a field edit competing with another
  editor, and blocking it on a stale version would be friction with no safety
  gained. Both still increment the party's `version`, which means a `PATCH`
  already in flight against the old version will correctly fail with
  `409 CONCURRENT_MODIFICATION`.
- `nationalId` is **masked** in the returned detail view, and monetary values
  inside `latestBalance` are decimal **strings** in Rial minor units.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- Both endpoints are under the global rate limit of 100 requests per minute
  per IP.

## Effect on matching
- While `isExcludedFromMatching` is `true`, the party appears on the matching
  board **neither as a creditor nor as a debtor**, regardless of their
  balance.
- The switch does not retroactively unwind anything: allocations already made
  and payment orders already issued are untouched. It changes what the next
  board build considers.
- Excluding a party and excluding one of their bank accounts are separate
  controls. See the Admin Bank Accounts API for the account-level flag.

## x-exclusion-scope
description: What a party-level matching exclusion does and does not reach, from `src/modules/parties/parties.service.ts` and the matching eligibility rules.
effects:
  -
    board: While set, the party is offered to the matching board neither as a creditor nor as a debtor, whatever their balance says.
  -
    existing_allocations: Not unwound. Allocations already made and payment orders already issued stand; the flag governs the next board build.
  -
    recompute: Setting or clearing the flag does not itself rebuild the board. Trigger that with POST /api/v1/matching/recompute.
  -
    separate_controls: A party-level exclusion and a bank-account-level exclusion are different switches. See the Admin Bank Accounts API for the account-level one.

## Operations

### POST /api/v1/parties/{id}/matching-exclusion
operationId: adminExcludePartyFromMatching
auth: bearer
summary: Exclude a party from settlement matching.

Sets the party's matching exclusion flag together with the reason for it,
and returns the refreshed party detail view.

**Notes:**
- Requires `matching:settings`, held only by `ADMIN` in the seeded roles.
- Returns `200`, not `201` — nothing is created; a flag on an existing
  party is switched.
- `reason` is **required**, 1–500 characters. There is no way to exclude a
  party silently: the reason is what makes the exclusion auditable and
  reversible by somebody who was not in the room.
- Excluding an already-excluded party succeeds and **overwrites** the
  stored reason with the new one. It is not a conflict.
- While excluded, the party appears on the matching board neither as a
  creditor nor as a debtor. Existing allocations and issued payment
  orders are **not** unwound.
- Side effects: `isExcludedFromMatching` set to `true`,
  `matchExclusionReason` stored, `version` incremented,
  `updatedByOperatorId` recorded, and a `matching.party.excluded` audit
  row written carrying the reason.
request body: SetPartyMatchingExclusionRequest
responses:
  200: PartyDetailEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/parties/{id}/matching-exclusion
operationId: adminRemovePartyMatchingExclusion
auth: bearer
summary: Return a party to settlement matching.

Clears the exclusion flag and its reason, and returns the refreshed party
detail view.

**Notes:**
- Requires `matching:settings`. Takes no request body.
- Returns `200` with the party, not `204`: the panel needs the refreshed
  row and the new `version` to keep editing.
- Clearing an exclusion that is not set succeeds. It is not a conflict —
  the endpoint asserts a desired state rather than performing a
  transition.
- `matchExclusionReason` is set to `null` on success. The reason survives
  only in the audit trail.
- The party becomes eligible again from the **next** board build; this
  does not itself recompute the matching board. Trigger that with
  `POST /api/v1/matching/recompute` in the Matching API.
- Side effects: `isExcludedFromMatching` set to `false`,
  `matchExclusionReason` cleared, `version` incremented,
  `updatedByOperatorId` recorded, and a
  `matching.party.exclusion_removed` audit row written.
responses:
  200: PartyDetailEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PartyNature
type: string
enum:
  - PERSON
  - ORGANIZATION
  - LEDGER_ACCOUNT
  - SELF
  - UNKNOWN
description: What kind of counterparty this row represents. `LEDGER_ACCOUNT` is an internal accounting account rather than a real party and is a common reason to exclude one from matching.

### NameConfidence
type: string
enum:
  - HIGH
  - MEDIUM
  - LOW
  - UNPARSED
description: How reliably the mirrored display name could be split into first and last name.

### FieldSource
type: string
enum:
  - SYNC
  - MANUAL
description: Who owns the value. `SYNC` means the accounting export writes it on every run; `MANUAL` means an operator set it and the pipeline never touches it again.

### BalanceStatus
type: string
enum:
  - DEBTOR
  - CREDITOR
  - SETTLED
description: The party's position, derived from the Rial line only. A party may be a `DEBTOR` here while holding a gold credit; `hasMixedPosition` flags that.

### PartyGroupSummary
type: object
description: The counterparty group this party belongs to. §4.1 — not a panel role.
properties:
  id:
    type: string
    format: uuid
    description: Internal group id.
  externalGid:
    type: string
    description: The accounting system's group key, always a **string** (`"1"`, `"4"`, `"5"`).
  nameFa:
    type: string
    description: Persian group name.
  nameEn:
    type: string
    nullable: True
    description: English group name; `null` for an auto-discovered group nobody has named yet.
required:
  - id
  - externalGid
  - nameFa
  - nameEn

### PartyPhoneSummary
type: object
description: The reduced phone shape embedded on a party row.
properties:
  id:
    type: string
    format: uuid
    description: The phone record's internal id.
  e164:
    type: string
    description: Normalised international form, e.g. `+989123456789`.
  display:
    type: string
    description: Persian-digit national rendering, for reading only — never parse it back.
  isPrimary:
    type: boolean
    description: At most one phone per party carries `true`.
  source:
    $ref: #/components/schemas/FieldSource
required:
  - id
  - e164
  - display
  - isPrimary
  - source

### PartyBalanceSummary
type: object
description: The headline of the party's most recent balance snapshot. Read-only.
properties:
  headlineStatus:
    $ref: #/components/schemas/BalanceStatus
  hasMixedPosition:
    type: boolean
    description: `true` when the party's position differs in sign across assets.
  irrAmount:
    type: string
    description: Decimal **string** in Rial minor units, signed. Never parse it with `Number()`.
  observedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the snapshot was taken from the accounting export.
required:
  - headlineStatus
  - hasMixedPosition
  - irrAmount
  - observedAt

### PartyBankAccountSummary
type: object
description: How many bank accounts the party has, not the accounts themselves.
properties:
  count:
    type: integer
    description: Live (not soft-deleted) bank accounts on this party.
  hasDefault:
    type: boolean
    description: Whether one of them is nominated as the default destination.
required:
  - count
  - hasDefault

### PartyDetail
type: object
description: The party's full detail view — the same shape `GET /api/v1/parties/{id}` returns — with the matching-exclusion fields this API controls.
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7).
  externalCode:
    type: string
    description: The accounting system's `Code` for this party, a **string**. Read-only.
  displayName:
    type: string
    description: The name as the accounting export gives it. Read-only here.
  firstName:
    type: string
    nullable: True
    description: Locally owned given name.
  lastName:
    type: string
    nullable: True
    description: Locally owned family name.
  honorific:
    type: string
    nullable: True
    description: Locally owned honorific, e.g. `آقای` / `خانم`.
  nameConfidence:
    $ref: #/components/schemas/NameConfidence
  nameSource:
    $ref: #/components/schemas/FieldSource
  group:
    allOf:
      -
        $ref: #/components/schemas/PartyGroupSummary
    nullable: True
    description: `null` when the export carried no group for this party.
  phones:
    type: array
    description: Live phone numbers in summary form, primary first.
    items:
      $ref: #/components/schemas/PartyPhoneSummary
  nature:
    $ref: #/components/schemas/PartyNature
  isActive:
    type: boolean
    description: Mirrored from the accounting export. Independent of the matching exclusion.
  hasBalance:
    type: boolean
    description: Whether any balance snapshot exists for this party's `externalCode`.
  version:
    type: integer
    description: Optimistic-concurrency counter, incremented by both endpoints here even though neither takes `If-Match`. A `PATCH` in flight against the old version will therefore correctly fail with `409`.
  note:
    type: string
    nullable: True
    description: Locally owned free-text note. Distinct from `matchExclusionReason`.
  nationalId:
    type: string
    nullable: True
    description: **Masked**: only the tail is returned, e.g. `"******7412"`. No endpoint reveals it in full.
  address:
    type: string
    nullable: True
    description: Mirrored from the accounting export. Read-only.
  city:
    type: string
    nullable: True
    description: Mirrored from the accounting export. Read-only.
  isExcludedFromMatching:
    type: boolean
    description: `true` ⇒ the party appears on the matching board neither as a creditor nor as a debtor. This is the field both endpoints here switch.
  matchExclusionReason:
    type: string
    nullable: True
    description: Why the party was excluded, as supplied on the last exclusion. Always `null` when `isExcludedFromMatching` is `false`; the reason then survives only in the audit trail.
  bankAccounts:
    $ref: #/components/schemas/PartyBankAccountSummary
  latestBalance:
    allOf:
      -
        $ref: #/components/schemas/PartyBalanceSummary
    nullable: True
    description: `null` when no balance snapshot exists for this party's `externalCode`.
  paymentCounts:
    type: object
    description: How many payment orders name this party on each side, in every state. Excluding a party does not change these — issued orders stand.
    properties:
      asPayer:
        type: integer
        description: Payment orders where this party is the payer.
      asPayee:
        type: integer
        description: Payment orders where this party is the payee.
    required:
      - asPayer
      - asPayee
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the party row was first created locally.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write of any kind.
required:
  - id
  - externalCode
  - displayName
  - firstName
  - lastName
  - honorific
  - nameConfidence
  - nameSource
  - group
  - phones
  - nature
  - isActive
  - hasBalance
  - version
  - note
  - nationalId
  - address
  - city
  - isExcludedFromMatching
  - matchExclusionReason
  - bankAccounts
  - latestBalance
  - paymentCounts
  - createdAt
  - updatedAt

### PartyDetailEnvelope
type: object
description: Success envelope around one party's detail view.
properties:
  data:
    $ref: #/components/schemas/PartyDetail
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### SetPartyMatchingExclusionRequest
type: object
description: Why this party should be kept out of settlement matching. Recording a reason is mandatory; an unexplained exclusion is unanswerable months later.
properties:
  reason:
    type: string
    minLength: 1
    maxLength: 500
    description: Free text, trimmed. Stored on the party, returned as `matchExclusionReason`, and copied into the audit row.
required:
  - reason


========================================================================
# Party Groups API (app: party-groups, version 1.0.0)
========================================================================

Servers: http://localhost:3000

Counterparty groups — صندوق / ويترين داران / خانگي and anything the sync has
met since. A party group classifies an **external counterparty**; it confers
no access to anything. §4.1 — these are not panel roles. Panel roles govern
who may click what and live in the Admin Roles and Permissions API; the two
tables are never joined and never merged, and the modules are kept apart so
that a copy-paste between them is a visible act rather than an easy one.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. Operator opens the group list with `GET /api/v1/party-groups`. Groups the
   sync auto-discovered sort first, because they are the ones still waiting
   for a human.
3. Operator filters that queue explicitly with
   `GET /api/v1/party-groups?isAutoDiscovered=true` and works through it.
4. Operator names a group and confirms it with
   `PATCH /api/v1/party-groups/{id}` — supplying an English name, a slug, an
   ordering, and `isAutoDiscovered: false` to clear it from the queue.
5. To register a group manually, ahead of the sync meeting its GID, the
   operator posts it to `POST /api/v1/party-groups` with the GID the vendor
   uses.
6. An empty group that turned out to be a mistake is removed with
   `DELETE /api/v1/party-groups/{id}`.

## Security Notes
- Every endpoint requires a valid Bearer access token. Reads need
  `party-groups:read`; creates, edits and deletes need `party-groups:manage`.
  In the seeded role set `ACCOUNTANT` holds both and `VIEWER` holds only the
  read.
- There is no ownership scoping: an operator who holds the permission sees
  every group. An unknown id is `404 RESOURCE_NOT_FOUND`.
- **`externalGid` is immutable and `nameFa` is not editable.** The first is
  the vendor's key that the sync upserts on — editing it would detach the
  group from the GID it mirrors and make the next run auto-discover a
  duplicate. The second is kept in step with the vendor on every run, so an
  operator edit would be silently reverted within the quarter-hour. English
  name, slug, ordering and activation are the operator-owned fields.
- A manually created group must claim a GID the vendor has not used. If it
  collides, the next sync that meets that GID will conflict with this row
  instead of upserting it cleanly.
- `PATCH` requires `If-Match` carrying the row's `version`, echoed as the
  `ETag` response header by every single-group read and write. A stale value
  is `409 CONCURRENT_MODIFICATION`.
- Deletion is a **real** delete, not a deactivation — unlike a party or an
  asset, an empty group carries no history the sync ever wrote. It is refused
  while any party is still assigned (`409 PARTY_GROUP_IN_USE`).
- `externalGid` is a **string** everywhere (`"1"`, `"4"`, `"5"`), never an
  integer. Treating it as a number is how leading zeros and non-numeric keys
  get lost.
- Every success is the §9.1 envelope `{ data, meta }`; every failure is
  `{ error: { code, message, messageFa, status, details, requestId } }` with
  both an English and a Persian sentence.
- Timestamps are ISO-8601 UTC strings with milliseconds.
- Every route is under the global rate limit of 100 requests per minute per
  IP.

## x-vendor-owned-fields
description: Which columns the accounting sync owns on a party group and which the panel owns, from `src/modules/party-groups/dto/party-group.dto.ts` and the party ingest service.
vendor_owned:
  -
    externalGid: the upsert key. Immutable after creation; editing it would detach the group from the GID it mirrors and make the next sync auto-discover a duplicate.
  -
    nameFa: refreshed from the vendor on every sync run, so an operator edit would be silently reverted within the quarter-hour. Settable at manual creation only.
operator_owned:
  -
    nameEn: the English label
  -
    slug: the stable lowercase identifier used in the panel
  -
    sortOrder: display ordering
  -
    isActive: whether the group is offered
  -
    isAutoDiscovered: cleared by the operator as the act of confirming the group

## Operations

### GET /api/v1/party-groups
operationId: listPartyGroups
auth: bearer
summary: List party groups, unconfirmed ones first.

Returns a page of counterparty groups, each with the number of parties
currently assigned to it.

**Notes:**
- Requires `party-groups:read`.
- Ordering is `isAutoDiscovered` descending, then `sortOrder` ascending,
  then `externalGid` ascending. Unconfirmed groups therefore surface
  without anyone having to remember to filter for them.
- `?isAutoDiscovered=true` **is** the review queue: the groups a sync
  registered on first sight, still carrying only the vendor's Persian
  label.
- `partyCount` is what makes that queue decidable — a group with 140
  parties is a different priority from one with none. It is also what
  blocks a delete.
- Both filters coerce loosely: `"true"`, `"1"` and similar truthy strings
  are accepted.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100. A page
  past the end returns an empty `data` array, not `404`.
- Side effects: none.
responses:
  200: PaginatedPartyGroups
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/party-groups
operationId: createPartyGroup
auth: bearer
summary: Register a party group manually.

Creates a group ahead of the sync meeting its GID, fully named from the
start.

**Notes:**
- Requires `party-groups:manage`.
- `externalGid` is **required** and must be a GID the vendor has not used.
  If it collides with a vendor key, the next sync that meets that GID will
  conflict with this row instead of upserting it cleanly. It is immutable
  once set.
- `slug` must be lowercase kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`) and
  unique across all groups.
- A duplicate `externalGid` or a duplicate `slug` is `409 CONFLICT`, with
  the offending value echoed in `details[]` and named in the message.
- The row is created with `isAutoDiscovered: false` — a manual
  registration is by definition already confirmed, so it never enters the
  review queue.
- `isActive` defaults to `true`; `sortOrder` defaults to the service's own
  value when omitted.
- Sets the `ETag` response header to the new row's `version` (`"1"`).
- Side effects: the group row is created and a `party_group.created` audit
  row is written recording the GID and slug.
request body: CreatePartyGroupRequest
responses:
  201: PartyGroupEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/party-groups/{id}
operationId: getPartyGroup
auth: bearer
summary: Retrieve one party group.

Returns a single group with its current party count.

**Notes:**
- Requires `party-groups:read`.
- Sets the `ETag` response header to the row's `version`, quoted — e.g.
  `ETag: "2"`. Send that value back as `If-Match` on the subsequent
  `PATCH`.
- Side effects: none.
responses:
  200: PartyGroupEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/party-groups/{id}
operationId: deletePartyGroup
auth: bearer
summary: Delete an empty party group.

Permanently removes a group that has no parties in it, and returns the id
that was removed.

**Notes:**
- Requires `party-groups:manage`.
- This is a **real** delete, not a deactivation. Parties and assets are
  soft-deleted because they carry history the sync wrote; an empty group
  carries none. To retire a group that still has members, set
  `isActive: false` through `PATCH` instead.
- Refused while **any** party is still assigned:
  `409 PARTY_GROUP_IN_USE`, with the live count in `details[].partyCount`.
  Reassign those parties first — note that a party's `groupId` is mirrored
  from the accounting export and cannot be changed through this API, so a
  populated vendor group is in practice undeletable.
- Returns `200` with `{ "id": … }`, not `204`, so the panel can reconcile
  its list without a re-read.
- Does not require `If-Match`.
- Side effects: the row is deleted and a `party_group.deleted` audit row
  is written.
responses:
  200: PartyGroupDeletedEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/party-groups/{id}
operationId: updatePartyGroup
auth: bearer
summary: Name and confirm a party group.

Applies a partial update to the operator-owned fields under an
optimistic-concurrency precondition. This is how a group leaves the review
queue.

**Notes:**
- Requires `party-groups:manage`.
- **`If-Match` is required**. Omitting it is `400 VALIDATION_FAILED` on
  the `If-Match` field; a stale value is `409 CONCURRENT_MODIFICATION`
  carrying both the expected and the actual version. `W/"2"`, `"2"`, `2`
  and `*` are all accepted spellings.
- At least one field must be supplied.
- `externalGid` and `nameFa` are **not** accepted. `externalGid` is the
  vendor's key that the sync upserts on; `nameFa` is refreshed from the
  vendor on every run, so an edit would be reverted within the
  quarter-hour. Both are stripped by the validation layer rather than
  applied.
- Sending `isAutoDiscovered: false` **is** the act of confirming the
  group; it is the only thing that clears it from the review queue.
  Naming a group does not confirm it by implication.
- A `slug` that another group already uses is `409 CONFLICT`. Re-sending
  the slug this group already holds is fine.
- Side effects: `version` incremented, `updatedByOperatorId` recorded, and
  a `party_group.updated` audit row written with a before/after diff and
  the group's GID in its metadata.
- The response carries the refreshed group and an updated `ETag`.
request body: UpdatePartyGroupRequest
responses:
  200: PartyGroupEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PaginationMeta
type: object
description: The §9.1 success envelope's `meta` for an offset-paginated list. `totalPages` is derived by the envelope interceptor, never by a handler.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total rows matching the filter across all pages.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when `total` is 0.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - page
  - pageSize
  - total
  - totalPages
  - requestId
  - timestamp

### PartyGroup
type: object
description: A counterparty classification. §4.1 — not a panel role, and it grants nothing.
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7). The identifier every endpoint in this file takes.
  externalGid:
    type: string
    description: The accounting system's group key — a **string** (`"1"`, `"4"`, `"5"`), never an integer. Immutable once set, because the sync upserts on it.
  nameFa:
    type: string
    description: Persian group name. Mirrored from the vendor and refreshed on every sync run, so it is not editable here.
  nameEn:
    type: string
    nullable: True
    description: English group name, owned by operators. `null` for an auto-discovered group nobody has named yet.
  slug:
    type: string
    description: Lowercase kebab-case identifier, unique across all groups. Owned by operators.
  sortOrder:
    type: integer
    description: Display ordering within the panel, ascending. Owned by operators.
  isActive:
    type: boolean
    description: Whether the group is offered in the panel. Setting this `false` is how a populated group is retired, since it cannot be deleted.
  isAutoDiscovered:
    type: boolean
    description: `true` ⇒ a sync met this GID for the first time and registered the group on the spot with only the vendor's Persian label. Such groups sort first. Sending `isAutoDiscovered: false` is the act of confirming the group.
  version:
    type: integer
    description: Optimistic-concurrency counter. Returned as the `ETag` header and required back as `If-Match` on `PATCH`.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant — for an auto-discovered group, the sync run that met it.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write, sync or operator.
  partyCount:
    type: integer
    description: Parties currently assigned to this group. Computed, read-only, and the number that both prioritises the review queue and blocks a delete.
required:
  - id
  - externalGid
  - nameFa
  - nameEn
  - slug
  - sortOrder
  - isActive
  - isAutoDiscovered
  - version
  - createdAt
  - updatedAt

### PartyGroupEnvelope
type: object
description: Success envelope around a single party group.
properties:
  data:
    $ref: #/components/schemas/PartyGroup
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedPartyGroups
type: object
description: A page of party groups in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/PartyGroup
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### PartyGroupDeletedEnvelope
type: object
description: Success envelope confirming a group deletion.
properties:
  data:
    type: object
    properties:
      id:
        type: string
        format: uuid
        description: The group id that was deleted, echoed from the path.
    required:
      - id
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### CreatePartyGroupRequest
type: object
description: A manually registered group. Created already confirmed (`isAutoDiscovered: false`), so it never enters the review queue.
properties:
  externalGid:
    type: string
    minLength: 1
    maxLength: 32
    description: The vendor's group key, as a **string**. Must be one the vendor has not used, or the next sync that meets it will conflict with this row. Immutable once set.
  nameFa:
    type: string
    minLength: 1
    maxLength: 160
    description: Persian group name. Settable here, but not editable afterwards — the sync refreshes it from the vendor on every run.
  nameEn:
    type: string
    minLength: 1
    maxLength: 120
    description: Optional English group name.
  slug:
    type: string
    minLength: 1
    maxLength: 64
    pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$
    description: Lowercase kebab-case identifier, e.g. `home-customers`. Must be unique.
  sortOrder:
    type: integer
    minimum: 0
    maximum: 10000
    description: Display ordering, ascending. Defaults to the service's own value when omitted.
  isActive:
    type: boolean
    default: True
    description: Whether the group is offered in the panel.
required:
  - externalGid
  - nameFa
  - slug

### UpdatePartyGroupRequest
type: object
description: Partial update of the operator-owned fields. At least one must be supplied. `externalGid` and `nameFa` are deliberately absent — both belong to the vendor.
properties:
  nameEn:
    type: string
    minLength: 1
    maxLength: 120
    description: English group name.
  slug:
    type: string
    minLength: 1
    maxLength: 64
    pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$
    description: New lowercase kebab-case identifier. A clash with another group is `409`.
  sortOrder:
    type: integer
    minimum: 0
    maximum: 10000
    description: New display ordering, ascending.
  isActive:
    type: boolean
    description: Whether the group is offered in the panel. This is how a populated group is retired.
  isAutoDiscovered:
    type: boolean
    description: Send `false` to confirm the group and clear it from the review queue. This is the only thing that clears the flag; naming a group does not confirm it by implication.


========================================================================
# Payments API (app: payments, version 1.0.0)
========================================================================

Servers: http://localhost:3000

«سوابق پرداخت» — payment orders, the state machine they move through, and the
receipt (فیش) evidence attached to them.

Payments here are **recorded, not executed**. This system issues no transfer;
it captures the instruction, tracks it to confirmation, and holds the evidence
that it happened. Three properties follow from that and shape every endpoint:
everything about *who* is snapshotted at creation; status moves only along
declared edges; and confirmation needs evidence plus, by default, a second
pair of eyes.

## Flow
1. Operator authenticates and obtains a Bearer access token — see the
   Authentication API.
2. Operator creates a payment with `POST /api/v1/payments`, supplying an
   `Idempotency-Key` header. It is born `DRAFT`, with the payer, payee and
   destination account frozen into it.
3. Operator moves it forward with `POST /api/v1/payments/{id}/transitions`:
   `DRAFT → ISSUED → AWAITING_RECEIPT`.
4. Somebody uploads the receipt to `POST /api/v1/payments/{id}/receipt` as
   multipart. A first upload automatically advances
   `AWAITING_RECEIPT → RECEIPT_UPLOADED`.
5. A reviewer moves it to `UNDER_REVIEW`, then either `CONFIRMED` — which
   requires the receipt and, by default, a **different** operator than the
   creator — or `REJECTED` with a reason.
6. A confirmed payment finally reaches `SETTLED` or `FAILED`.
7. The panel reads `GET /api/v1/payments` with filters,
   `GET /api/v1/payments/summary` for the dashboard, and
   `GET /api/v1/payments/{id}/transitions` for one payment's status history.
8. `GET /api/v1/payments/export` produces the whole matching set as CSV or
   XLSX, and `GET /api/v1/payments/hygiene` reports evidence problems.

## Security Notes
- Every endpoint requires a valid Bearer access token. Reading needs
  `payments:read`; creating `payments:create`; editing `payments:update`;
  deleting `payments:delete`; moving status `payments:transition` — except
  that the `→ CONFIRMED` edge needs `payments:confirm`, the `→ REJECTED` edges
  need `payments:reject`, and the `AWAITING_RECEIPT → RECEIPT_UPLOADED` edge
  needs `payments:upload-receipt`. Uploading a receipt needs
  `payments:upload-receipt`; **replacing or deleting** one needs
  `payments:manage-receipt`. Exporting needs `payments:export`.
- The transition endpoint's permission therefore depends on the **target
  status**, not on the route. A `403` from it names the specific permission
  the requested edge required.
- There is no ownership scoping: an operator with the permission sees every
  payment. An unknown id is `404 RESOURCE_NOT_FOUND`.
- Soft-deleted payments are excluded from the list, the export and the
  summary, but remain readable by id.
- **Money is a decimal string** in Rial minor units. Rial has no fractional
  part, so an amount is a plain positive integer string; zero and negatives
  are rejected. Weights are three-decimal gram strings. Never parse either
  with `Number()`.
- Every timestamp comes back twice: an ISO-8601 UTC field and a `…Jalali`
  Persian calendar string for display. On input, `valueDate` accepts either an
  ISO-8601 string or a Jalali one (`1405/05/13` or `۱۴۰۵/۰۵/۱۳ ۱۱:۳۵`), and is
  converted at the boundary.
- The IBAN inside `payeeAccount` is **masked**. That snapshot is display
  evidence; there is no reveal endpoint for it.
- `availableTransitions` is computed **for the calling operator**: it already
  accounts for their permissions and for the four-eyes rule, so an operator
  who created the payment will not see `CONFIRMED` offered.
- Search (`q`) is folded through the shared Persian normalisation and matched
  against the **frozen** payer/payee name snapshots and the reference, so a
  search still finds a payment after the party behind it has been renamed.
- Every success is the §9.1 envelope `{ data, meta }` — except
  `GET /api/v1/payments/export`, which returns raw file bytes with a
  `Content-Disposition` header and no envelope at all.
- Receipt upload is limited to **10 requests per minute per IP**; every other
  route falls under the global limit of 100 per minute.

## Immutability
- `PATCH` is accepted only while the payment is `DRAFT` or `ISSUED`. Anything
  later is `409 PAYMENT_IMMUTABLE`.
- Payer, payee and destination account are **never** editable. To redirect a
  payment, cancel it and create a new one.
- `DELETE` is a soft delete and is refused once the payment is `CONFIRMED` or
  `SETTLED`.
- A receipt may be replaced or deleted only while the payment is **not**
  `CONFIRMED` or `SETTLED` — the same rule, because a confirmed payment's
  evidence is the thing being relied on.
- Deleting a receipt clears the link but **keeps the stored file row**:
  evidence is never overwritten in place.

## x-payment-state-machine
description: The complete edge table, from `src/modules/payments/state-machine/`. Only an edge listed here succeeds, and this table is the only thing in the system that assigns a payment status after creation.
edges:
  -
    from: DRAFT
    to:
      - ISSUED
      - CANCELLED
    permission: payments:transition
    guards: none
  -
    from: ISSUED
    to:
      - AWAITING_RECEIPT
      - CANCELLED
    permission: payments:transition
    guards: none
  -
    from: AWAITING_RECEIPT
    to:
      - RECEIPT_UPLOADED
    permission: payments:upload-receipt
    guards: requireReceiptFileId
  -
    from: AWAITING_RECEIPT
    to:
      - CANCELLED
    permission: payments:transition
    guards: none
  -
    from: RECEIPT_UPLOADED
    to:
      - UNDER_REVIEW
    permission: payments:transition
    guards: none
  -
    from: RECEIPT_UPLOADED
    to:
      - REJECTED
    permission: payments:reject
    guards: requireReason
  -
    from: UNDER_REVIEW
    to:
      - CONFIRMED
    permission: payments:confirm
    guards: requireReceipt + requireDifferentOperatorThanCreator
  -
    from: UNDER_REVIEW
    to:
      - REJECTED
    permission: payments:reject
    guards: requireReason
  -
    from: UNDER_REVIEW
    to:
      - RECEIPT_UPLOADED
    permission: payments:transition
    guards: requireReason
  -
    from: CONFIRMED
    to:
      - SETTLED
      - FAILED
    permission: payments:transition
    guards: none
  -
    from: REJECTED
    to:
      - AWAITING_RECEIPT
    permission: payments:transition
    guards: requireReason
  -
    from: SETTLED / CANCELLED / FAILED
    to:

    permission: none
    guards: terminal — no outgoing edges
guard_meanings:
  -
    requireReceipt: A receipt must already be attached. Refusal is 409 PAYMENT_RECEIPT_REQUIRED. Also enforced by a database CHECK constraint.
  -
    requireReason: A non-blank reason must be supplied. Refusal is 400 VALIDATION_FAILED on the reason field.
  -
    requireDifferentOperatorThanCreator: Four-eyes. The operator confirming must not be the one who created the payment. Refusal is 409 PAYMENT_FOUR_EYES_VIOLATION. Disabled entirely by PAYMENT_REQUIRE_FOUR_EYES=false, which a two-person office may need — an explicit, audited configuration decision rather than a silent gap.
  -
    requireReceiptFileId: An existing StoredFile id must be named. Refusal is 400 VALIDATION_FAILED, or 404 when the file does not exist.

## x-receipt-pipeline
description: What happens to an uploaded receipt between the request and storage, from `src/modules/payments/receipts/receipts.service.ts`.
steps:
  -
    size_limit: Bounded by RECEIPT_MAX_BYTES (10 MB default) before anything is read. Exceeding it is 413 PAYLOAD_TOO_LARGE.
  -
    type_detection: From magic bytes, never the client Content-Type. Only the types in RECEIPT_ALLOWED_MIME are accepted — JPEG, PNG, WebP, PDF by default. Anything else is 415 RECEIPT_INVALID_TYPE with the detected MIME reported.
  -
    pdf_structure: A PDF is additionally checked for a %PDF- header and a %%EOF trailer; failure is 415 with issue malformed_pdf.
  -
    reencode: Images are re-encoded, stripping EXIF and capping the longest side at 3000px. The stored bytes are therefore not identical to what was uploaded.
  -
    deduplicate: SHA-256 of the processed bytes is compared against every other live payment's receipt. A match is 409 RECEIPT_DUPLICATE naming the other payment.
  -
    thumbnail: Generated for images at 400px wide; not generated for PDFs.
  -
    object_key: Server-generated from the date and the payment id. A client-supplied filename is never used.
  -
    malware_scan: When enabled, an asynchronous scan is queued and the file starts PENDING. Downloading is blocked with 409 RECEIPT_SCAN_PENDING until it reports clean, and permanently with 409 RECEIPT_SCAN_INFECTED if it does not.
  -
    auto_transition: A first upload takes the AWAITING_RECEIPT to RECEIPT_UPLOADED edge in the same operation.
  -
    evidence_retention: Detaching a receipt clears the payment's link but keeps the StoredFile row. Evidence is never overwritten in place.

## Operations

### GET /api/v1/payments
operationId: listPayments
auth: bearer
summary: List payment orders with filters.

Returns a page of payment orders, each rendered from its frozen snapshots
rather than from live joins.

**Notes:**
- Requires `payments:read`.
- Soft-deleted payments are always excluded; there is no `includeDeleted`
  option here.
- `q` is folded through the shared Persian normalisation and matched
  against a stored search blob built from the **frozen** payer and payee
  names, their external codes, and the reference. A payment therefore
  stays findable under the name it was created with even after the party
  is renamed.
- `status` accepts a single value or an array; repeat the parameter to
  filter on several statuses at once.
- `dateFrom` / `dateTo` bound `valueDate` inclusively and accept ISO-8601
  or Jalali strings.
- `minAmount` / `maxAmount` are positive integer **strings** in Rial minor
  units.
- Default ordering is `valueDate:desc`.
- `availableTransitions` on each row is computed for the **calling**
  operator, so two operators can legitimately see different values on the
  same payment.
- Paginated: `pageSize` defaults to 25 and is clamped to 1–100. A page past
  the end returns an empty `data` array, not `404`.
- Side effects: none.
responses:
  200: PaginatedPayments
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/payments
operationId: createPayment
auth: bearer
summary: Create a payment order.

Records a new payment instruction as `DRAFT`, freezing the payer, payee
and destination account into it.

**Notes:**
- Requires `payments:create`.
- **`Idempotency-Key` is required.** Omitting it is
  `400 VALIDATION_FAILED` on the `Idempotency-Key` field. Replaying the
  same key with the **same** body returns the original response and the
  original `201` — a double-click can never create two payment orders.
  The same key with a **different** body is
  `409 IDEMPOTENCY_KEY_CONFLICT`. Records live for 24 hours and are scoped
  to `(key, operator, endpoint)`.
- Who pays and who is paid may be given either as a party id, or — for an
  accounting code with no party record — as `payerExternalCode` /
  `payerNameSnapshot` (and the payee equivalents). At least one form of
  each is required.
- `settlementType: "THIRD_PARTY"` additionally **requires**
  `payeeBankAccountId`: without a nominated account there is no
  instruction, only an intention.
- `payeeBankAccountId` must belong to `payeePartyId` when both are given;
  a mismatch is `400 VALIDATION_FAILED` with issue `party_mismatch`.
- `assetId` and `assetQuantity` must be supplied **together** or not at
  all.
- `amountMinor` is a **positive integer string** in Rial minor units. Zero
  and negative values are rejected here and by a database constraint.
- `valueDate` accepts ISO-8601 or Jalali (`1405/05/13`,
  `۱۴۰۵/۰۵/۱۳ ۱۱:۳۵`) and is converted at the boundary.
- The schema is **strict**: an unrecognised key is `400`. `status`,
  `reference` and the snapshots cannot be supplied — all are
  server-assigned.
- An orphaned payer or payee is **accepted**, not refused:
  `hasOrphanReference` is set to `true` on the payment so the condition
  stays visible and filterable.
- The payment is born `DRAFT`, is given a `reference` of the form
  `PAY-<jalali year>-<sequence>`, and gets its first transition row
  (`null → DRAFT`).
- Sets the `ETag` response header to the new row's `version` (`"1"`).
- Side effects on success: the payment row created with frozen payer,
  payee and payee-account snapshots; a sequence number allocated for the
  Jalali year; the first transition row written; the search blob built; a
  `payment_order.created` audit row written; the idempotency record stored
  in the same transaction; and the `DRAFT` status counter incremented.
request body: CreatePaymentRequest
responses:
  201: PaymentEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/payments/summary
operationId: getPaymentsSummary
auth: bearer
summary: Aggregate payment counts and sums for the dashboard.

Returns counts and Rial sums grouped by status, plus three headline
figures the dashboard leads with.

**Notes:**
- Requires `payments:read`.
- Matched **before** `/{id}`, so `summary` is a reserved segment.
- Computed in a single query over the whole (non-deleted) payment table.
  It takes no filters — it is a dashboard aggregate, not a report.
- `byStatus` carries one entry per status that has at least one payment;
  statuses with none are absent rather than reported as zero.
- `sumMinor` is a decimal **string** in Rial minor units.
- `reviewSlaBreachCount` counts payments whose receipt has been waiting
  for review longer than the configured SLA
  (`RECEIPT_REVIEW_SLA_HOURS`) — the same condition the hygiene sweep
  reports as `RECEIPT_REVIEW_OVERDUE`.
- `orphanReferenceCount` counts payments created against an accounting
  code that had no party record.
- Side effects: none.
responses:
  200: PaymentsSummaryEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/payments/hygiene
operationId: getPaymentHygieneReport
auth: bearer
summary: Run the receipt hygiene sweep and report its findings.

Runs five read-only integrity checks over payments and stored files, and
returns everything they found.

**Notes:**
- Requires `payments:read`.
- Matched **before** `/{id}`, so `hygiene` is a reserved segment.
- The sweep **never mutates anything**. It is a report, not a repair.
- Each finding is *also* filed as a `NOTICE` or `CRITICAL` audit row, so a
  problem discovered here is durable even if nobody reads this response.
- The five checks are: a `CONFIRMED` payment with no receipt; a receipt
  waiting for review longer than `RECEIPT_REVIEW_SLA_HOURS`; two payments
  whose receipts share a SHA-256; a stored file whose object is missing or
  whose scan failed; and a stored file no payment points at.
- Each finding carries enough context — the payment reference, the frozen
  party names, the file id and the age of the problem — to act on without
  a second query.
- Both `summaryEn` and `summaryFa` are provided, as everywhere else in
  this API.
- The same sweep runs on a schedule (`PAYMENT_HYGIENE_SWEEP_CRON`,
  nightly by default); calling this endpoint runs it on demand.
- An empty `findings` array with zero counts is the healthy outcome.
- Side effects: audit rows for each finding. No payment or file is
  changed.
responses:
  200: HygieneReportEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/payments/export
operationId: exportPayments
auth: bearer
summary: Export the matching payment orders as CSV or XLSX.

Streams the whole filtered set — not one page — as a downloadable
spreadsheet.

**Notes:**
- Requires `payments:export`.
- Matched **before** `/{id}`, so `export` is a reserved segment.
- **This is the only endpoint in the API that does not return the §9.1
  envelope.** The response body is the raw file, with
  `Content-Disposition: attachment` and a generated filename.
- Accepts every filter `GET /api/v1/payments` accepts **except**
  pagination: an export is the whole matching set by definition. A safety
  row limit applies; a request matching more rows than that is a missing
  filter, not a use case.
- `format` defaults to `csv`. CSV is written with a UTF-8 byte-order mark
  so Excel opens Persian text correctly.
- Dates in the file are Jalali; money is written as **text**, not as a
  number, so a spreadsheet cannot silently round a Rial figure into a
  float; IBANs are masked.
- Side effects: the download itself is audited, recording the operator and
  the filters used.
- An error raised before the file is produced still returns the ordinary
  JSON error envelope.
responses:
  200: string
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/payments/{id}
operationId: getPayment
auth: bearer
summary: Retrieve one payment order.

Returns a single payment, rendered from its frozen snapshots.

**Notes:**
- Requires `payments:read`.
- Sets the `ETag` response header to the row's `version`, quoted — e.g.
  `ETag: "7"`. Send that value back as `If-Match` on the subsequent
  `PATCH`.
- Soft-deleted payments **are** returned here, with `deletedAt` set, even
  though they are hidden from the list. An audit trail naming a payment
  has to stay followable.
- `payer.name`, `payee.name` and `payeeAccount` come from the frozen
  snapshot columns, never from a live join. Renaming the party or editing
  the account does not change what this payment says.
- `availableTransitions` is computed for the **calling** operator and
  already reflects their permissions and the four-eyes rule — which is
  why the two examples below differ on the same payment.
- `receipt.thumbnailUrl` is `null` for a PDF receipt; thumbnails are
  generated only for images.
- Side effects: none.
responses:
  200: PaymentEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/payments/{id}
operationId: deletePayment
auth: bearer
summary: Soft-delete a payment order.

Marks the payment deleted, provided it has not been confirmed.

**Notes:**
- Requires `payments:delete`.
- **Soft delete**: the row survives with `deletedAt` set. It disappears
  from the list, the export and the summary, but stays readable by id so
  the audit trail remains followable.
- Refused with `409 PAYMENT_IMMUTABLE` once the payment is `CONFIRMED` or
  `SETTLED` — those carry evidence somebody has relied on. `REJECTED`,
  `CANCELLED` and `FAILED` payments **can** be deleted.
- Returns `200` with `{ "id": … }`, not `204`.
- Does not require `If-Match`.
- Side effects: `deletedAt` set, `version` incremented, and an audit row
  written.
responses:
  200: PaymentDeletedEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/payments/{id}
operationId: updatePayment
auth: bearer
summary: Edit a draft or issued payment order.

Applies a partial update under an optimistic-concurrency precondition,
while the payment is still early enough in its life to be changed.

**Notes:**
- Requires `payments:update`.
- **`If-Match` is required**. Omitting it is `400 VALIDATION_FAILED` on
  the `If-Match` field; a stale value is `409 CONCURRENT_MODIFICATION`
  carrying both the expected and the actual version.
- Accepted **only** while the status is `DRAFT` or `ISSUED`. Anything
  later — including `AWAITING_RECEIPT` — is `409 PAYMENT_IMMUTABLE` with
  the current status in `details[]`.
- **Identity is not editable**: payer, payee and destination account are
  fixed at creation. To redirect a payment, cancel it and create a new
  one. The schema is strict, so sending `payeePartyId` is `400`, not a
  silent drop.
- `status` is not editable here either; it moves only through
  `POST /api/v1/payments/{id}/transitions`.
- At least one field must be supplied.
- `assetId` and `assetQuantity` must be supplied — or cleared with
  `null` — **together**.
- `description` and `internalNote` accept `null` to clear them.
- Side effects: the row updated, `version` incremented,
  `updatedByOperatorId` recorded, the search blob rebuilt when a
  searchable field changed, and an audit row written with a before/after
  diff.
request body: UpdatePaymentRequest
responses:
  200: PaymentEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/payments/{id}/transitions
operationId: listPaymentTransitions
auth: bearer
summary: List a payment's status history.

Returns every status change this payment has been through, including the
creation row.

**Notes:**
- Requires `payments:read`.
- **Not paginated** — `data` is a plain array. A payment has at most a
  handful of transitions.
- The first entry always has `fromStatus: null` and `toStatus: "DRAFT"`:
  that is the creation itself.
- `operatorNameSnapshot` is frozen at the moment of the transition, so the
  history keeps reading correctly after an operator is renamed or deleted.
  `operatorId` may be `null` for a transition the system performed rather
  than a person.
- `reason` is populated for rejections and for every backward edge, and is
  `null` otherwise.
- Side effects: none.
responses:
  200: PaymentTransitionListEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/payments/{id}/transitions
operationId: transitionPayment
auth: bearer
summary: Move a payment to another status.

Applies one declared edge of the state machine, enforcing the permission
and the guards that belong to that edge.

**Notes:**
- The permission depends on the **target status**, not on the route:
  `→ CONFIRMED` needs `payments:confirm`; `→ REJECTED` needs
  `payments:reject`; `AWAITING_RECEIPT → RECEIPT_UPLOADED` needs
  `payments:upload-receipt`; every other edge needs
  `payments:transition`. A `403` names the permission the requested edge
  required.
- Only an edge **declared from the current status** succeeds. Anything
  else is `409 PAYMENT_INVALID_TRANSITION`, with the `from` and `to` in
  `details[]`. `SETTLED`, `CANCELLED` and `FAILED` are terminal and have
  no outgoing edges at all.
- `→ CONFIRMED` requires a receipt to be attached
  (`409 PAYMENT_RECEIPT_REQUIRED`) **and**, unless
  `PAYMENT_REQUIRE_FOUR_EYES` is turned off, a different operator than the
  one who created the payment (`409 PAYMENT_FOUR_EYES_VIOLATION`).
- `reason` is **required** by `→ REJECTED` and by every backward edge
  (`UNDER_REVIEW → RECEIPT_UPLOADED`, `REJECTED → AWAITING_RECEIPT`).
  Omitting it is `400 VALIDATION_FAILED` on the `reason` field.
- `receiptFileId` is required **only** by the
  `AWAITING_RECEIPT → RECEIPT_UPLOADED` edge, and must name an existing
  stored file. In normal use that edge is taken automatically by
  `POST /api/v1/payments/{id}/receipt`; calling it here is for attaching a
  file that is already stored.
- The status is written with a compare-and-set against the status the
  caller last saw. If somebody else moved the payment in between, the
  answer is `409 CONCURRENT_MODIFICATION` — the edge *was* valid, the
  world moved.
- Returns `200`, not `201`. Consult `availableTransitions` on the returned
  payment for what is open next.
- This endpoint is the **only** thing in the system that assigns a payment
  status after creation.
- Side effects: the status updated, a transition row written with the
  operator's frozen name and the reason, `version` incremented, an audit
  row written, and the per-status counter moved.
request body: TransitionPaymentRequest
responses:
  200: PaymentEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/payments/{id}/receipt
operationId: getPaymentReceiptUrl
auth: bearer
summary: Get a short-lived signed URL for a payment's receipt.

Issues a fresh, expiring download link for the receipt after checking the
caller's permission and the file's malware-scan status.

**Notes:**
- Requires `payments:read`.
- The storage bucket is **private**. There is no permanent link, and this
  endpoint mints a new signed URL on every call rather than returning a
  stored one.
- `expiresInSeconds` comes from `RECEIPT_URL_TTL_SECONDS` (300 by
  default). Treat the URL as short-lived.
- A payment with **no** receipt is `404 RESOURCE_NOT_FOUND` naming
  `StoredFile`, not `PaymentOrder`.
- A file whose asynchronous malware scan has not finished is
  `409 RECEIPT_SCAN_PENDING`; a file the scan flagged is
  `409 RECEIPT_SCAN_INFECTED` and will never be served.
- Side effects: none beyond issuing the URL.
responses:
  200: ReceiptUrlEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/payments/{id}/receipt
operationId: uploadPaymentReceipt
auth: bearer
summary: Upload or replace a payment's receipt.

Accepts a receipt file as multipart, validates and re-encodes it, stores
it privately, and attaches it to the payment.

**Notes:**
- Requires `payments:upload-receipt` for a **first** upload. Uploading
  onto a payment that **already has** a receipt is a replace and
  additionally requires `payments:manage-receipt`; without it the answer
  is `403 PERMISSION_DENIED` naming that permission.
- Rate limited to **10 requests per minute per IP** — the tightest limit
  in the API after sign-in.
- The multipart field name is `file`. The size ceiling is
  `RECEIPT_MAX_BYTES` (10 MB by default); exceeding it is
  `413 PAYLOAD_TOO_LARGE`.
- The type is detected from the file's **magic bytes**, never from the
  client's `Content-Type`. Permitted types come from
  `RECEIPT_ALLOWED_MIME` — JPEG, PNG, WebP and PDF by default — and
  anything else is `415 RECEIPT_INVALID_TYPE` with the detected MIME in
  `details[]`. A PDF is additionally checked for a valid header and
  trailer.
- Images are **re-encoded**: EXIF is stripped and the longest side capped
  at 3000 px. The stored file is therefore not byte-identical to what was
  uploaded, and `receipt.mimeType` may differ from what was sent.
- The SHA-256 of the processed bytes is checked against every other live
  payment's receipt. A match is `409 RECEIPT_DUPLICATE`, naming the other
  payment's id and reference — the same image cannot evidence two
  payments.
- A thumbnail is generated for images and **not** for PDFs, which is why
  `receipt.thumbnailUrl` can be `null`.
- Refused with `409 PAYMENT_IMMUTABLE` once the payment is `CONFIRMED` or
  `SETTLED`.
- A first upload **auto-transitions**
  `AWAITING_RECEIPT → RECEIPT_UPLOADED` as part of the same operation.
- Returns `200`, not `201`, and the body is the refreshed **payment**, not
  the file.
- When malware scanning is enabled the file is queued for an asynchronous
  scan and starts `PENDING`; downloading is blocked until it comes back
  clean.
- Side effects: the object and its thumbnail written to private storage
  under a server-generated key (never the client's filename), a stored
  file row created, the payment linked to it, a status transition applied
  when applicable, an audit row written, and a scan job enqueued.
request body: object
responses:
  200: PaymentEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  413: ErrorEnvelope
  415: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/payments/{id}/receipt
operationId: deletePaymentReceipt
auth: bearer
summary: Detach a payment's receipt.

Clears the payment's link to its receipt, keeping the stored file itself.

**Notes:**
- Requires `payments:manage-receipt` — the same permission a replace
  needs, and stricter than `payments:upload-receipt`.
- Refused with `409 PAYMENT_IMMUTABLE` once the payment is `CONFIRMED` or
  `SETTLED`, for the same reason a replace is.
- A payment with no receipt is `404 RESOURCE_NOT_FOUND` naming
  `StoredFile`.
- **The stored file row is kept.** Only the link is cleared: evidence is
  never overwritten or destroyed in place, and the hygiene sweep's
  `STORED_FILE_ORPHANED` check will subsequently see it.
- The payment's **status does not change**. A payment left in
  `RECEIPT_UPLOADED` with no receipt is a real, visible state, and the
  hygiene sweep reports it.
- Returns `200` with the refreshed payment, not `204`.
- Side effects: `receiptFileId` and `receiptUploadedAt` cleared, `version`
  incremented, and an audit row written naming the detached file.
responses:
  200: PaymentEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PaginationMeta
type: object
description: The §9.1 success envelope's `meta` for an offset-paginated list. `totalPages` is derived by the envelope interceptor, never by a handler.
properties:
  page:
    type: integer
    description: The page actually served, after clamping.
  pageSize:
    type: integer
    description: Rows per page actually applied, after clamping to 1–100.
  total:
    type: integer
    description: Total rows matching the filter across all pages.
  totalPages:
    type: integer
    description: `ceil(total / pageSize)`, or `0` when `total` is 0.
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built.
required:
  - page
  - pageSize
  - total
  - totalPages
  - requestId
  - timestamp

### PaymentStatus
type: string
enum:
  - DRAFT
  - ISSUED
  - AWAITING_RECEIPT
  - RECEIPT_UPLOADED
  - UNDER_REVIEW
  - CONFIRMED
  - SETTLED
  - REJECTED
  - CANCELLED
  - FAILED
description: Where the payment stands. `SETTLED`, `CANCELLED` and `FAILED` are terminal and have no outgoing edges. `CONFIRMED` and `SETTLED` additionally freeze the payment against editing, deletion and any receipt change.

### SettlementType
type: string
enum:
  - DIRECT
  - THIRD_PARTY
  - INTERNAL
description: How the money moves. `DIRECT` — payer pays payee. `THIRD_PARTY` — the payer is instructed to pay into a nominated account, which is why this type **requires** `payeeBankAccountId`. `INTERNAL` — a movement inside the business.

### PaymentParty
type: object
description: One side of a payment, as **frozen at creation**. Never a live join: renaming the party afterwards does not change what this payment says.
properties:
  partyId:
    type: string
    format: uuid
    nullable: True
    description: The party record this side referred to, or `null` when the side was an accounting code with no party — which also sets `hasOrphanReference`.
  name:
    type: string
    description: The name as it stood when the payment was created. Always populated, including for an orphaned side, where it is the placeholder name.
  externalCode:
    type: string
    nullable: True
    description: The accounting system's code for this side, frozen at creation. `null` when the side was given only as a party id with no code.
required:
  - partyId
  - name
  - externalCode

### PaymentAccount
type: object
description: The destination account as **frozen at creation**. Editing or deleting the underlying bank account afterwards does not change this snapshot.
properties:
  bankNameFa:
    type: string
    nullable: True
    description: The Persian bank name at the time of creation; `null` if none was resolved.
  ibanMasked:
    type: string
    nullable: True
    description: The snapshot IBAN, masked. `null` when the account carried none. There is **no** endpoint that reveals a payment's snapshot IBAN in full.
  accountNumber:
    type: string
    nullable: True
    description: The account number at the time of creation. Not masked.
  accountHolderName:
    type: string
    nullable: True
    description: The account holder's name at the time of creation.
required:
  - bankNameFa
  - ibanMasked
  - accountNumber
  - accountHolderName

### PaymentAmount
type: object
description: The payment's Rial amount, in three renderings of the same value.
properties:
  minor:
    type: string
    description: The authoritative value: a positive integer **string** in Rial minor units. The value the `minAmount`/`maxAmount` filters compare against.
  toman:
    type: string
    description: The same amount in Toman, as a decimal **string**. A convenience rendering.
  formatted:
    type: string
    description: Server-rendered Persian string with digit grouping and the currency word.
required:
  - minor
  - toman
  - formatted

### PaymentAsset
type: object
description: The optional asset leg alongside the Rial amount — a gold or silver quantity moving with the payment.
properties:
  assetId:
    type: string
    format: uuid
    description: The asset this quantity is denominated in; resolve it through the Assets API.
  quantity:
    type: string
    description: Decimal **string** at three-decimal scale, in the asset's own unit (grams for weights). Never parse it with `Number()`.
required:
  - assetId
  - quantity

### PaymentReceipt
type: object
description: The receipt attached to a payment. `null` on the payment when none is attached.
properties:
  fileId:
    type: string
    format: uuid
    description: The stored file's id. Use it with the Files API to fetch the thumbnail.
  uploadedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant the receipt was attached.
  thumbnailUrl:
    type: string
    nullable: True
    description: Relative path to the thumbnail, or `null` — thumbnails are generated for images and **not** for PDFs. This is a path, not a signed URL; the full-size file needs `GET /api/v1/payments/{id}/receipt`.
  mimeType:
    type: string
    description: The **detected** type of the stored file, not what the client declared. Images are re-encoded, so this may differ from what was uploaded.
required:
  - fileId
  - uploadedAt
  - thumbnailUrl
  - mimeType

### Payment
type: object
description: A payment order. Every "who" and "where" field is a frozen snapshot; nothing here is a live join except the receipt's file metadata.
properties:
  id:
    type: string
    format: uuid
    description: Internal id (UUID v7). The identifier every endpoint in this file takes.
  reference:
    type: string
    description: Human-readable identifier of the form `PAY-<jalali year>-<sequence>`, allocated at creation from a per-year sequence. Unique and never reused.
  settlementType:
    $ref: #/components/schemas/SettlementType
  settlementTypeFa:
    type: string
    description: The settlement type in Persian — `پرداخت مستقیم` / `پرداخت به شخص ثالث` / `انتقال داخلی`.
  payer:
    $ref: #/components/schemas/PaymentParty
  payee:
    $ref: #/components/schemas/PaymentParty
  payeeAccount:
    allOf:
      -
        $ref: #/components/schemas/PaymentAccount
    nullable: True
    description: `null` when no destination account was nominated — impossible for a `THIRD_PARTY` payment, which requires one.
  amount:
    $ref: #/components/schemas/PaymentAmount
  asset:
    allOf:
      -
        $ref: #/components/schemas/PaymentAsset
    nullable: True
    description: `null` when the payment carries no asset leg.
  status:
    $ref: #/components/schemas/PaymentStatus
  statusFa:
    type: string
    description: The status in Persian — `پیش‌نویس`, `در انتظار فیش`, `تأیید شده`, and so on.
  receipt:
    allOf:
      -
        $ref: #/components/schemas/PaymentReceipt
    nullable: True
    description: `null` when no receipt is attached — including after one has been detached.
  valueDate:
    type: string
    format: date-time
    description: ISO-8601 UTC value date of the payment.
  valueDateJalali:
    type: string
    description: The same date as a Persian (Jalali) string, `"YYYY/MM/DD HH:MM:SS"`.
  availableTransitions:
    type: array
    description: The statuses **this operator** may move the payment to right now. Already accounts for their permissions and for the four-eyes rule, so two operators can legitimately see different lists on the same payment. Empty for a terminal status.
    items:
      $ref: #/components/schemas/PaymentStatus
  hasOrphanReference:
    type: boolean
    description: `true` when the payer or payee was an accounting code with no party record at creation time. Set once, at creation, and never recomputed — it records what was true then.
  description:
    type: string
    nullable: True
    description: Free text visible on the payment; up to 2000 characters.
  internalNote:
    type: string
    nullable: True
    description: Internal note, up to 2000 characters. Distinct from `description`.
  version:
    type: integer
    description: Optimistic-concurrency counter. Returned as the `ETag` header and required back as `If-Match` on `PATCH`.
  deletedAt:
    type: string
    format: date-time
    nullable: True
    description: ISO-8601 UTC instant of the soft delete; `null` for a live payment. A soft-deleted payment is hidden from the list but readable by id.
  deletedAtJalali:
    type: string
    nullable: True
    description: The soft-delete instant as a Persian (Jalali) string; `null` for a live payment.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant.
  createdAtJalali:
    type: string
    description: The creation instant as a Persian (Jalali) string.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write of any kind, including a status change.
  updatedAtJalali:
    type: string
    description: The last-write instant as a Persian (Jalali) string.
required:
  - id
  - reference
  - settlementType
  - settlementTypeFa
  - payer
  - payee
  - payeeAccount
  - amount
  - asset
  - status
  - statusFa
  - receipt
  - valueDate
  - valueDateJalali
  - availableTransitions
  - hasOrphanReference
  - description
  - internalNote
  - version
  - deletedAt
  - deletedAtJalali
  - createdAt
  - createdAtJalali
  - updatedAt
  - updatedAtJalali

### PaymentTransition
type: object
description: One status change, with the operator's name frozen at the moment it happened.
properties:
  id:
    type: string
    format: uuid
    description: Internal id of the transition row.
  fromStatus:
    allOf:
      -
        $ref: #/components/schemas/PaymentStatus
    nullable: True
    description: `null` only on the first row, which records the payment's creation.
  fromStatusFa:
    type: string
    nullable: True
    description: The originating status in Persian; `null` on the creation row.
  toStatus:
    $ref: #/components/schemas/PaymentStatus
  toStatusFa:
    type: string
    description: The resulting status in Persian.
  operatorId:
    type: string
    format: uuid
    nullable: True
    description: The operator who made the change, or `null` for a transition the system performed rather than a person.
  operatorNameSnapshot:
    type: string
    nullable: True
    description: The operator's display name **frozen at the moment of the transition**, so the history keeps reading correctly after they are renamed or deleted.
  reason:
    type: string
    nullable: True
    description: Why the change was made. Always populated for a rejection and for a backward edge, which require it; `null` otherwise.
  occurredAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the transition was applied.
required:
  - id
  - fromStatus
  - fromStatusFa
  - toStatus
  - toStatusFa
  - operatorId
  - operatorNameSnapshot
  - reason
  - occurredAt

### PaymentStatusCount
type: object
description: The count and Rial sum of payments in one status. Statuses with no payments are absent from the array rather than reported as zero.
properties:
  status:
    $ref: #/components/schemas/PaymentStatus
  statusFa:
    type: string
    description: The status in Persian.
  count:
    type: integer
    description: How many non-deleted payments hold this status.
  sumMinor:
    type: string
    description: Their total amount as a decimal **string** in Rial minor units.
required:
  - status
  - statusFa
  - count
  - sumMinor

### PaymentsSummary
type: object
description: The dashboard aggregate. Takes no filters — it always covers the whole book.
properties:
  byStatus:
    type: array
    description: One entry per status that has at least one payment.
    items:
      $ref: #/components/schemas/PaymentStatusCount
  awaitingReceiptCount:
    type: integer
    description: Payments currently in `AWAITING_RECEIPT`.
  reviewSlaBreachCount:
    type: integer
    description: Payments whose receipt has been waiting for review longer than `RECEIPT_REVIEW_SLA_HOURS` — the same condition the hygiene sweep reports as `RECEIPT_REVIEW_OVERDUE`.
  orphanReferenceCount:
    type: integer
    description: Payments whose payer or payee had no party record at creation.
required:
  - byStatus
  - awaitingReceiptCount
  - reviewSlaBreachCount
  - orphanReferenceCount

### HygieneFinding
type: object
description: One problem the sweep found. A report, never a repair — nothing is mutated by producing it.
properties:
  check:
    type: string
    enum:
      - CONFIRMED_WITHOUT_RECEIPT
      - RECEIPT_REVIEW_OVERDUE
      - DUPLICATE_RECEIPT_SHA256
      - STORED_FILE_UNHEALTHY
      - STORED_FILE_ORPHANED
    description: Which of the five checks produced this finding. `CONFIRMED_WITHOUT_RECEIPT` — a confirmed payment with no evidence. `RECEIPT_REVIEW_OVERDUE` — past the review SLA. `DUPLICATE_RECEIPT_SHA256` — two payments sharing a receipt image. `STORED_FILE_UNHEALTHY` — the object is missing or its scan failed. `STORED_FILE_ORPHANED` — a stored file no payment points at.
  severity:
    type: string
    enum:
      - NOTICE
      - CRITICAL
    description: How urgent the finding is. Also the level of the audit row filed alongside it.
  summaryEn:
    type: string
    description: One-sentence English description, with the concrete numbers in it.
  summaryFa:
    type: string
    description: The same sentence in Persian, for the panel.
  resourceType:
    type: string
    enum:
      - PaymentOrder
      - StoredFile
    description: What kind of record the finding is about.
  resourceId:
    type: string
    format: uuid
    description: The offending record's internal id.
  resourceLabel:
    type: string
    description: A human-readable label — the payment reference, or the stored file's object key.
  context:
    type: object
    description: Free-form context for the operator: the payment reference, the frozen party names, file ids, ages in hours. Enough to act on without a second query. Keys vary by `check`.
    additionalProperties: True
required:
  - check
  - severity
  - summaryEn
  - summaryFa
  - resourceType
  - resourceId
  - resourceLabel
  - context

### HygieneReport
type: object
description: The result of one sweep. Empty `findings` with zero counts is the healthy outcome.
properties:
  generatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the sweep ran — which is when this request was served.
  receiptReviewSlaHours:
    type: integer
    description: The configured review SLA (`RECEIPT_REVIEW_SLA_HOURS`) the `RECEIPT_REVIEW_OVERDUE` check applied, reported so the threshold is visible alongside the findings.
  counts:
    type: object
    description: How many findings of each severity.
    properties:
      notice:
        type: integer
        description: Findings with severity `NOTICE`.
      critical:
        type: integer
        description: Findings with severity `CRITICAL`.
    required:
      - notice
      - critical
  findings:
    type: array
    description: Every finding, in the order the checks produced them.
    items:
      $ref: #/components/schemas/HygieneFinding
required:
  - generatedAt
  - receiptReviewSlaHours
  - counts
  - findings

### PaymentEnvelope
type: object
description: Success envelope around a single payment order.
properties:
  data:
    $ref: #/components/schemas/Payment
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaginatedPayments
type: object
description: A page of payment orders in the §9.1 paginated envelope.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/Payment
  meta:
    $ref: #/components/schemas/PaginationMeta
required:
  - data
  - meta

### PaymentTransitionListEnvelope
type: object
description: Success envelope around one payment's status history. Not paginated — `meta` carries only `requestId` and `timestamp`.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/PaymentTransition
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaymentsSummaryEnvelope
type: object
description: Success envelope around the dashboard aggregate.
properties:
  data:
    $ref: #/components/schemas/PaymentsSummary
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### HygieneReportEnvelope
type: object
description: Success envelope around the hygiene sweep's report.
properties:
  data:
    $ref: #/components/schemas/HygieneReport
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### ReceiptUrlEnvelope
type: object
description: Success envelope around a freshly signed receipt download URL.
properties:
  data:
    type: object
    properties:
      url:
        type: string
        format: uri
        description: A signed, expiring URL into private storage. Minted per request; there is no permanent link.
      expiresInSeconds:
        type: integer
        description: How long the URL stays valid, from `RECEIPT_URL_TTL_SECONDS` (300 by default).
    required:
      - url
      - expiresInSeconds
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PaymentDeletedEnvelope
type: object
description: Success envelope confirming a soft deletion.
properties:
  data:
    type: object
    properties:
      id:
        type: string
        format: uuid
        description: The payment id that was soft-deleted, echoed from the path.
    required:
      - id
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### CreatePaymentRequest
type: object
description: A new payment instruction. **Strict**: `status`, `reference` and the snapshots are server-assigned and are rejected rather than dropped. Each side may be given as a party id **or** as an external code plus a name, so an accounting code with no party record can still be paid.
properties:
  settlementType:
    $ref: #/components/schemas/SettlementType
  payerPartyId:
    type: string
    format: uuid
    description: The paying party. Required unless `payerNameSnapshot` is supplied instead.
  payerExternalCode:
    type: string
    minLength: 1
    maxLength: 64
    description: The payer's accounting code. Meaningful only when `payerPartyId` is absent — the orphaned-payer case.
  payerNameSnapshot:
    type: string
    minLength: 1
    maxLength: 200
    description: The payer's name, typed by the operator, for a payer with no party record. Frozen into the payment as-is.
  payeePartyId:
    type: string
    format: uuid
    description: The party being paid.
  payeeExternalCode:
    type: string
    minLength: 1
    maxLength: 64
    description: The payee's accounting code, for a payee with no party record.
  payeeNameSnapshot:
    type: string
    minLength: 1
    maxLength: 200
    description: The payee's name, typed by the operator, for a payee with no party record.
  payeeBankAccountId:
    type: string
    format: uuid
    description: The destination account. **Required** for `THIRD_PARTY`: without a nominated account there is no instruction, only an intention. Must belong to `payeePartyId` when both are given.
  amountMinor:
    type: string
    pattern: ^\d+$
    description: A **positive integer string** in Rial minor units. Zero is rejected here and by a database constraint.
  assetId:
    type: string
    format: uuid
    description: Optional asset leg. Must be supplied together with `assetQuantity`.
  assetQuantity:
    type: string
    pattern: ^\d+(?:\.\d{1,3})?$
    description: Non-negative decimal **string** with up to three decimal places, in the asset's own unit. Must be supplied together with `assetId`.
  valueDate:
    type: string
    description: The payment's value date. Accepts ISO-8601 or Jalali (`1405/05/13`, `۱۴۰۵/۰۵/۱۳ ۱۱:۳۵`); converted at the boundary.
  description:
    type: string
    maxLength: 2000
    description: Free text visible on the payment.
  internalNote:
    type: string
    maxLength: 2000
    description: Internal note.
required:
  - settlementType
  - amountMinor
  - valueDate

### UpdatePaymentRequest
type: object
description: Partial update, accepted only while the payment is `DRAFT` or `ISSUED`. At least one field must be supplied. **Strict**: payer, payee, account and `status` are absent by design — identity is fixed at creation and status moves only through the transitions endpoint.
properties:
  amountMinor:
    type: string
    pattern: ^\d+$
    description: New amount as a positive integer string in Rial minor units.
  valueDate:
    type: string
    description: New value date. ISO-8601 or Jalali.
  description:
    type: string
    maxLength: 2000
    nullable: True
    description: New description, or `null` to clear it.
  internalNote:
    type: string
    maxLength: 2000
    nullable: True
    description: New internal note, or `null` to clear it.
  assetId:
    type: string
    format: uuid
    nullable: True
    description: New asset for the optional leg, or `null` to clear it. Must be changed together with `assetQuantity`.
  assetQuantity:
    type: string
    pattern: ^\d+(?:\.\d{1,3})?$
    nullable: True
    description: New quantity, or `null` to clear it. Must be changed together with `assetId`.

### TransitionPaymentRequest
type: object
description: One move through the state machine. **Strict**: only these three keys are accepted. Which of the optional two are required depends on the **edge**, not on the endpoint.
properties:
  to:
    allOf:
      -
        $ref: #/components/schemas/PaymentStatus
    description: The target status. Must be a declared edge from the payment's current status, or the request is `409 PAYMENT_INVALID_TRANSITION`.
  reason:
    type: string
    minLength: 1
    maxLength: 1000
    description: Why the move is being made. **Required** for `→ REJECTED` and for every backward edge (`UNDER_REVIEW → RECEIPT_UPLOADED`, `REJECTED → AWAITING_RECEIPT`); ignored otherwise.
  receiptFileId:
    type: string
    format: uuid
    description: The already-stored file to attach as evidence. **Required only** by the `AWAITING_RECEIPT → RECEIPT_UPLOADED` edge. Normal use takes that edge automatically through `POST /api/v1/payments/{id}/receipt` instead.
required:
  - to


========================================================================
# Admin Roles and Permissions API (app: rbac-admin, version 1.0.0)
========================================================================

Servers: http://localhost:3000

The panel's access-control settings: the **panel roles** operators are
assigned to, and the read-only **permission catalog** those roles are built
from. §4.1 — these are panel roles, not party groups; nothing here has
anything to do with صندوق / ويترين داران / خانگي, which classify external
counterparties and confer no access at all.

## Authentication
Every endpoint requires a Bearer access token whose operator holds a
`roles:*` permission. In the seeded role set only **ADMIN** holds any of
them.

1. Obtain a token pair from `POST /api/v1/auth/login` — see the
   Authentication API. There is no separate staff login endpoint.
2. Send `Authorization: Bearer <accessToken>` on every request.
3. Refresh the pair through the same `POST /api/v1/auth/refresh`.
4. Before `PUT /api/v1/panel-roles/{id}/permissions`, call
   `POST /api/v1/auth/reauth` with the password — `roles:manage-permissions`
   is a dangerous permission and refuses to run without a re-authentication
   in the last five minutes.

A missing, malformed or expired token returns `401`. A valid token whose
operator lacks the permission returns `403 PERMISSION_DENIED`, and every such
denial is written to the audit log with `outcome: DENIED`.

## Conventions
- Success bodies are the §9.1 envelope `{ data, meta }`. **Neither list here
  is paginated** — there are about a dozen roles and exactly 47 permission
  keys, so both return a plain array in `data` and `meta` carries only
  `requestId` and `timestamp`.
- `createdAt` / `updatedAt` are **ISO-8601 UTC** strings with milliseconds,
  e.g. `"2026-08-21T09:27:33.104Z"`.
- A role's `permissions` is a flat array of permission **keys**, not objects.
  Resolve a key to its labels through `GET /api/v1/permissions`.
- `PATCH /api/v1/panel-roles/{id}` requires `If-Match` carrying the row's
  `version`, echoed as the `ETag` response header by every read and every
  write. A stale value is `409 CONCURRENT_MODIFICATION`.
  `PUT /api/v1/panel-roles/{id}/permissions` deliberately does **not** take a
  precondition — it is a whole-set replacement whose own ADMIN guard is the
  protection that matters.
- Three rows are protected by `isSystem`: `ADMIN`, `ACCOUNTANT` and `VIEWER`,
  seeded and owned by the migration. They can be renamed and re-described but
  never deleted.
- Role writes are **hard**, not soft: a role has no history worth retaining
  and cannot be deleted while any operator holds it.
- Validation failures are `400 VALIDATION_FAILED` with one `details[]` entry
  per offending field.

## Grant model
- A role is created **empty**. Grants arrive only through
  `PUT /api/v1/panel-roles/{id}/permissions`, which carries the dangerous
  permission. Accepting grants at creation time would let `roles:manage` —
  which is *not* dangerous — mint a role holding `operators:delete` and route
  every grant around the re-auth gate.
- `ADMIN` may be **extended** but never **reduced**: a later milestone adding
  a 48th permission key should land on ADMIN like everything else, but
  removing one of its keys is `409 ROLE_ADMIN_NOT_REDUCIBLE`. The check is on
  the difference, not on the endpoint.
- `ADMIN` short-circuits authorization to allow-all at request time, so its
  materialised grants and its effective access always agree. They are
  materialised anyway: a settings screen rendering ADMIN with zero
  permissions invites someone to "fix" it.
- Changing a role's grants touches the role row itself. That is the
  revocation mechanism, not bookkeeping: the authorization guard compares an
  access token's `iat` against `panel_roles.updated_at`, so a revoked
  permission bites on the holder's next request rather than when their
  15-minute token expires.

## x-role-immutability
description: The rules that stop an administrator locking the panel's access control into an unrecoverable state, from `src/modules/rbac/rbac.service.ts` and `prisma/seeds/roles.seed.ts`.
rules:
  -
    system_roles: ADMIN, ACCOUNTANT and VIEWER carry isSystem: true. They may be renamed and re-described but never deleted (409 ROLE_SYSTEM_IMMUTABLE), and no role created over HTTP can set the flag.
  -
    admin_grants: ADMIN may be extended but never reduced (409 ROLE_ADMIN_NOT_REDUCIBLE). The check is on the difference between held and submitted, so adding a new key still works.
  -
    in_use: A role held by at least one operator cannot be deleted (409 ROLE_IN_USE). Reassign the operators through PATCH /api/v1/operators/{id} first.
  -
    grant_door: PUT /api/v1/panel-roles/{id}/permissions is the only way to change a grant set, and it carries the dangerous roles:manage-permissions. Role creation accepts no grants, so roles:manage cannot route around the re-auth gate.

## Operations

### GET /api/v1/panel-roles
operationId: adminListPanelRoles
auth: bearer
summary: List panel roles with their grants and operator counts.

Returns every panel role, each with its complete permission key set and
the number of operators currently assigned to it.

**Notes:**
- Requires `roles:read`.
- **Not paginated** — `data` is a plain array. There are only ever a
  handful of roles.
- `operatorCount` is included so the settings screen can warn before a
  role is emptied or removed; a role with `operatorCount > 0` cannot be
  deleted.
- `permissions` is sorted as the database returns it and should be treated
  as a set, not an ordered list.
- Side effects: none.
responses:
  200: PanelRoleListEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### POST /api/v1/panel-roles
operationId: adminCreatePanelRole
auth: bearer
summary: Create an empty panel role.

Registers a new role with display names and no grants at all.

**Notes:**
- Requires `roles:manage`.
- The role is created **empty**, deliberately. Grant it through
  `PUT /api/v1/panel-roles/{id}/permissions`, which carries the dangerous
  `roles:manage-permissions`. Accepting a `permissions` array here would
  let `roles:manage` route every grant around that gate — so a
  `permissions` key in the body is stripped, not honoured.
- The new role is never `isSystem`. That flag marks the three roles the
  seeder owns, and is what makes them undeletable; a role cannot promote
  itself out of deletion.
- `key` must be UPPER_SNAKE_CASE, 2–40 characters, starting with a letter
  — it is the stable identifier the guards and seeds match on and it is
  never localised or translated.
- A `key` already in use is `409 ROLE_KEY_TAKEN`.
- Sets the `ETag` response header to the new row's `version` (`"1"`).
- Side effects: the role row is created and a `role.created` audit row is
  written.
request body: CreatePanelRoleRequest
responses:
  201: PanelRoleEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/panel-roles/{id}
operationId: adminGetPanelRole
auth: bearer
summary: Retrieve one panel role.

Returns a single role with its grants and operator count.

**Notes:**
- Requires `roles:read`.
- Sets the `ETag` response header to the row's `version`, quoted — e.g.
  `ETag: "3"`. Send that value back as `If-Match` on the subsequent
  `PATCH`.
- Side effects: none.
responses:
  200: PanelRoleEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### DELETE /api/v1/panel-roles/{id}
operationId: adminDeletePanelRole
auth: bearer
summary: Delete a panel role.

Permanently removes a role and its grants. Returns a confirmation object
rather than `204`, so the panel can reconcile its list without a re-read.

**Notes:**
- Requires `roles:manage`.
- This is a **hard** delete. Unlike an operator or a party, a role carries
  no history worth retaining; its `role_permissions` rows cascade away
  with it.
- Refused for a seeded `isSystem` role (`409 ROLE_SYSTEM_IMMUTABLE`) and
  for any role still assigned to at least one operator
  (`409 ROLE_IN_USE`, with the operator count in `details[]`). Reassign
  those operators through
  `PATCH /api/v1/operators/{id}` first.
- The foreign key from `operators.role_id` would refuse an in-use role
  anyway; refusing here turns a database constraint violation into a
  sentence an operator can act on.
- Does not require `If-Match`.
- Side effects: the role and its grants are deleted, a `role.deleted`
  audit row is written, and the role's cached RBAC stamp is invalidated.
responses:
  200: RoleDeletedEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PATCH /api/v1/panel-roles/{id}
operationId: adminUpdatePanelRole
auth: bearer
summary: Rename or re-describe a panel role.

Applies a partial update to the role's display fields under an
optimistic-concurrency precondition. Grants are not touched here.

**Notes:**
- Requires `roles:manage`.
- **`If-Match` is required**. Omitting it is `400 VALIDATION_FAILED` on
  the `If-Match` field; a stale value is `409 CONCURRENT_MODIFICATION`
  carrying both the expected and the actual version. `W/"3"`, `"3"`, `3`
  and `*` are all accepted spellings.
- At least one of `nameFa`, `nameEn`, `description` must be sent.
- `key` is **not** editable: it is the identifier the guards and seeds
  match on, and renaming it would silently detach every permission
  decision from its role. `isSystem` is not editable either.
- A `isSystem` role may be renamed and re-described here — only deletion
  is blocked for them.
- Send `description: null` to clear the description.
- Side effects: `version` incremented, `updatedByOperatorId` recorded, a
  `role.updated` audit row written with a before/after diff, and the
  role's cached RBAC stamp invalidated.
request body: UpdatePanelRoleRequest
responses:
  200: PanelRoleEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### PUT /api/v1/panel-roles/{id}/permissions
operationId: adminSetPanelRolePermissions
auth: bearer
summary: Replace a role's entire permission set.

Sets the role's grants to exactly the submitted list. This is the only
door into a grant set.

**Notes:**
- Requires `roles:manage-permissions`, which is a **dangerous**
  permission: `POST /api/v1/auth/reauth` must have succeeded on this
  access token within the last five minutes, otherwise the call is
  `403 AUTH_REAUTH_REQUIRED`. `ADMIN` is not exempt from this.
- The body is the **complete desired grant set**, not a delta. Keys
  present in the body but not held are granted; keys held but absent from
  the body are revoked. Sending `{"permissions": []}` strips the role
  bare.
- Every entry must be one of the 47 catalog keys; an unrecognised key is
  `400 VALIDATION_FAILED`. Read the catalog from
  `GET /api/v1/permissions`.
- `ADMIN` may be **extended** but never **reduced**: if the submitted set
  omits any key ADMIN currently holds, the whole request is
  `409 ROLE_ADMIN_NOT_REDUCIBLE` with one `details[]` entry per key that
  would have been removed. Nothing is applied.
- Sending a set identical to the one already held is a no-op: the role is
  returned unchanged, `version` does not move, and no audit row is
  written.
- Does **not** require `If-Match`. A whole-set replacement is not a field
  edit, and its own ADMIN guard is the protection that matters.
- Side effects on a real change: grants inserted and deleted, the **role
  row itself** touched (`version` incremented) so that the authorization
  guard's `iat`-versus-`updated_at` comparison forces every live token for
  this role to re-read its permissions on the next request, a
  `role.permissions_changed` audit row written with the full before/after
  key sets plus granted/revoked counts, and the role's cached RBAC stamp
  invalidated after commit.
- Sets the `ETag` response header to the role's new `version`.
request body: SetRolePermissionsRequest
responses:
  200: PanelRoleEnvelope
  400: ErrorEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  404: ErrorEnvelope
  409: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

### GET /api/v1/permissions
operationId: adminListPermissions
auth: bearer
summary: List the permission catalog, grouped.

Returns the 47 permission keys in their 12 groups, each with its Persian
and English label and its `isDangerous` flag.

**Notes:**
- Requires `roles:read`.
- **Not paginated**, and not writable over HTTP: the catalog is seeded
  reference data that only a migration changes. There is no `POST`,
  `PATCH` or `DELETE` here by design.
- The 12 groups are `parties`, `party-groups`, `balances`, `assets`,
  `bank-accounts`, `payments`, `matching`, `operators`, `roles`,
  `settings`, `audit` and `sync`. A key's group is the part before the
  colon.
- Exactly three keys carry `isDangerous: true` — `operators:delete`,
  `roles:manage-permissions` and `audit:export`. Exercising one requires a
  password re-entry through `POST /api/v1/auth/reauth` within the last
  five minutes, so the panel should render them differently rather than
  discovering it at the point of the `403`.
- Side effects: none.
responses:
  200: PermissionGroupListEnvelope
  401: ErrorEnvelope
  403: ErrorEnvelope
  429: ErrorEnvelope
  500: ErrorEnvelope

## Schemas

### ErrorDetail
type: object
description: One machine-readable reason for a refusal. `field` is a dotted path into the request payload, or a header name, when the reason is attributable to one; further keys vary by `issue` and are described on the operation that produces them.
properties:
  field:
    type: string
    description: Dotted path into the request payload, or a header name. Absent on whole-request refusals.
  issue:
    type: string
    description: Stable machine-readable reason, e.g. `not_found`, `version_mismatch`, `denied`.
  message:
    type: string
    description: Human-readable elaboration. Present on validation issues raised by the schema layer.
required:
  - issue
additionalProperties: True

### ErrorEnvelope
type: object
description: The §9.1 error shape, returned by every failing request in every module of this API. `code` comes from the project's error catalog, so a client branches on it rather than on message text.
properties:
  error:
    type: object
    properties:
      code:
        type: string
        description: Stable catalog code, e.g. `RESOURCE_NOT_FOUND`, `VALIDATION_FAILED`, `CONCURRENT_MODIFICATION`.
      message:
        type: string
        description: English sentence. Also written to the server log.
      messageFa:
        type: string
        description: Persian sentence for the panel. Always present, so the frontend never maintains its own translation table.
      status:
        type: integer
        description: HTTP status, repeated in the body so it survives logging and proxying.
      details:
        type: array
        description: Zero or more machine-readable reasons. Empty for refusals with nothing to attribute.
        items:
          $ref: #/components/schemas/ErrorDetail
      requestId:
        type: string
        description: ULID correlating this response with the server log line. Also returned in the `X-Request-Id` response header.
    required:
      - code
      - message
      - messageFa
      - status
      - details
      - requestId
required:
  - error

### ResponseMeta
type: object
description: The §9.1 success envelope's `meta` for a single-object response.
properties:
  requestId:
    type: string
    description: ULID correlating this response with the server log line.
  timestamp:
    type: string
    format: date-time
    description: ISO-8601 UTC instant the envelope was built, e.g. `"2026-08-21T09:27:33.104Z"`.
required:
  - requestId
  - timestamp

### PermissionKey
type: string
description: One of the 47 catalog keys, formatted `<group>:<action>`. The full list is available from `GET /api/v1/permissions`.
example: payments:upload-receipt

### Permission
type: object
description: One entry in the seeded permission catalog.
properties:
  key:
    $ref: #/components/schemas/PermissionKey
  groupKey:
    type: string
    description: The part of `key` before the colon; the group this permission is rendered under.
  nameFa:
    type: string
    description: Persian label for the settings screen.
  nameEn:
    type: string
    description: English label, used in logs and English-locale clients.
  description:
    type: string
    nullable: True
    description: Longer explanation of what the key allows; `null` when the labels suffice.
  isDangerous:
    type: boolean
    description: `true` for `operators:delete`, `roles:manage-permissions` and `audit:export`. Exercising one requires a password re-entry within the last five minutes, and ADMIN is not exempt.
required:
  - key
  - groupKey
  - nameFa
  - nameEn
  - description
  - isDangerous

### PermissionGroup
type: object
description: The catalog entries sharing one `groupKey`, as the settings screen renders them.
properties:
  groupKey:
    type: string
    description: One of the 12 groups, e.g. `parties`, `payments`, `matching`, `sync`.
  permissions:
    type: array
    items:
      $ref: #/components/schemas/Permission
required:
  - groupKey
  - permissions

### PanelRole
type: object
description: A panel role and its complete grant set. §4.1 — governs who may click what in the panel; unrelated to a party's group.
properties:
  id:
    type: string
    format: uuid
    description: Internal role id (UUID v7). This is what `roleId` refers to elsewhere in the API.
  key:
    type: string
    description: Stable UPPER_SNAKE_CASE identifier the guards and seeds match on. Immutable after creation and never localised.
  nameFa:
    type: string
    description: Persian display name, shown in the panel.
  nameEn:
    type: string
    description: English display name.
  description:
    type: string
    nullable: True
    description: Free-text explanation of what the role is for; `null` when unset or cleared.
  isSystem:
    type: boolean
    description: `true` for the three seeded roles (`ADMIN`, `ACCOUNTANT`, `VIEWER`). A system role can be renamed but never deleted. Read-only.
  permissions:
    type: array
    description: The role's complete grant set as a flat array of permission keys. Treat it as a set; the order is not meaningful. Empty for a newly created role.
    items:
      $ref: #/components/schemas/PermissionKey
  operatorCount:
    type: integer
    description: How many operators currently hold this role. Computed, read-only, and the reason a delete can be refused with `ROLE_IN_USE`.
  version:
    type: integer
    description: Optimistic-concurrency counter. Returned as the `ETag` header and required back as `If-Match` on `PATCH`. Also incremented by a grant change, which is what forces live tokens to re-read their permissions.
  createdAt:
    type: string
    format: date-time
    description: ISO-8601 UTC creation instant.
  updatedAt:
    type: string
    format: date-time
    description: ISO-8601 UTC instant of the last write. The authorization guard compares an access token's `iat` against this column to decide whether to re-read permissions from the database.
required:
  - id
  - key
  - nameFa
  - nameEn
  - description
  - isSystem
  - permissions
  - operatorCount
  - version
  - createdAt
  - updatedAt

### PanelRoleEnvelope
type: object
description: Success envelope around a single panel role.
properties:
  data:
    $ref: #/components/schemas/PanelRole
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PanelRoleListEnvelope
type: object
description: Success envelope around every panel role. Not paginated — `meta` carries only `requestId` and `timestamp`.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/PanelRole
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### PermissionGroupListEnvelope
type: object
description: Success envelope around the grouped permission catalog. Not paginated.
properties:
  data:
    type: array
    items:
      $ref: #/components/schemas/PermissionGroup
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### RoleDeletedEnvelope
type: object
description: Success envelope confirming a role deletion.
properties:
  data:
    type: object
    properties:
      deleted:
        type: boolean
        description: Always `true`; the field exists so the panel can assert on it.
      id:
        type: string
        format: uuid
        description: The id that was removed, echoed back from the path.
    required:
      - deleted
      - id
  meta:
    $ref: #/components/schemas/ResponseMeta
required:
  - data
  - meta

### CreatePanelRoleRequest
type: object
description: A new, empty role. A `permissions` key sent here is stripped by the validation layer rather than honoured — grants have exactly one door.
properties:
  key:
    type: string
    minLength: 2
    maxLength: 40
    pattern: ^[A-Z][A-Z0-9_]*$
    description: UPPER_SNAKE_CASE identifier, e.g. `NIGHT_DESK`. Immutable once created.
  nameFa:
    type: string
    minLength: 1
    maxLength: 120
    description: Persian display name.
  nameEn:
    type: string
    minLength: 1
    maxLength: 120
    description: English display name.
  description:
    type: string
    maxLength: 1000
    description: Optional free-text explanation of what the role is for.
required:
  - key
  - nameFa
  - nameEn

### UpdatePanelRoleRequest
type: object
description: Partial update; any subset of these fields may be sent, but at least one must be. `key` and `isSystem` are deliberately absent.
properties:
  nameFa:
    type: string
    minLength: 1
    maxLength: 120
    description: New Persian display name.
  nameEn:
    type: string
    minLength: 1
    maxLength: 120
    description: New English display name.
  description:
    type: string
    maxLength: 1000
    nullable: True
    description: New description, or `null` to clear it.

### SetRolePermissionsRequest
type: object
description: The **complete** desired grant set. Anything held but absent from this array is revoked; an empty array strips the role bare.
properties:
  permissions:
    type: array
    description: Catalog keys to grant. Duplicates are collapsed; unknown keys are `400`.
    items:
      $ref: #/components/schemas/PermissionKey
required:
  - permissions
