Files
2026-05-27 23:29:53 +01:00

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 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 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_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"