15 KiB
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
BankingErrorwith codeBANKING.INVALID_OPTIONSand the originalZodErrorascause
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 withdbregistered - THEN
ctx.getService("bankingService")returns a non-undefinedBankingService
Scenario: onInit fails when db slot is missing
- WHEN
onInit(ctx)runs withdbabsent - THEN it throws
BankingError(BANKING.SERVICE_NOT_AVAILABLE)
Scenario: onReady mounts routes at the configured routePrefix
- WHEN
onInitthenonReadyruns with options{ routePrefix: "/api/v1/banking" } - THEN the host
app.routewas called with"/api/v1/banking"and a Hono sub-app
Scenario: onClose drops the cached service
- WHEN
onInitruns, thenonClose, thenctx.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 = 4account andsoftDeleteBank(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 === 1and the OTelbanks_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
maxAccountsPerUseraccounts andcreateAccountis 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 nameddefaultBankNameexists - THEN a bank with that name and
sort_code = defaultSortCodeis 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 withconfig.enableTransfers === false - THEN the call throws
BankingError(BANKING.INVALID_OPTIONS)
Scenario: Insufficient funds with overdraft off
- WHEN an account has
balance = 50, overdraft = 100andsubtractFunds(id, 100)is called withenableOverdraft: 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 = 100andsubtractFunds(id, 100)is called withenableOverdraft: true - THEN the result is
{ success: true, data: { balance: -50 } }
Scenario: Transfer success emits OTel
- WHEN a successful transfer of
amount = 10runs - THEN
banking.transfers_total{outcome="success"}increments by 1 andbanking.transfer_amountrecords one observation of10
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
initializeDefaultBankis invoked with an emptybanktable andenableDefaultBank: true - THEN exactly one bank with the configured name exists afterward
Scenario: Second call is a no-op
- WHEN
initializeDefaultBankis invoked twice in a row withenableDefaultBank: true - THEN the bank count after both calls is
1
Scenario: Disabled bootstrap
- WHEN
initializeDefaultBankis invoked withenableDefaultBank: false - THEN the returned value is
nulland 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 onBankRepository - 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 tenantabc - 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 BankingErrors 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_FOUNDpropagates from a handler - THEN the HTTP response carries status
404and 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-bankingand callsctx.getService("bankingService") - THEN TypeScript infers the result type as
BankingService | undefinedwithout 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_CODESis 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 withoutresetBankingInstruments()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-memoryMeterProvider - THEN the collected metrics include
banking.banks_total{op="create"}with delta1
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"