Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
# Add `@suaveplan/api-plugin-banking`
**Package**: `@suaveplan/api-plugin-banking`
**Status**: PROPOSED
**Type**: new-package
**Spec delta**: ./specs/api-plugin-banking/spec.md
## Why
The genesis api-plugin chain needs a banking-foundation plugin that
owns the canonical `bank` (institution) and `bank_account` SurrealDB
tables, exposes a coordinator service for the typed read / write
surface, and ships the HTTP route tree consumers depend on. The
downstream `@suaveplan/api-plugin-bank` (game-wallet specialisation)
is being migrated by a peer agent in parallel and depends on this
package — ship as fast as quality allows.
The upstream package at
`/mnt/s/web/suaveplan/packages/server/api-plugin-banking/` shipped
working code but failed the genesis quality bar in three ways that
the audit document `enterprise/api-plugin-banking.md` calls out:
- `src/index.ts:111` instantiated the route tree against a fake
`{} as PluginContext` cast at module-load time so routes never
actually received the live context.
- The `BankingService` and repositories carried `as any` casts on the
database adapter because the upstream `@suaveplan/api`
`DatabaseAdapter` shape was untyped.
- Routes were defined with the upstream `GET/POST/successResponse`
builder which the genesis `@suaveplan/api` does not export, so a
direct port is impossible — the migration retypes against the
canonical `@hono/zod-openapi` `createRoute` + `OpenAPIHono` surface
used by every other genesis api-plugin (search, audit, webhook,
...).
The migration brings the full functional surface across, retypes
against the genesis `DatabaseAdapter` from `@suaveplan/db`, adds the
fourteen `BANKING.*` error codes, six OTel instruments, a typed event
map, a Zod options boundary, and 100% istanbul coverage.
## What changes
- ADDED: `createBankingPlugin(options?)` — Zod-validated factory
returning a typed `PluginDescriptor<BankingPluginConfig>` with
`name: "banking"`, `dependencies: ["database"]`,
`optionalDependencies: ["auth", "audit", "rate-limit"]`,
three lifecycle hooks (`onInit`/`onReady`/`onClose`), and the
configured `routePrefix` mount.
- ADDED: `BankingPluginOptionsSchema` /
`BankingPluginOptionsInput` / `BankingPluginOptionsResolved` /
`BankingPluginConfig` — seven defaulted options
(`enableDefaultBank`, `defaultBankName`, `defaultSortCode`,
`maxAccountsPerUser`, `enableTransfers`, `enableOverdraft`,
`routePrefix`).
- ADDED: `BankingService` coordinator with typed `createBank`,
`updateBank`, `softDeleteBank`, `createAccount`, `updateAccount`,
`updateAccountStatus`, `transferFunds`, `addFunds`,
`subtractFunds`, `initializeDefaultBank`, `deleteTenantData`, and
repository accessors.
- ADDED: `BaseRepository`, `BankRepository`, `BankAccountRepository`
— SurrealDB-backed CRUD with field-whitelist enforcement,
RecordId flattening, and tenant-cleanup helpers.
- ADDED: 11 Zod schemas (`BankSchema`, `BankAccountSchema`,
`CreateBank{,Account}Schema`, `UpdateBank{,Account}Schema`,
`TransferFundsSchema`, `FundsOperationSchema`,
`UpdateAccountStatusSchema`, `SortCodeSchema`,
literal-union status / type enums) plus matching
`z.infer<>` aliases.
- ADDED: 14 `BANKING.*` error codes via `defineCodes("BANKING", ...)`
— every code thrown by ≥1 production code path.
- ADDED: 6 OTel instruments under the
`@suaveplan/api-plugin-banking` meter
(`banks_total`, `accounts_total`, `transfers_total`,
`transfer_amount` histogram, `fund_operations_total`,
`query_duration_ms` histogram).
- ADDED: `createBankingRoutes(deps)` — 19 OpenAPIHono routes
(`/banks/*` + `/accounts/*`) including `POST /accounts/transfer`,
`POST /accounts/:id/{add,subtract}-funds`,
`PATCH /accounts/:id/status`, `GET /banks/sort-code/:sortCode`,
`GET /banks/search/:query`, `GET /accounts/by-number/:n`,
`GET /accounts/user/:userId`, `GET /accounts/:id/owner`.
- ADDED: Typed `BankingEventMap` and event emission on every state
transition (created / updated / soft-deleted / status-changed /
transfer-succeeded / transfer-failed).
- ADDED: `bankingService`, `app`, `db` slots on the canonical
`ServiceRegistry` via module augmentation.
- ADDED: `decommissionBankingTenant(service, tenantId)` helper for
hosts orchestrating tenant lifecycle externally.
- ADDED: `surql/001-bank-and-account.surql` migration file carrying
the upstream schema, sort-code function library, account-number
arithmetic functions, and the `user_account` relation.
## Out of scope
- Auth / rate-limit / audit-log integration — the upstream plugin
did not gate routes, and the enterprise audit document flags these
as future-work additions. The optional-dependencies list signals
the topo-sort order so a follow-up change can wire them without
re-ordering.
- Multi-currency accounts via `@suaveplan/money` /
`@suaveplan/currency` — flagged as feature-rich enhancement in
`enterprise/api-plugin-banking.md §4` but not present in the
upstream source. Deferred to a dedicated change.
- Double-entry ledger model — the upstream stores raw balance; the
enhancement flag is preserved for a follow-up.
- Scheduled / standing transfers, reconciliation endpoint,
statement-PDF generation — listed in §4 enhancements only.
- Decommissioned tenant hook on the `PluginDescriptor` itself — the
current `@suaveplan/api-plugin` `PluginDescriptor` shape does not
declare `onTenantDelete`. The migration exposes a
`decommissionBankingTenant` helper that hosts invoke from their
own tenant orchestrator.
## Source mapping
This change derives from
`../suaveplan/enterprise/api-plugin-banking.md` and the upstream
source at
`../suaveplan/packages/server/api-plugin-banking/src/`.
| Requirement | Source lines | Notes |
|---|---|---|
| Plugin factory with `name: "banking"`, `version`, `description`, `dependencies: ["database"]` | `src/index.ts` L86-L96 (`createBankingPlugin`) | retyped against genesis `PluginDescriptor` |
| `BankingConfigSchema` (`enableDefaultBank`, `defaultBankName`, `defaultSortCode`, `maxAccountsPerUser`, `enableTransfers`, `enableOverdraft`) | `src/index.ts` L19-L28 | renamed to `BankingPluginOptionsSchema`; added `routePrefix` (genesis convention) |
| `BankingService` coordinator | `src/index.ts` L34-L64 | moved into `src/service/service.ts` per `enterprise §3` "move out of `src/index.ts`" |
| `initializeDefaultBank` idempotent bootstrap | `src/index.ts` L54-L58 | preserved + made config-driven via name + sort code |
| `validateUserAccountLimit` → `countByUserId` | `src/index.ts` L60-L63 | renamed + moved into repository; service enforces via `ACCOUNT_LIMIT_REACHED` |
| `onInit` registers `bankingService`, `bankRepository`, `bankAccountRepository` | `src/index.ts` L114-L135 | trimmed to single `bankingService` slot; repositories are accessible via `service.getBankRepository()` |
| `onReady` bootstraps default bank | `src/index.ts` L137-L143 | preserved; emits `banking:ready` typed event |
| `onClose` drops service | `src/index.ts` L145-L147 | preserved |
| `onTenantDelete` purges banks + accounts | `src/index.ts` L154-L168 | preserved as `service.deleteTenantData(tenantId)` + `decommissionBankingTenant` helper (descriptor hook not yet exposed in genesis `PluginDescriptor`) |
| Re-export of `@suaveplan/api-schemas/banking` | `src/index.ts` L178 | REMOVED per `enterprise §3` ("violates NEVER re-export external deps") and CLAUDE.md rule 4; banking schemas now owned locally |
| `BankRepository` full surface (create, existsByName, getByName, getBySiteId, getBySortCode, getByStatus, getActiveBanks, getAvailableBanks, softDelete, hasActiveAccounts, searchByName, getOrCreateDefaultBank) | `src/repositories/bank.repository/index.ts` L23-L209 | preserved with field-whitelist tightened; default-bank takes config args |
| `BankAccountRepository` full surface | `src/repositories/bank-account.repository/index.ts` L47-L364 | preserved; fund-movement primitives retyped against `DatabaseAdapter`; overdraft tolerance threaded through |
| `BaseRepository` shared scaffold + field whitelist | `src/repositories/base.repository.ts` L40-L266 | retyped against genesis `@suaveplan/db` `DatabaseAdapter`; parameter style switched to `$p0,$p1` positional via `db.query(sql, [...])` |
| `RecordWithId`, `RecordId`, `RepositoryResponse`, `ListResponse` | `src/repositories/base.repository.types.ts` L1-L52 | folded into `src/types/types.ts` and `src/schemas/schemas.ts`; `ListResponse` dropped as unused on the genesis surface |
| Bank routes (`GET /`, `POST /`, `GET /:id`, `PATCH /:id`, `DELETE /:id`, `GET /sort-code/:sortCode`, `GET /search/:query`) | `src/routes/banks/index.ts` L25-L336 | retyped against `@hono/zod-openapi` `createRoute`; every status declared in `responses` |
| Account routes (list, get, by-user, by-number, create, update, delete, transfer, add-funds, subtract-funds, status, owner) | `src/routes/accounts/index.ts` L29-L537 | preserved verbatim semantics; routes consume `BankingService` instead of constructing repos per-request |
| `banking:initialized`, `banking:ready` event extension | `src/types.ts` L20-L25 | extended to full `BankingEventMap` with per-state-transition payloads |
| `surql/tables/bank-account.surql` (bank + bank_account schemas, function library, account-number arithmetic) | upstream `surql/tables/bank-account.surql` L1-L345 | copied verbatim to `surql/001-bank-and-account.surql` |
| Fix `{} as PluginContext` cast at route mount | `enterprise §3` first bullet | routes now mount inside `onReady(ctx)` with real `ctx` |
| Remove `as any` casts on `db` | `enterprise §3` second bullet | replaced with typed `DatabaseAdapter` from `@suaveplan/db` |
| Move `BankingService` out of `src/index.ts` | `enterprise §3` third bullet | service lives in `src/service/service.ts` |
| Drop re-export of `@suaveplan/api-schemas/banking` | `enterprise §3` fourth bullet | schemas owned locally per CLAUDE.md rule 4 |
| Add `events.ts` for `EventMap` declaration | `enterprise §3` fifth bullet | replaced by `src/types/types.ts` `BankingEventMap` + typed `emitEvent` in routes |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| 100% istanbul test coverage gate | Genesis-wide policy, not package-specific |
| `defineCodes("BANKING", [...])` error registry of 14 codes | Genesis-wide policy — upstream threw raw `new Error()` |
| 6 OTel instruments emitted from production paths | Genesis-wide policy — upstream had no observability |
| Module-augmentation of `ServiceRegistry` | Genesis convention — upstream had untyped `getService("db")` |
| Co-located sibling `.md` files (≥200 words) | Genesis CLAUDE.md doc-coverage rule |
| Test files import only from `@suaveplan/testing/runner` | CLAUDE.md rule 11 |
| `enableOverdraft` + `enableTransfers` + `routePrefix` | `routePrefix` is genesis convention; `enable*` flags were upstream but `routePrefix` was hardcoded `/banking` upstream — moved to schema for multi-version deploys |
| Account-number arithmetic (`parseAccountNumber`, `splitAccountNumber`) raises `INVALID_ACCOUNT_NUMBER` on malformed input | Upstream returned `null` silently; genesis throws so callers cannot ignore malformed input |

