Initial Commit
This commit is contained in:
158
openspec/changes/add-api-plugin-banking/proposal.md
Normal file
158
openspec/changes/add-api-plugin-banking/proposal.md
Normal 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 |
|
||||
@@ -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"`
|
||||
66
openspec/changes/add-api-plugin-banking/tasks.md
Normal file
66
openspec/changes/add-api-plugin-banking/tasks.md
Normal 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/`
|
||||
Reference in New Issue
Block a user