# 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` 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`. 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"`