View File

@@ -0,0 +1,267 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-banking (add-api-plugin-banking)
**Package**: `@suaveplan/api-plugin-banking`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Plugin factory shape
The package SHALL export `createBankingPlugin(options?: BankingPluginOptionsInput)` returning a `PluginDescriptor<BankingPluginConfig>` whose `name` is `"banking"`, whose `dependencies` is `["database"]`, whose `optionalDependencies` includes `"auth"`, `"audit"`, and `"rate-limit"`, and whose `tags` include `"banking"`, `"accounts"`, and `"finance"`.
#### Scenario: Descriptor metadata
- **WHEN** `createBankingPlugin()` is called with no arguments
- **THEN** the returned descriptor has the exact name, version, dependencies, optional-dependencies, and tags listed above
#### Scenario: Invalid options reject at the boundary
- **WHEN** `createBankingPlugin({ maxAccountsPerUser: 0 })` is called
- **THEN** the factory throws `BankingError` with code `BANKING.INVALID_OPTIONS` and the original `ZodError` as `cause`
**Verified by:** `src/plugin/plugin.test.ts::"returns a descriptor with the canonical banking-plugin metadata"` AND `src/plugin/plugin.test.ts::"throws BANKING.INVALID_OPTIONS when options fail schema validation"`
---
### Requirement: Lifecycle (onInit / onReady / onClose)
The descriptor SHALL implement `onInit`, `onReady`, and `onClose`. `onInit` resolves `ctx.getService("db")`, constructs a `BankingService`, and registers it under the `bankingService` slot, raising `BANKING.SERVICE_NOT_AVAILABLE` when the `db` slot is empty. `onReady` calls `service.initializeDefaultBank()` then mounts the route tree on the host `app` under `config.routePrefix`. `onClose` drops the cached service reference.
#### Scenario: onInit registers the bankingService
- **WHEN** `onInit(ctx)` runs with `db` registered
- **THEN** `ctx.getService("bankingService")` returns a non-undefined `BankingService`
#### Scenario: onInit fails when db slot is missing
- **WHEN** `onInit(ctx)` runs with `db` absent
- **THEN** it throws `BankingError(BANKING.SERVICE_NOT_AVAILABLE)`
#### Scenario: onReady mounts routes at the configured routePrefix
- **WHEN** `onInit` then `onReady` runs with options `{ routePrefix: "/api/v1/banking" }`
- **THEN** the host `app.route` was called with `"/api/v1/banking"` and a Hono sub-app
#### Scenario: onClose drops the cached service
- **WHEN** `onInit` runs, then `onClose`, then `ctx.getService("bankingService")` is read
- **THEN** the runtime cache is `undefined` (host registry retention is the host's concern)
**Verified by:** `src/plugin/plugin.test.ts::"onInit registers the bankingService slot"` AND `src/plugin/plugin.test.ts::"onInit throws SERVICE_NOT_AVAILABLE when db is missing"` AND `src/plugin/plugin.test.ts::"onReady mounts routes at the configured routePrefix"` AND `src/plugin/plugin.test.ts::"onClose drops the cached service"`
---
### Requirement: Bank lifecycle CRUD with uniqueness and active-account guards
The package SHALL expose bank CRUD via `BankingService.createBank`, `updateBank`, and `softDeleteBank`. `createBank` rejects duplicates with `BANKING.BANK_NAME_TAKEN`. `updateBank` and `softDeleteBank` raise `BANKING.BANK_NOT_FOUND` when the bank id is missing. `softDeleteBank` raises `BANKING.BANK_HAS_ACTIVE_ACCOUNTS` when the bank still has accounts with `status = 4`.
#### Scenario: Duplicate name on create
- **WHEN** `createBank({ name: "A" })` is called twice
- **THEN** the second call throws `BankingError(BANKING.BANK_NAME_TAKEN)`
#### Scenario: Soft-delete blocked by active accounts
- **WHEN** a bank has at least one `status = 4` account and `softDeleteBank(id)` is called
- **THEN** the call throws `BankingError(BANKING.BANK_HAS_ACTIVE_ACCOUNTS)`
#### Scenario: Soft-delete sets status = 1
- **WHEN** a bank has no active accounts and `softDeleteBank(id)` is called
- **THEN** the returned record has `status === 1` and the OTel `banks_total{op="soft-delete"}` counter incremented by 1
**Verified by:** `src/service/service.test.ts::"createBank rejects duplicate name with BANK_NAME_TAKEN"` AND `src/service/service.test.ts::"softDeleteBank rejects banks with active accounts"` AND `src/service/service.test.ts::"softDeleteBank sets status to 1 on success"`
---
### Requirement: Account creation with per-user limit and default-bank fallback
The package SHALL expose `BankingService.createAccount(data)`. Before insert, it counts the user's accounts; if the count is `>= config.maxAccountsPerUser`, it raises `BANKING.ACCOUNT_LIMIT_REACHED`. When `data.bank_id` is omitted, it calls `BankRepository.getOrCreateDefaultBank(config.defaultBankName, config.defaultSortCode)` and inserts against the resulting id.
#### Scenario: Account-limit enforcement
- **WHEN** the user already has `maxAccountsPerUser` accounts and `createAccount` is called
- **THEN** the call throws `BankingError(BANKING.ACCOUNT_LIMIT_REACHED)`
#### Scenario: Default-bank fallback
- **WHEN** `createAccount({ user_id, account_number, ... /* no bank_id */ })` is called and no bank named `defaultBankName` exists
- **THEN** a bank with that name and `sort_code = defaultSortCode` is created and the account references it
**Verified by:** `src/service/service.test.ts::"createAccount rejects when account-limit reached"` AND `src/service/service.test.ts::"createAccount bootstraps default bank when bank_id omitted"`
---
### Requirement: Fund-movement primitives with overdraft policy
The package SHALL expose `BankingService.transferFunds`, `addFunds`, and `subtractFunds`. Each raises `BANKING.INVALID_AMOUNT` for non-positive `amount`. `transferFunds` short-circuits with `BANKING.INVALID_OPTIONS` when `config.enableTransfers === false`. When `config.enableOverdraft === true`, the per-account `overdraft` field becomes the negative balance ceiling; otherwise the floor is `0`. Insufficient-balance outcomes return `{ success: false, code: "BANKING.INSUFFICIENT_FUNDS" }` rather than throwing.
#### Scenario: Negative-amount reject
- **WHEN** `addFunds(id, -1)` is called
- **THEN** the call throws `BankingError(BANKING.INVALID_AMOUNT)`
#### Scenario: Transfer disabled
- **WHEN** `transferFunds(...)` is called with `config.enableTransfers === false`
- **THEN** the call throws `BankingError(BANKING.INVALID_OPTIONS)`
#### Scenario: Insufficient funds with overdraft off
- **WHEN** an account has `balance = 50, overdraft = 100` and `subtractFunds(id, 100)` is called with `enableOverdraft: false`
- **THEN** the result is `{ success: false, code: "BANKING.INSUFFICIENT_FUNDS" }` and balance is unchanged
#### Scenario: Insufficient funds with overdraft on
- **WHEN** an account has `balance = 50, overdraft = 100` and `subtractFunds(id, 100)` is called with `enableOverdraft: true`
- **THEN** the result is `{ success: true, data: { balance: -50 } }`
#### Scenario: Transfer success emits OTel
- **WHEN** a successful transfer of `amount = 10` runs
- **THEN** `banking.transfers_total{outcome="success"}` increments by 1 and `banking.transfer_amount` records one observation of `10`
**Verified by:** `src/service/service.test.ts::"addFunds rejects negative amount"` AND `src/service/service.test.ts::"transferFunds short-circuits when enableTransfers is false"` AND `src/service/service.test.ts::"subtractFunds rejects when overdraft is off and balance insufficient"` AND `src/service/service.test.ts::"subtractFunds tolerates overdraft when enabled"` AND `src/service/service.test.ts::"successful transfer emits transfers_total + transfer_amount"`
---
### Requirement: Default-bank idempotent bootstrap
The package SHALL expose `BankingService.initializeDefaultBank()`. When `config.enableDefaultBank === true`, the first call inserts a bank with `name = config.defaultBankName, sort_code = config.defaultSortCode, status = 4`; subsequent calls return the existing record. When `enableDefaultBank === false`, the call returns `null` without touching the database.
#### Scenario: First call creates the bank
- **WHEN** `initializeDefaultBank` is invoked with an empty `bank` table and `enableDefaultBank: true`
- **THEN** exactly one bank with the configured name exists afterward
#### Scenario: Second call is a no-op
- **WHEN** `initializeDefaultBank` is invoked twice in a row with `enableDefaultBank: true`
- **THEN** the bank count after both calls is `1`
#### Scenario: Disabled bootstrap
- **WHEN** `initializeDefaultBank` is invoked with `enableDefaultBank: false`
- **THEN** the returned value is `null` and the bank table is unchanged
**Verified by:** `src/service/service.test.ts::"initializeDefaultBank creates default bank on first call"` AND `src/service/service.test.ts::"initializeDefaultBank is idempotent across two calls"` AND `src/service/service.test.ts::"initializeDefaultBank no-ops when enableDefaultBank is false"`
---
### Requirement: Repository field-whitelist guard
The repositories SHALL gate every dynamically constructed `UPDATE` field name against the child class's `getAllowedFields()` whitelist. Unknown field names raise `BANKING.INVALID_FIELD_NAME` before any SQL is emitted.
#### Scenario: Whitelisted field passes
- **WHEN** `update(id, { name: "X" })` is called on `BankRepository`
- **THEN** the resulting record carries `name === "X"`
#### Scenario: Non-whitelisted field rejects
- **WHEN** `update(id, { evil_field: "X" } as never)` is called
- **THEN** the call throws `BankingError(BANKING.INVALID_FIELD_NAME)`
**Verified by:** `src/repositories/bank-repository.test.ts::"update accepts whitelisted field"` AND `src/repositories/bank-repository.test.ts::"update rejects non-whitelisted field with INVALID_FIELD_NAME"`
---
### Requirement: Account-number and sort-code parsing
The package SHALL expose `BankAccountRepository.parseAccountNumber(str)` and `splitAccountNumber(num)`. The string parser accepts `BBBB-CCCC-AAAAAAAA`; the numeric splitter accepts a non-negative integer rendered as 12 digits with zero-padding. `BankRepository.getBySortCode(sortCode)` accepts `XX-XX-XX`. All three raise their respective typed errors (`INVALID_ACCOUNT_NUMBER`, `INVALID_SORT_CODE`) on malformed input.
#### Scenario: Valid account-number string parses
- **WHEN** `parseAccountNumber("0123-4567-89012345")` is called
- **THEN** the result is `{ bankCode: "0123", branchCode: "4567", accountCode: "89012345" }`
#### Scenario: Malformed account-number rejects
- **WHEN** `parseAccountNumber("invalid")` is called
- **THEN** the call throws `BankingError(BANKING.INVALID_ACCOUNT_NUMBER)`
#### Scenario: Malformed sort-code rejects
- **WHEN** `getBySortCode("12-3-456")` is called
- **THEN** the call throws `BankingError(BANKING.INVALID_SORT_CODE)`
**Verified by:** `src/repositories/account-repository.test.ts::"parseAccountNumber accepts canonical BBBB-CCCC-AAAAAAAA"` AND `src/repositories/account-repository.test.ts::"parseAccountNumber throws INVALID_ACCOUNT_NUMBER on malformed input"` AND `src/repositories/bank-repository.test.ts::"getBySortCode throws INVALID_SORT_CODE on malformed input"`
---
### Requirement: Tenant cleanup
The package SHALL expose `BankingService.deleteTenantData(tenantId)` returning `{ banks: number, accounts: number }` — the count of rows removed. Failures wrap as `BANKING.TENANT_CLEANUP_FAILED` with the underlying error as `cause`. A `decommissionBankingTenant(service, tenantId)` helper is re-exported for hosts that orchestrate tenant decommission externally.
#### Scenario: Cleanup returns counts
- **WHEN** `deleteTenantData("site:abc")` is called against a database with 2 banks and 5 accounts owned by tenant `abc`
- **THEN** the returned object has `banks: 2, accounts: 5`
**Verified by:** `src/service/service.test.ts::"deleteTenantData returns row counts for both tables"`
---
### Requirement: Route surface
The package SHALL expose `createBankingRoutes(deps)` returning an `OpenAPIHono` instance mounting 7 bank routes (`GET /`, `POST /`, `GET /:id`, `PATCH /:id`, `DELETE /:id`, `GET /sort-code/:sortCode`, `GET /search/:query`) and 12 account routes (`GET /`, `POST /`, `GET /:id`, `PUT /:id`, `DELETE /:id`, `GET /by-number/:accountNumber`, `GET /user/:userId`, `GET /:id/owner`, `POST /transfer`, `POST /:id/add-funds`, `POST /:id/subtract-funds`, `PATCH /:id/status`). Every route validates inputs via Zod and maps thrown `BankingError`s to HTTP `400`/`404`/`500` via the package's `errorBody` helper.
#### Scenario: Routes mounted
- **WHEN** `createBankingRoutes({ service })` is called
- **THEN** the returned app has 19 registered routes whose paths begin with `/banks/` or `/accounts/`
#### Scenario: Error mapping
- **WHEN** `BANKING.BANK_NOT_FOUND` propagates from a handler
- **THEN** the HTTP response carries status `404` and a body `{ success: false, error, code }`
**Verified by:** `src/routes/build-routes.test.ts::"mounts 19 routes across /banks and /accounts"` AND `src/routes/build-routes.test.ts::"maps BANK_NOT_FOUND to HTTP 404 in error body"`
---
### Requirement: Service-registry augmentation
The package SHALL surface the `bankingService` slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via a side-effect-imported module. `ctx.getService("bankingService")` SHALL type as `BankingService | undefined` for every consumer importing the package barrel.
#### Scenario: Augmented slot type
- **WHEN** a consumer file imports `@suaveplan/api-plugin-banking` and calls `ctx.getService("bankingService")`
- **THEN** TypeScript infers the result type as `BankingService | undefined` without a cast
**Verified by:** `src/api-plugin-banking.spec-conformance.test.ts::"ServiceRegistry['bankingService'] is BankingService"`
---
### Requirement: Error-code registry
The package SHALL register 14 `BANKING.*` error codes via `defineCodes("BANKING", [...])` and export them as `BANKING_CODES`, with a `BankingError` class extending `SuaveplanError<BankingErrorCode>`. Every registered code SHALL be thrown by at least one production code path.
#### Scenario: Code registry exhaustiveness
- **WHEN** every code in `BANKING_CODES` is enumerated
- **THEN** the union of code strings equals exactly the 14 documented codes
**Verified by:** `src/error-codes/error-codes.test.ts::"BANKING_CODES carries the 14 documented codes"` AND `src/api-plugin-banking.spec-conformance.test.ts::"every BANKING code is thrown from at least one production path"`
---
### Requirement: OTel instrument bundle
The package SHALL expose `getBankingInstruments()` returning a cached bundle of 6 instruments (`banksTotal`, `accountsTotal`, `transfersTotal`, `transferAmount`, `fundOperationsTotal`, `queryDurationMs`) bound to the meter `"@suaveplan/api-plugin-banking"`. Production code paths SHALL emit on every lifecycle transition (bank create/update/soft-delete, account create/update/delete/status-change, transfer success/failure/insufficient-funds, add-funds, subtract-funds).
#### Scenario: Instruments cached
- **WHEN** `getBankingInstruments()` is called twice without `resetBankingInstruments()` in between
- **THEN** the returned reference is identity-equal
#### Scenario: Counter emitted on production path
- **WHEN** `service.createBank({ name: "X" })` succeeds against a fresh in-memory `MeterProvider`
- **THEN** the collected metrics include `banking.banks_total{op="create"}` with delta `1`
**Verified by:** `src/otel/instruments.test.ts::"getBankingInstruments caches the bundle"` AND `src/service/service.test.ts::"createBank emits banks_total{op=create} on the global MeterProvider"`

View File

@@ -0,0 +1,66 @@
# Tasks — Add `@suaveplan/api-plugin-banking`
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-banking.md` end-to-end
- [x] 1.2 Verify no prior `openspec/specs/api-plugin-banking/spec.md` exists
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` with line references to upstream
- [x] 1.6 Draft `specs/api-plugin-banking/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 `error-codes/error-codes.ts` — 14 `BANKING.*` codes + `BankingError` class
- [x] 2.2 `otel/instruments.ts` — meter + 6 instruments (counters + histograms)
- [x] 2.3 `schemas/schemas.ts` — 11 Zod schemas
- [x] 2.4 `schemas/options.ts` — `BankingPluginOptionsSchema` factory-options schema
- [x] 2.5 `repositories/base-repository.ts` — shared scaffold + field whitelist + RecordId flattening
- [x] 2.6 `repositories/bank-repository.ts` — full upstream bank surface retyped
- [x] 2.7 `repositories/account-repository.ts` — full upstream account surface retyped
- [x] 2.8 `service/service.ts` — `BankingService` coordinator with policy enforcement
- [x] 2.9 `routes/build-routes.ts` — 19 OpenAPIHono routes with uniform error mapping
- [x] 2.10 `plugin/plugin.ts` — `createBankingPlugin` factory + `decommissionBankingTenant` helper
- [x] 2.11 `services/registry-augmentation.ts` — `bankingService` / `app` / `db` slots
- [x] 2.12 `types/types.ts` — record aliases, result wrappers, lifecycle event payloads, `BankingEventMap`
- [x] 2.13 `index.ts` — explicit named barrel + side-effect imports for error-codes + registry augmentation
- [x] 2.14 `surql/001-bank-and-account.surql` — schema migration carried over verbatim
## 3. Tests
- [ ] 3.1 Unit tests per feature module (in-memory `FakeDatabaseAdapter` for fast feedback)
- [ ] 3.2 `api-plugin-banking.spec-conformance.test.ts` — type-level + barrel pins
- [ ] 3.3 Integration test against real SurrealDB testcontainer covering create / transfer / soft-delete / tenant-purge
- [ ] 3.4 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for testcontainer-bound cases)
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 4.2 README links every co-located `.md`
- [ ] 4.3 STATUS.md row updated post-archival (other-session-owned; skipped per guidance)
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome 0/0/0
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-banking`
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-banking`
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-banking`
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-banking`
## 6. Pre-archive 9-row checklist
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
- [ ] 6.2 Zod schemas wired at every declared boundary
- [ ] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
- [ ] 6.4 Strict-mode behaviours tested both on and off (overdraft toggle)
- [ ] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
- [ ] 6.6 Lifecycle dedup tested where claimed (idempotent default-bank bootstrap)
- [ ] 6.7 No retroactive tick
- [ ] 6.8 No `export *` in any feature barrel
- [ ] 6.9 No direct `vitest` / `bun:test` imports in any test file
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/api-plugin-banking/spec.md`
- [ ] 7.2 Move `openspec/changes/add-api-plugin-banking/` → `openspec/archive/2026-05-19-add-api-plugin-banking/`