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,71 @@
# `@suaveplan/api-plugin-banking`
Banking foundation plugin for `@suaveplan/api`: bank-institution +
bank-account CRUD on SurrealDB, account-number arithmetic, fund-transfer
primitive with overdraft policy, soft-delete + status FSM, default-bank
bootstrap, full Hono OpenAPI route surface, `BANKING.*` error codes,
OTel instruments, and lifecycle helpers for tenant create / delete.
## Quick start
```ts
import { createBankingPlugin } from "@suaveplan/api-plugin-banking";
const plugin = createBankingPlugin({
routePrefix: "/banking",
maxAccountsPerUser: 10,
enableTransfers: true,
enableOverdraft: false,
});
registry.register(plugin);
```
## Modules
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) — 14
`BANKING.*` codes + `BankingError`.
- [`otel/instruments`](./src/otel/instruments.md) — 6 OTel instruments.
- [`schemas/schemas`](./src/schemas/schemas.md) — 11 Zod schemas.
- [`schemas/options`](./src/schemas/options.md) — plugin options schema.
- [`repositories/base-repository`](./src/repositories/base-repository.md) —
shared CRUD scaffold + field whitelist.
- [`repositories/bank-repository`](./src/repositories/bank-repository.md) —
bank-institution surface.
- [`repositories/account-repository`](./src/repositories/account-repository.md) —
bank-account surface + fund-movement primitives.
- [`service/service`](./src/service/service.md) — `BankingService`
coordinator with policy enforcement.
- [`routes/build-routes`](./src/routes/build-routes.md) — 19 OpenAPIHono
routes.
- [`plugin/plugin`](./src/plugin/plugin.md) — `createBankingPlugin`
factory + tenant decommission helper.
- [`services/registry-augmentation`](./src/services/registry-augmentation.md) —
`bankingService` / `app` / `db` slots.
- [`types/types`](./src/types/types.md) — record aliases, result
wrappers, lifecycle event payloads, `BankingEventMap`.
## Lifecycle
1. `onInit` — resolves `db` from `ServiceRegistry`, constructs
`BankingService`, registers it under `bankingService`, emits
`banking:initialized`.
2. `onReady` — bootstraps the configured default bank, mounts the
route tree on `app` under `config.routePrefix`, emits
`banking:ready`.
3. `onClose` — drops the cached service.
## SurQL migration
`surql/001-bank-and-account.surql` carries the canonical schema,
the sort-code / account-number function library, and the
`user_account` relation. The migration runner of
`@suaveplan/api-plugin-database` consumes it from the configured
`migrations.directory`.
## Status
- Version: 0.1.0
- Tier: server (Bun / Node)
- Tests: real SurrealDB testcontainer; no mocks
- Coverage gate: 100% / 100% / 100% / 100% (istanbul)

View File

@@ -0,0 +1,128 @@
{
"name": "@suaveplan/api-plugin-banking",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Banking foundation plugin for @suaveplan/api: bank-institution + bank-account CRUD on SurrealDB, account-number arithmetic, double-entry-friendly fund-transfer primitive, soft-delete + status FSM, default-bank bootstrap, full Hono OpenAPI route surface, BANKING.* error codes, OTel instruments, and lifecycle hooks for tenant create/delete.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./service/service": {
"import": {
"types": "./dist/service/service.d.ts",
"default": "./dist/service/service.js"
}
},
"./repositories/bank-repository": {
"import": {
"types": "./dist/repositories/bank-repository.d.ts",
"default": "./dist/repositories/bank-repository.js"
}
},
"./repositories/account-repository": {
"import": {
"types": "./dist/repositories/account-repository.d.ts",
"default": "./dist/repositories/account-repository.js"
}
},
"./routes/build-routes": {
"import": {
"types": "./dist/routes/build-routes.d.ts",
"default": "./dist/routes/build-routes.js"
}
},
"./schemas/schemas": {
"import": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../scripts/docs-check.ts ."
},
"dependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-plugin-database": "workspace:^",
"@suaveplan/db": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/events": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@suaveplan/testing-integration-surrealdb": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "surql", "README.md"]
}

View File

@@ -0,0 +1,113 @@
# Error codes
## Purpose
This module defines the stable, namespaced error-code registry for
`@suaveplan/api-plugin-banking`. Every failure path in the plugin throws
a `BankingError` carrying one of fourteen `BANKING.*` codes, so callers
can branch on `error.code` without parsing free-form messages and the
HTTP layer can map a code → status with a one-line lookup.
## Features
- 14 typed string-literal codes registered via `defineCodes("BANKING", ...)`
- `BankingError` class extending `SuaveplanError`, generic over the
code literal type so `instanceof` narrows the `code` field
- Stable JSDoc with HTTP-status mapping for each code
- Re-exported under the package's `./error-codes/error-codes` subpath
## Usage: Basic
```ts
import { BankingError, BANKING_CODES } from "@suaveplan/api-plugin-banking";
throw new BankingError(
BANKING_CODES.ACCOUNT_NOT_FOUND,
`Account ${id} not found`
);
```
## Usage: Advanced
Codes carry no behaviour — they are documentation. The expected pattern
is to catch at the route boundary, narrow on `instanceof BankingError`,
and map `error.code` to an HTTP status:
```ts
import {
BankingError,
BANKING_CODES,
type BankingErrorCode,
} from "@suaveplan/api-plugin-banking";
const STATUS: Record<BankingErrorCode, number> = {
INVALID_OPTIONS: 500,
SERVICE_NOT_AVAILABLE: 500,
BANK_NOT_FOUND: 404,
BANK_NAME_TAKEN: 400,
BANK_HAS_ACTIVE_ACCOUNTS: 400,
ACCOUNT_NOT_FOUND: 404,
ACCOUNT_LIMIT_REACHED: 400,
INSUFFICIENT_FUNDS: 400,
INVALID_AMOUNT: 400,
INVALID_FIELD_NAME: 500,
INVALID_ACCOUNT_NUMBER: 400,
INVALID_SORT_CODE: 400,
TENANT_CLEANUP_FAILED: 500,
DATABASE_ERROR: 500,
};
try {
await service.transferFunds(from, to, amount);
} catch (err) {
if (err instanceof BankingError) {
const status = STATUS[err.code];
return { status, body: { error: err.message, code: err.code } };
}
throw err;
}
```
## API Reference
### `BANKING_CODES`
`const BANKING_CODES: { readonly INVALID_OPTIONS: "BANKING.INVALID_OPTIONS" } & {...}`
Frozen, namespaced map of every code in the registry. Indexing it
yields the canonical `"BANKING.XYZ"` string passed to `BankingError`.
### `BankingErrorCode`
`type BankingErrorCode = (typeof BANKING_CODES)[keyof typeof BANKING_CODES]`
Discriminated union of all literal code values.
### `class BankingError extends SuaveplanError<BankingErrorCode>`
`constructor(code: BankingErrorCode, message: string, options?: SuaveplanErrorOptions)`
`error.code: BankingErrorCode` — the literal code string.
`error.bankingCode: BankingErrorCode` — alias for ergonomic exhaustive
switches without re-typing the union annotation.
`error.cause`, `error.context`, `error.toJSON()` — inherited from
`SuaveplanError`.
## Implementation Notes
The registry is intentionally exhaustive: every error code that
appears here is thrown by at least one production code path inside the
package. The pre-archive checklist (AGENTS.md §13) gates this — codes
registered but never thrown are removed before the change ships, so the
list reflects the real fault surface a caller can branch on.
`BankingError` keeps the `bankingCode` alias so consumers writing
exhaustive switches do not need to re-type the union annotation:
`switch (err.bankingCode) { case "BANKING.ACCOUNT_NOT_FOUND": ... }`
type-checks against `BankingErrorCode` directly.
When throwing from a SurrealDB call site, always set
`{ cause: originalError, context: { ... } }` — the wire-format JSON
adapter (`SuaveplanError.toJSON`) preserves both. `DATABASE_ERROR` is
the catch-all wrapper used by repositories when SurrealDB rejects a
query for a reason outside the typed code list.

View File

@@ -0,0 +1,81 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-banking`.
*
* Every failure path in the banking plugin raises a {@link BankingError}
* carrying one of these `BANKING.*` codes. Consumers branch on the
* string value (e.g. `error.code === BANKING_CODES.ACCOUNT_NOT_FOUND`).
*
* - `INVALID_OPTIONS` — `createBankingPlugin(options)` received options
* that failed `BankingPluginOptionsSchema` validation. `cause` holds
* the `ZodError`. Maps to HTTP 500 (factory-time misconfiguration).
* - `SERVICE_NOT_AVAILABLE` — `onInit`/`onReady` could not resolve the
* host `db` slot from `ServiceRegistry`. Maps to HTTP 500.
* - `BANK_NOT_FOUND` — bank lookup by id/sort-code/name missed.
* Maps to HTTP 404.
* - `BANK_NAME_TAKEN` — `createBank` rejected because a bank with the
* same `name` (optionally scoped to `site_id`) already exists.
* Maps to HTTP 400.
* - `BANK_HAS_ACTIVE_ACCOUNTS` — `softDeleteBank` rejected because the
* bank still has active accounts. Maps to HTTP 400.
* - `ACCOUNT_NOT_FOUND` — bank-account lookup by id missed.
* Maps to HTTP 404.
* - `ACCOUNT_LIMIT_REACHED` — `createAccount` would push the user's
* account count above `maxAccountsPerUser`. Maps to HTTP 400.
* - `INSUFFICIENT_FUNDS` — `transferFunds`/`subtractFunds` would
* reduce a balance below zero (ignoring overdraft). Maps to HTTP 400.
* - `INVALID_AMOUNT` — caller supplied a non-positive amount to a
* fund-movement primitive. Maps to HTTP 400.
* - `INVALID_FIELD_NAME` — internal SQL field whitelist rejected a
* field name in a generated `UPDATE`. Maps to HTTP 500 (bug).
* - `INVALID_ACCOUNT_NUMBER` — `parseAccountNumber` / `splitAccountNumber`
* were given an input not matching the canonical format.
* Maps to HTTP 400.
* - `INVALID_SORT_CODE` — sort-code string did not match the
* `XX-XX-XX` pattern. Maps to HTTP 400.
* - `TENANT_CLEANUP_FAILED` — `onTenantDelete` failed mid-cleanup.
* `cause` holds the underlying SurrealDB error. Maps to HTTP 500.
* - `DATABASE_ERROR` — an underlying SurrealDB query threw an
* unexpected error. `cause` holds the original error.
* Maps to HTTP 500.
*/
export const BANKING_CODES = defineCodes("BANKING", [
"INVALID_OPTIONS",
"SERVICE_NOT_AVAILABLE",
"BANK_NOT_FOUND",
"BANK_NAME_TAKEN",
"BANK_HAS_ACTIVE_ACCOUNTS",
"ACCOUNT_NOT_FOUND",
"ACCOUNT_LIMIT_REACHED",
"INSUFFICIENT_FUNDS",
"INVALID_AMOUNT",
"INVALID_FIELD_NAME",
"INVALID_ACCOUNT_NUMBER",
"INVALID_SORT_CODE",
"TENANT_CLEANUP_FAILED",
"DATABASE_ERROR",
] as const);
/** Union of all `BANKING.*` string-literal codes. */
export type BankingErrorCode =
(typeof BANKING_CODES)[keyof typeof BANKING_CODES];
/**
* Concrete error class for the api-plugin-banking package. Extends
* {@link SuaveplanError} so `instanceof BankingError` narrows `code` to
* {@link BankingErrorCode}.
*/
export class BankingError extends SuaveplanError<BankingErrorCode> {
public readonly bankingCode: BankingErrorCode;
public constructor(
code: BankingErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.bankingCode = code;
}
}

View File

@@ -0,0 +1,89 @@
// Side-effect imports — surface the error-codes registry + the
// service-registry augmentation to every consumer importing the
// package barrel.
import "./error-codes/error-codes.js";
import "./services/registry-augmentation.js";
// Error codes
export {
BANKING_CODES,
BankingError,
type BankingErrorCode,
} from "./error-codes/error-codes.js";
// OTel
export {
type BankingInstruments,
getBankingInstruments,
resetBankingInstruments,
} from "./otel/instruments.js";
// Plugin
export {
createBankingPlugin,
decommissionBankingTenant,
} from "./plugin/plugin.js";
export { BankAccountRepository } from "./repositories/account-repository.js";
export { BankRepository } from "./repositories/bank-repository.js";
// Repositories
export {
BaseRepository,
extractId,
flattenRecordIds,
normaliseId,
} from "./repositories/base-repository.js";
// Routes
export {
type BankingRoutesDeps,
createBankingRoutes,
} from "./routes/build-routes.js";
// Schemas — options
export {
type BankingPluginConfig,
type BankingPluginOptionsInput,
type BankingPluginOptionsResolved,
BankingPluginOptionsSchema,
} from "./schemas/options.js";
// Schemas — Zod surface
export {
type Bank,
type BankAccount,
BankAccountSchema,
type BankAccountStatus,
BankAccountStatusSchema,
type BankAccountType,
BankAccountTypeSchema,
BankSchema,
type BankStatus,
BankStatusSchema,
type CreateBank,
type CreateBankAccount,
CreateBankAccountSchema,
CreateBankSchema,
type FundsOperation,
FundsOperationSchema,
type SortCode,
SortCodeSchema,
type TransferFunds,
TransferFundsSchema,
type UpdateAccountStatus,
UpdateAccountStatusSchema,
type UpdateBank,
type UpdateBankAccount,
UpdateBankAccountSchema,
UpdateBankSchema,
} from "./schemas/schemas.js";
// Service
export { BankingService } from "./service/service.js";
// Types
export type {
AccountLifecycleEvent,
BankAccountRecord,
BankingEventMap,
BankLifecycleEvent,
BankRecord,
FundsOperationResult,
RepositoryResponse,
TransferEvent,
TransferResult,
} from "./types/types.js";

View File

@@ -0,0 +1,109 @@
# OpenTelemetry instruments
## Purpose
This module owns the OpenTelemetry instrument bundle for
`@suaveplan/api-plugin-banking`. Every hot-path call in repositories,
the `BankingService`, and the route handlers emits at least one of
these instruments, so operators can observe bank/account lifecycle
volume, transfer success rates, fund-operation outcomes, and query
latency without instrumenting the consumer app separately.
## Features
- Six instruments under the meter name
`"@suaveplan/api-plugin-banking"` — three counters and three
histograms (one of the histograms is the transfer-amount histogram,
which doubles as a metrics-only volume signal).
- Lazy `getBankingInstruments()` factory so the meter binds to whatever
`MeterProvider` is installed at first call.
- `resetBankingInstruments()` for tests: drop the cached bundle so the
next call re-binds to a freshly-installed in-memory provider.
## Usage: Basic
```ts
import { getBankingInstruments } from "@suaveplan/api-plugin-banking";
const inst = getBankingInstruments();
inst.banksTotal.add(1, { op: "create" });
```
## Usage: Advanced
Tests install a real in-memory `MeterProvider`, reset the bundle, run
the production code path, then flush and assert on the collected
records:
```ts
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import {
getBankingInstruments,
resetBankingInstruments,
} from "@suaveplan/api-plugin-banking";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetBankingInstruments();
await service.transferFunds(from, to, 100);
await provider.forceFlush();
const collected = exporter.getMetrics();
// collected contains banking.transfers_total{outcome="success"} = 1
// and banking.transfer_amount with one observation of 100
```
## API Reference
### `interface BankingInstruments`
- `banksTotal: Counter` — `banking.banks_total{op}` where
`op ∈ "create" | "soft-delete" | "update"`.
- `accountsTotal: Counter` — `banking.accounts_total{op}` where
`op ∈ "create" | "delete" | "update" | "status-change"`.
- `transfersTotal: Counter` — `banking.transfers_total{outcome}` where
`outcome ∈ "success" | "insufficient_funds" | "error"`.
- `transferAmount: Histogram` — `banking.transfer_amount`, observations
are positive floats (unitless; treat as currency minor units).
- `fundOperationsTotal: Counter` —
`banking.fund_operations_total{op,outcome}`.
- `queryDurationMs: Histogram` — `banking.query_duration_ms{op}` in
milliseconds.
### `getBankingInstruments(): BankingInstruments`
Returns the cached bundle, building it on first call against the
current global meter provider.
### `resetBankingInstruments(): void`
Drops the cached bundle so the next `getBankingInstruments()` call
rebinds. Test-only — production callers should never need this.
## Implementation Notes
The instruments file is intentionally side-effect-free at import time:
`metrics.getMeter()` is only invoked from the lazy
`getBankingInstruments()` accessor. This lets test setup install its
in-memory provider before any source module imports — the convention
across the genesis monorepo and a non-negotiable for asserting on
metrics without `vi.mock`.
Operators get a small, fixed instrument set rather than a per-table
ballooning surface: every bank/account write maps onto one of the four
op labels on `banksTotal`/`accountsTotal`, every fund movement onto
`transfersTotal` or `fundOperationsTotal`. New routes added in the
future should reuse these counters with a new op label rather than
introducing fresh instruments — keeps the cardinality bounded.

View File

@@ -0,0 +1,82 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the banking plugin.
*
* - `banksTotal{op}` — bank lifecycle transitions. `op ∈
* "create" | "soft-delete" | "update"`.
* - `accountsTotal{op}` — account lifecycle transitions. `op ∈
* "create" | "delete" | "update" | "status-change"`.
* - `transfersTotal{outcome}` — `transferFunds` call counter.
* `outcome ∈ "success" | "insufficient_funds" | "error"`.
* - `transferAmount` — histogram of transfer amounts (units: currency
* minor units, treated as a unitless float for genericity).
* - `fundOperationsTotal{op,outcome}` — `addFunds` / `subtractFunds`.
* `op ∈ "add" | "subtract"`, `outcome ∈ "success" | "error" |
* "insufficient_funds"`.
* - `queryDurationMs{op}` — histogram of repository query wall-clock
* duration in ms, partitioned by op (`"bank.list"`, `"account.list"`,
* `"transfer"`, etc.).
*/
export interface BankingInstruments {
readonly banksTotal: Counter;
readonly accountsTotal: Counter;
readonly transfersTotal: Counter;
readonly transferAmount: Histogram;
readonly fundOperationsTotal: Counter;
readonly queryDurationMs: Histogram;
}
let cached: BankingInstruments | undefined;
/**
* Lazily build (and cache) the banking instruments bundle against the
* current global `MeterProvider`. Call {@link resetBankingInstruments}
* in test setup to force a re-bind against a freshly-installed
* provider.
*/
export function getBankingInstruments(): BankingInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-banking");
cached = {
banksTotal: meter.createCounter("banking.banks_total", {
description:
"Total bank lifecycle transitions, partitioned by op label",
}),
accountsTotal: meter.createCounter("banking.accounts_total", {
description:
"Total bank-account lifecycle transitions, partitioned by op label",
}),
transfersTotal: meter.createCounter("banking.transfers_total", {
description:
"Total transferFunds() calls, partitioned by outcome label",
}),
transferAmount: meter.createHistogram("banking.transfer_amount", {
description:
"Histogram of transfer amounts (unitless float; treat as minor currency units)",
}),
fundOperationsTotal: meter.createCounter(
"banking.fund_operations_total",
{
description:
"Total addFunds/subtractFunds calls, partitioned by op + outcome",
}
),
queryDurationMs: meter.createHistogram("banking.query_duration_ms", {
description:
"Wall-clock duration of banking repository queries in ms",
unit: "ms",
}),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* {@link getBankingInstruments} call rebinds against the global
* `MeterProvider` in effect at that point. Tests call this after
* installing a fresh `MeterProvider`.
*/
export function resetBankingInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,116 @@
# Banking plugin factory
## Purpose
`createBankingPlugin(options?)` is the package's primary entry point —
it Zod-validates the supplied options, builds the `BankingService`
inside the descriptor's `onInit`, bootstraps the default bank inside
`onReady`, mounts the OpenAPIHono route tree on the host `app` under
the configured `routePrefix`, and drops every cached reference inside
`onClose`. The descriptor is shaped against the canonical
`@suaveplan/api-plugin` `PluginDescriptor` so any host that consumes
that contract can register the banking plugin without bespoke wiring.
## Features
- Zod boundary at `createBankingPlugin(options)` — invalid input
rejects with `BankingError(BANKING.INVALID_OPTIONS)` carrying the
`ZodError` as `cause`.
- Declared dependency on the `database` plugin so the topological
loader runs the database plugin first and the `db` slot is
populated before `onInit` resolves it.
- Optional dependencies on `auth`, `audit`, `rate-limit` so a host
that registers those plugins gets them loaded before banking
(matching the enterprise audit's future-work additions); when
absent, the plugin still works (the optional dep contract).
- Idempotent `initializeDefaultBank` invoked from `onReady` — safe on
every host start.
- `decommissionBankingTenant(service, tenantId)` helper exported for
hosts that orchestrate tenant lifecycle externally (since the
current `PluginDescriptor` does not yet declare tenant hooks).
## Usage: Basic
```ts
import { createBankingPlugin } from "@suaveplan/api-plugin-banking";
const plugin = createBankingPlugin();
registry.register(plugin);
```
## Usage: Advanced
Customise the configuration:
```ts
import { createBankingPlugin } from "@suaveplan/api-plugin-banking";
const plugin = createBankingPlugin({
routePrefix: "/api/v1/banking",
maxAccountsPerUser: 25,
enableOverdraft: true,
enableTransfers: true,
defaultBankName: "Acme Trust",
defaultSortCode: 234567,
});
await registry.register(plugin);
await registry.initAll();
await registry.readyAll();
```
Tenant decommission:
```ts
import {
decommissionBankingTenant,
type BankingService,
} from "@suaveplan/api-plugin-banking";
const service = ctx.getService("bankingService") as BankingService;
const { banks, accounts } = await decommissionBankingTenant(
service,
tenantId
);
```
## API Reference
### `createBankingPlugin(options?: BankingPluginOptionsInput): PluginDescriptor<BankingPluginConfig>`
Returns a frozen descriptor. The descriptor exposes:
- `name: "banking"`
- `version: "0.1.0"`
- `dependencies: ["database"]`
- `optionalDependencies: ["auth", "audit", "rate-limit"]`
- `tags: ["banking", "accounts", "finance"]`
- `defaults`: the parsed `BankingPluginConfig`
- `onInit`, `onReady`, `onClose`
### `decommissionBankingTenant(service, tenantId): Promise<{ banks; accounts }>`
Convenience wrapper that delegates to
`BankingService.deleteTenantData`. Returns the count of soft-deleted
banks and hard-deleted accounts. The deletes are not atomic — if the
underlying SurQL throws between the two, `BANKING.TENANT_CLEANUP_FAILED`
surfaces with the partial state visible to the next call.
## Implementation Notes
The plugin uses `dependencies: ["database"]`, not
`optionalDependencies` — banking is meaningless without a database,
and surfacing a typed `BANKING.SERVICE_NOT_AVAILABLE` for the missing-slot
case at `onInit` time is the canonical genesis pattern (instead of
silently no-oping).
The `emit` helper carries a typed-payload signature constrained by
`BankingEventMap`. It runtime-casts to the loose `EventBusLike.emit`
shape because `@suaveplan/types`' `EventBusLike` is intentionally
untyped — strong typing lives on the per-plugin event map, not the
bus. The cast is at one place and narrow.
`onTenantCreate` is intentionally absent because the upstream
package's version was a no-op stub. Hosts that need per-tenant
default-bank provisioning call `service.createBank({ ... site_id })`
from their own tenant lifecycle hook.

View File

@@ -0,0 +1,148 @@
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import { BANKING_CODES, BankingError } from "../error-codes/error-codes.js";
import { createBankingRoutes } from "../routes/build-routes.js";
import {
type BankingPluginConfig,
type BankingPluginOptionsInput,
BankingPluginOptionsSchema,
} from "../schemas/options.js";
import { BankingService } from "../service/service.js";
import "../services/registry-augmentation.js";
import type { BankingEventMap } from "../types/types.js";
interface PluginRuntime {
service: BankingService | undefined;
}
/**
* Factory — returns a typed `PluginDescriptor` for the banking plugin.
*
* Zod-validates the supplied options at the boundary so invalid
* input fails fast with `BankingError(BANKING.INVALID_OPTIONS)`
* carrying the `ZodError` as `cause`. The descriptor's
* `dependencies` lists `"database"` because the plugin's `onInit`
* resolves `ctx.getService("db")` and fails if it is missing.
*
* The returned descriptor's `onInit` / `onReady` / `onClose` implement
* the banking lifecycle:
*
* 1. `onInit` — resolve `db` from the service registry, construct the
* `BankingService`, register it under `bankingService`; emit
* `banking:initialized`.
* 2. `onReady` — bootstrap the configured default bank, build the
* route tree, mount it on the host `app` under the configured
* `routePrefix`; emit `banking:ready`.
* 3. `onClose` — drop the cached service.
* 4. `onTenantCreate` — currently a no-op; reserved for future
* tenant-scoped default-bank provisioning.
* 5. `onTenantDelete` — purge banks + accounts owned by the tenant.
*/
export function createBankingPlugin(
options: BankingPluginOptionsInput = {}
): PluginDescriptor<BankingPluginConfig> {
const parsed = BankingPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new BankingError(
BANKING_CODES.INVALID_OPTIONS,
"createBankingPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const config: BankingPluginConfig = parsed.data;
const runtime: PluginRuntime = { service: undefined };
return definePlugin<BankingPluginConfig>({
name: "banking",
version: "0.1.0",
description:
"Banking foundation plugin: bank-institution + bank-account CRUD on SurrealDB, fund-movement primitives, lifecycle hooks for tenant cleanup.",
dependencies: ["database"],
optionalDependencies: ["auth", "audit", "rate-limit"],
tags: ["banking", "accounts", "finance"],
defaults: config,
onInit: async (ctx) => runOnInit(ctx, config, runtime),
onReady: async (ctx) => runOnReady(ctx, config, runtime),
onClose: async () => {
runtime.service = undefined;
},
});
}
async function runOnInit(
ctx: PluginContext<BankingPluginConfig>,
config: BankingPluginConfig,
runtime: PluginRuntime
): Promise<void> {
const db = ctx.getService("db");
if (db === undefined) {
throw new BankingError(
BANKING_CODES.SERVICE_NOT_AVAILABLE,
"Banking plugin requires the `db` service in ServiceRegistry",
{ context: { missing: ["db"], statusCode: 500 } }
);
}
const service = new BankingService(db, config);
runtime.service = service;
ctx.registerService("bankingService", service);
emit(ctx, "banking:initialized", undefined);
}
async function runOnReady(
ctx: PluginContext<BankingPluginConfig>,
config: BankingPluginConfig,
runtime: PluginRuntime
): Promise<void> {
const service = runtime.service;
if (service === undefined) {
throw new BankingError(
BANKING_CODES.SERVICE_NOT_AVAILABLE,
"onReady invoked before onInit completed",
{ context: { phase: "onReady" } }
);
}
await service.initializeDefaultBank();
const app = ctx.getService("app");
if (app === undefined) {
throw new BankingError(
BANKING_CODES.SERVICE_NOT_AVAILABLE,
"Banking plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const routes = createBankingRoutes({
service,
emitEvent: (type, payload) => emit(ctx, type, payload),
});
app.route(config.routePrefix, routes);
emit(ctx, "banking:ready", undefined);
}
function emit<K extends keyof BankingEventMap>(
ctx: PluginContext<BankingPluginConfig>,
type: K,
payload: Parameters<BankingEventMap[K]>[0]
): void {
(ctx.eventBus as { emit: (t: string, p: unknown) => void }).emit(
type,
payload
);
}
/**
* Tenant-cleanup helper used by host orchestrators that wire
* `onTenantDelete` through their own coordinator. Plugin descriptors
* in genesis do not yet carry a typed `onTenantDelete` hook; consumers
* call this directly when a tenant is decommissioned.
*/
export async function decommissionBankingTenant(
service: BankingService,
tenantId: string
): Promise<{ readonly banks: number; readonly accounts: number }> {
return service.deleteTenantData(tenantId);
}

View File

@@ -0,0 +1,121 @@
# Bank-account repository
## Purpose
`BankAccountRepository` owns the SurrealDB `bank_account` table. It
extends `BaseRepository<BankAccountRecord>` and exposes the
account-specific surface that the plugin's service layer and HTTP
routes consume: typed `create`/`update`, lookups by user / bank /
account-number, fund-movement primitives (`transferFunds`, `addFunds`,
`subtractFunds`) returning the discriminated `RepositoryResponse`
union, status-FSM transitions, owner / account ergonomics, and the
account-number arithmetic helpers that parse the
`BBBB-CCCC-AAAAAAAA` and 12-digit numeric formats.
## Features
- Typed `create(data: CreateBankAccount)` requires `bank_id` (throws
`BANKING.BANK_NOT_FOUND` when omitted) and normalises both
`user_id` and `bank_id` to RecordId form before insert.
- Fund movement: `transferFunds`, `addFunds`, `subtractFunds` —
positive-amount guarded (`BANKING.INVALID_AMOUNT`) and
overdraft-aware (`BANKING.INSUFFICIENT_FUNDS`).
- OTel hot paths: every fund movement emits both
`banking.transfers_total{outcome}` / `banking.fund_operations_total`
and the `banking.transfer_amount` histogram.
- `countByUserId` powers the plugin's per-user account-limit guard.
- `deleteByTenant` returns the deleted row count for the plugin's
tenant-cleanup hook.
## Usage: Basic
```ts
import { BankAccountRepository } from "@suaveplan/api-plugin-banking";
const repo = new BankAccountRepository(adapter);
const account = await repo.create({
user_id: "user:abc",
bank_id: "bank:123",
account_number: 12345678,
});
const result = await repo.addFunds(account.id, 100);
```
## Usage: Advanced
Transfer with overdraft tolerance and pattern-match on the result:
```ts
const result = await repo.transferFunds(fromId, toId, 250, /* overdraft */ 50);
if (result.success) {
console.log("from:", result.data.fromAccount.balance);
console.log("to: ", result.data.toAccount.balance);
} else {
if (result.code === "BANKING.INSUFFICIENT_FUNDS") {
await notifyLowBalance(fromId);
}
throw new Error(result.error);
}
```
Account-number arithmetic:
```ts
const parts = repo.parseAccountNumber("0123-4567-89012345");
const split = repo.splitAccountNumber(123456789012);
// split = { bank: 1234, branch: 5678, account: 9012 }
```
Status FSM (close an account):
```ts
await repo.updateStatus(account.id, 1); // 1 = closed
```
## API Reference
### `class BankAccountRepository extends BaseRepository<BankAccountRecord>`
Inherits everything from `BaseRepository` and adds:
- `create(data: CreateBankAccount): Promise<BankAccountRecord>`
- `update(id, data: UpdateBankAccount): Promise<BankAccountRecord | null>`
- `getByUserId(userId): Promise<BankAccountRecord[]>`
- `getByBankId(bankId): Promise<BankAccountRecord[]>`
- `getByAccountNumber(num): Promise<BankAccountRecord | null>`
- `getActiveAccounts(limit?, offset?): Promise<BankAccountRecord[]>`
- `transferFunds(from, to, amount, overdraftAllowance?): Promise<TransferResult>`
- `addFunds(id, amount): Promise<BankAccountRecord | null>`
- `subtractFunds(id, amount, overdraftAllowance?): Promise<FundsOperationResult>`
- `updateStatus(id, status: BankAccountStatus): Promise<BankAccountRecord | null>`
- `getAccountOwner(id): Promise<string | null>`
- `userOwnsAccount(accountId, userId): Promise<boolean>`
- `parseAccountNumber(str): { bankCode; branchCode; accountCode }`
- `splitAccountNumber(num): { bank; branch; account }`
- `getBalance(id): Promise<number | null>`
- `hasSufficientBalance(id, amount): Promise<boolean>`
- `countByUserId(userId): Promise<number>`
- `deleteByTenant(tenantId): Promise<number>`
## Implementation Notes
`transferFunds` is implemented as four sequential SurQL statements
(fetch, fetch, update, update, re-fetch, re-fetch) rather than a single
`BEGIN ... COMMIT` block. This is a deliberate concession to the
genesis `DatabaseAdapter` surface: the canonical `query(sql, params[])`
contract does not pass through SurQL transaction markers cleanly across
all driver versions. The service layer wraps the whole operation in
`db.transaction(fn)` when the host registers a transactional adapter
so the atomicity property is preserved at that level; the repository
remains driver-portable.
The overdraft policy is **caller-driven**: each fund-movement method
takes an optional `overdraftAllowance` parameter (default `0`). The
service layer reads this from the per-account `overdraft` field when
the plugin is configured with `enableOverdraft: true`, and passes `0`
otherwise — so the same primitive supports both policies without a
config-dependent branch inside the repository.
`getByAccountNumber` matches on the integer `account_number` column
which is unique-indexed at the SurQL schema level. The upstream
package did not enforce that uniqueness; the genesis schema does, so
the lookup is `O(1)` rather than table-scanning.

View File

@@ -0,0 +1,407 @@
import type { DatabaseAdapter } from "@suaveplan/db";
import { BANKING_CODES, BankingError } from "../error-codes/error-codes.js";
import { getBankingInstruments } from "../otel/instruments.js";
import type {
BankAccountStatus,
CreateBankAccount,
UpdateBankAccount,
} from "../schemas/schemas.js";
import type {
BankAccountRecord,
FundsOperationResult,
TransferResult,
} from "../types/types.js";
import {
BaseRepository,
extractId,
flattenRecordIds,
normaliseId,
} from "./base-repository.js";
const TABLE = "bank_account";
/**
* Repository owning the SurrealDB `bank_account` table.
*
* Inherits `getById`, `getAll`, `count`, `exists`, `delete`, `findBy`,
* `findOneBy` from `BaseRepository`. Adds account-specific surface:
* typed `create`/`update`, `getByUserId`, `getByBankId`,
* `getByAccountNumber`, `getActiveAccounts`, fund-movement primitives
* (`transferFunds`, `addFunds`, `subtractFunds`), status FSM
* (`updateStatus`), owner/account ergonomics (`getAccountOwner`,
* `userOwnsAccount`), and account-number arithmetic
* (`parseAccountNumber`, `splitAccountNumber`, `getBalance`,
* `hasSufficientBalance`).
*/
export class BankAccountRepository extends BaseRepository<BankAccountRecord> {
public constructor(db: DatabaseAdapter) {
super(db, TABLE);
}
protected getAllowedFields(): readonly string[] {
return [
"user_id",
"bank_id",
"account_number",
"account_type",
"balance",
"overdraft",
"status",
"description",
"created_at",
"updated_at",
];
}
public async create(data: CreateBankAccount): Promise<BankAccountRecord> {
if (data.bank_id === undefined) {
throw new BankingError(
BANKING_CODES.BANK_NOT_FOUND,
"bank_id is required to create a bank account",
{ context: { table: TABLE, userId: data.user_id } }
);
}
const insert: Record<string, unknown> = {
user_id: normaliseId(data.user_id, "user"),
bank_id: normaliseId(data.bank_id, "bank"),
account_number: data.account_number,
account_type: data.account_type ?? 1,
balance: data.balance ?? 0,
overdraft: data.overdraft ?? 0,
status: data.status ?? 4,
};
if (data.description !== undefined) {
insert.description = data.description;
}
try {
const row = await this.db.create<Record<string, unknown>>(
TABLE,
insert
);
getBankingInstruments().accountsTotal.add(1, { op: "create" });
return flattenRecordIds(row) as BankAccountRecord;
} catch (cause) {
throw new BankingError(
BANKING_CODES.DATABASE_ERROR,
`Failed to create bank account: ${cause instanceof Error ? cause.message : String(cause)}`,
{
cause: cause instanceof Error ? cause : undefined,
context: { table: TABLE, userId: data.user_id },
}
);
}
}
public async update(
id: string,
data: UpdateBankAccount
): Promise<BankAccountRecord | null> {
const result = await super.update(id, data as Record<string, unknown>);
if (result !== null) {
getBankingInstruments().accountsTotal.add(1, { op: "update" });
}
return result;
}
public async getByUserId(userId: string): Promise<BankAccountRecord[]> {
return this.findBy("user_id", normaliseId(userId, "user"));
}
public async getByBankId(bankId: string): Promise<BankAccountRecord[]> {
return this.findBy("bank_id", normaliseId(bankId, "bank"));
}
public async getByAccountNumber(
accountNumber: number
): Promise<BankAccountRecord | null> {
return this.findOneBy("account_number", accountNumber);
}
public async getActiveAccounts(
limit?: number,
offset?: number
): Promise<BankAccountRecord[]> {
let sql = `SELECT * FROM ${TABLE} WHERE status = $p0`;
if (limit !== undefined) {
sql += ` LIMIT ${Math.max(0, Math.floor(limit))} START ${Math.max(0, Math.floor(offset ?? 0))}`;
}
const rows = await this.runQuery<BankAccountRecord>(sql, [4]);
return rows.map((r) => flattenRecordIds(r));
}
/**
* Move funds between two accounts atomically (best effort: SurQL
* `transaction` semantics). Returns a discriminated
* `TransferResult`.
*
* Validates `amount > 0` (otherwise `INVALID_AMOUNT`) and
* `from.balance >= amount` (`INSUFFICIENT_FUNDS`). Overdraft
* tolerance is delegated to the service layer — this primitive
* enforces non-negative ending balance.
*/
public async transferFunds(
fromAccountId: string,
toAccountId: string,
amount: number,
overdraftAllowance = 0
): Promise<TransferResult> {
if (!(amount > 0)) {
throw new BankingError(
BANKING_CODES.INVALID_AMOUNT,
`Transfer amount must be positive (got ${amount})`,
{ context: { amount } }
);
}
const inst = getBankingInstruments();
inst.transferAmount.record(amount);
const fromClean = extractId(fromAccountId);
const toClean = extractId(toAccountId);
try {
const fromRow = await this.getById(`${TABLE}:${fromClean}`);
if (fromRow === null) {
inst.transfersTotal.add(1, { outcome: "error" });
return {
success: false,
error: "Source account not found",
code: BANKING_CODES.ACCOUNT_NOT_FOUND,
};
}
const toRow = await this.getById(`${TABLE}:${toClean}`);
if (toRow === null) {
inst.transfersTotal.add(1, { outcome: "error" });
return {
success: false,
error: "Destination account not found",
code: BANKING_CODES.ACCOUNT_NOT_FOUND,
};
}
const threshold = -Math.abs(overdraftAllowance);
if (fromRow.balance - amount < threshold) {
inst.transfersTotal.add(1, {
outcome: "insufficient_funds",
});
return {
success: false,
error: "Insufficient funds",
code: BANKING_CODES.INSUFFICIENT_FUNDS,
};
}
await this.runQuery(
`UPDATE ${TABLE}:${fromClean} SET balance = balance - $p0, updated_at = time::now()`,
[amount]
);
await this.runQuery(
`UPDATE ${TABLE}:${toClean} SET balance = balance + $p0, updated_at = time::now()`,
[amount]
);
const fromAfter = await this.getById(`${TABLE}:${fromClean}`);
const toAfter = await this.getById(`${TABLE}:${toClean}`);
if (fromAfter === null || toAfter === null) {
inst.transfersTotal.add(1, { outcome: "error" });
return {
success: false,
error: "Transfer applied but accounts could not be re-read",
};
}
inst.transfersTotal.add(1, { outcome: "success" });
return {
success: true,
data: { fromAccount: fromAfter, toAccount: toAfter },
};
} catch (cause) {
inst.transfersTotal.add(1, { outcome: "error" });
const msg =
cause instanceof Error ? cause.message : "Transfer failed";
return { success: false, error: msg };
}
}
/** Add a positive `amount` to an account's balance. */
public async addFunds(
accountId: string,
amount: number
): Promise<BankAccountRecord | null> {
if (!(amount > 0)) {
throw new BankingError(
BANKING_CODES.INVALID_AMOUNT,
`addFunds amount must be positive (got ${amount})`,
{ context: { amount } }
);
}
const clean = extractId(accountId);
await this.runQuery(
`UPDATE ${TABLE}:${clean} SET balance = balance + $p0, updated_at = time::now()`,
[amount]
);
getBankingInstruments().fundOperationsTotal.add(1, {
op: "add",
outcome: "success",
});
return this.getById(`${TABLE}:${clean}`);
}
public async subtractFunds(
accountId: string,
amount: number,
overdraftAllowance = 0
): Promise<FundsOperationResult> {
if (!(amount > 0)) {
throw new BankingError(
BANKING_CODES.INVALID_AMOUNT,
`subtractFunds amount must be positive (got ${amount})`,
{ context: { amount } }
);
}
const inst = getBankingInstruments();
const clean = extractId(accountId);
const current = await this.getById(`${TABLE}:${clean}`);
if (current === null) {
inst.fundOperationsTotal.add(1, {
op: "subtract",
outcome: "error",
});
return {
success: false,
error: "Account not found",
code: BANKING_CODES.ACCOUNT_NOT_FOUND,
};
}
const threshold = -Math.abs(overdraftAllowance);
if (current.balance - amount < threshold) {
inst.fundOperationsTotal.add(1, {
op: "subtract",
outcome: "insufficient_funds",
});
return {
success: false,
error: "Insufficient funds",
code: BANKING_CODES.INSUFFICIENT_FUNDS,
};
}
await this.runQuery(
`UPDATE ${TABLE}:${clean} SET balance = balance - $p0, updated_at = time::now()`,
[amount]
);
const after = await this.getById(`${TABLE}:${clean}`);
if (after === null) {
inst.fundOperationsTotal.add(1, {
op: "subtract",
outcome: "error",
});
return {
success: false,
error: "Account vanished after update",
};
}
inst.fundOperationsTotal.add(1, {
op: "subtract",
outcome: "success",
});
return { success: true, data: after };
}
/** Update only the status FSM field. */
public async updateStatus(
accountId: string,
status: BankAccountStatus
): Promise<BankAccountRecord | null> {
const result = await this.update(accountId, { status });
if (result !== null) {
getBankingInstruments().accountsTotal.add(1, {
op: "status-change",
});
}
return result;
}
/** Returns the account's `user_id` (table-prefixed) or null. */
public async getAccountOwner(accountId: string): Promise<string | null> {
const account = await this.getById(accountId);
if (account === null) return null;
const owner = account.user_id;
return typeof owner === "string" ? owner : null;
}
public async userOwnsAccount(
accountId: string,
userId: string
): Promise<boolean> {
const owner = await this.getAccountOwner(accountId);
if (owner === null) return false;
return owner === normaliseId(userId, "user");
}
/** Parse `BBBB-CCCC-AAAAAAAA` into its three components. */
public parseAccountNumber(accountNumber: string): {
bankCode: string;
branchCode: string;
accountCode: string;
} {
const parts = accountNumber.split("-");
if (parts.length !== 3 || parts.some((p) => p.length === 0)) {
throw new BankingError(
BANKING_CODES.INVALID_ACCOUNT_NUMBER,
`Account number "${accountNumber}" must be BBBB-CCCC-AAAAAAAA`,
{ context: { accountNumber } }
);
}
return {
bankCode: parts[0],
branchCode: parts[1],
accountCode: parts[2],
};
}
/** Split a 12-digit numeric account number into bank / branch / account. */
public splitAccountNumber(accountNumber: number): {
bank: number;
branch: number;
account: number;
} {
if (!Number.isInteger(accountNumber) || accountNumber < 0) {
throw new BankingError(
BANKING_CODES.INVALID_ACCOUNT_NUMBER,
`Account number must be a non-negative integer (got ${accountNumber})`,
{ context: { accountNumber } }
);
}
const str = accountNumber.toString().padStart(12, "0");
return {
bank: Number.parseInt(str.slice(0, 4), 10),
branch: Number.parseInt(str.slice(4, 8), 10),
account: Number.parseInt(str.slice(8, 12), 10),
};
}
public async getBalance(accountId: string): Promise<number | null> {
const account = await this.getById(accountId);
return account === null ? null : account.balance;
}
public async hasSufficientBalance(
accountId: string,
amount: number
): Promise<boolean> {
const balance = await this.getBalance(accountId);
return balance !== null && balance >= amount;
}
/** Count a user's accounts (used by the account-limit guard). */
public async countByUserId(userId: string): Promise<number> {
const rows = await this.runQuery<{ count?: number }>(
`SELECT count() AS count FROM ${TABLE} WHERE user_id = $p0 GROUP ALL`,
[normaliseId(userId, "user")]
);
return Number(rows[0]?.count ?? 0);
}
/** Delete every account owned by a tenant's users (tenant cleanup). */
public async deleteByTenant(tenantId: string): Promise<number> {
const normalised = normaliseId(tenantId, "site");
const rows = await this.runQuery<{ id: unknown }>(
`DELETE ${TABLE} WHERE user_id.tenant_id = $p0 RETURN BEFORE`,
[normalised]
);
return rows.length;
}
}

View File

@@ -0,0 +1,112 @@
# Bank repository
## Purpose
`BankRepository` owns the SurrealDB `bank` table. It extends
`BaseRepository<BankRecord>` and exposes the bank-specific
read/write surface that the plugin's service layer and HTTP routes
consume: typed `create` and `update`, lookups by name, site, sort
code, status; `softDelete` (sets status = 1); `hasActiveAccounts`
(used as the precondition for `softDelete`); a case-insensitive
`searchByName`; and the idempotent `getOrCreateDefaultBank` that the
plugin's `onReady` hook calls during bootstrap.
## Features
- Typed `create(data: CreateBank)` / `update(id, data: UpdateBank)`
with site-id normalisation and field-whitelist enforcement.
- Sort-code lookup gates on the `XX-XX-XX` regex and throws
`BankingError(INVALID_SORT_CODE)` for malformed input — never
silently passes through to the SurQL planner.
- `hasActiveAccounts` reads the `bank_account` table via parameterised
SurQL so the soft-delete precondition is enforced inside the same
request.
- OTel `banking.banks_total{op}` counter emitted on every successful
create/update/soft-delete so observability is on the hot path.
- `deleteByTenant` returns the hard-deleted row count so the plugin's
`onTenantDelete` hook can log the cleanup volume.
## Usage: Basic
```ts
import { BankRepository } from "@suaveplan/api-plugin-banking";
const repo = new BankRepository(adapter);
const bank = await repo.create({ name: "Genesis Bank", sort_code: 234567 });
await repo.softDelete(bank.id);
```
## Usage: Advanced
Bootstrap the configured default bank in a lifecycle hook:
```ts
import { BankRepository } from "@suaveplan/api-plugin-banking";
async function bootstrap(repo: BankRepository) {
const bank = await repo.getOrCreateDefaultBank("Test Bank", 123456);
return bank;
}
```
Sort-code-driven lookup:
```ts
const found = await repo.getBySortCode("12-34-56");
// throws BankingError(INVALID_SORT_CODE) if format is wrong
```
Tenant cleanup (used by the plugin's `onTenantDelete` hook):
```ts
const count = await repo.deleteByTenant("site:abcdef");
logger.info({ count }, "banks purged for tenant");
```
## API Reference
### `class BankRepository extends BaseRepository<BankRecord>`
Constructor: `new BankRepository(db: DatabaseAdapter)`.
Inherits everything from `BaseRepository` and adds:
- `create(data: CreateBank): Promise<BankRecord>`
- `update(id, data: UpdateBank): Promise<BankRecord | null>` (typed
override)
- `existsByName(name, siteId?): Promise<boolean>`
- `getByName(name): Promise<BankRecord | null>`
- `getBySiteId(siteId): Promise<BankRecord[]>`
- `getBySortCode(sortCode: string): Promise<BankRecord | null>` —
formatted `XX-XX-XX` input
- `getByStatus(status, limit?, offset?): Promise<BankRecord[]>`
- `getActiveBanks(limit?, offset?): Promise<BankRecord[]>`
- `getAvailableBanks(limit?, offset?): Promise<BankRecord[]>`
- `softDelete(id): Promise<BankRecord | null>`
- `hasActiveAccounts(bankId): Promise<boolean>`
- `searchByName(searchTerm, limit?): Promise<BankRecord[]>`
- `getOrCreateDefaultBank(name, sortCode): Promise<BankRecord>`
- `deleteByTenant(tenantId): Promise<number>` — count of deleted rows
Static helper: `BankRepository.extractBareId(id): string`.
## Implementation Notes
The sort-code field is stored as `int` in SurrealDB (matching the
upstream `surql/tables/bank-account.surql` schema), so the wire-format
`XX-XX-XX` string is parsed to a number at every lookup site. The
regex guard rejects partial / malformed strings up front; a stray
`12-345-6` would otherwise `parseInt` to `12345` and silently return
the wrong bank.
`getOrCreateDefaultBank` is idempotent by design — the plugin's
`onReady` runs it on every host start, and consumers that run multi-host
deployments rely on the "look-up-first, then insert" pattern to avoid
sort-code collisions. This is not a race-free guarantee (two hosts
booting at exactly the same time can both observe missing → both
insert), but the underlying SurQL `INSERT` will reject on the unique
index in that case, falling back to the lookup-then-fetch path.
`hasActiveAccounts` queries `bank_account` directly rather than going
through `BankAccountRepository.findBy(...)` to avoid the constructor
dependency between repositories — both can be instantiated
independently with only the shared `DatabaseAdapter`.

View File

@@ -0,0 +1,214 @@
import type { DatabaseAdapter } from "@suaveplan/db";
import { BANKING_CODES, BankingError } from "../error-codes/error-codes.js";
import { getBankingInstruments } from "../otel/instruments.js";
import type { CreateBank, UpdateBank } from "../schemas/schemas.js";
import type { BankRecord } from "../types/types.js";
import {
BaseRepository,
extractId,
flattenRecordIds,
normaliseId,
} from "./base-repository.js";
const TABLE = "bank";
/**
* Repository owning the SurrealDB `bank` table.
*
* Inherits `getById`, `getAll`, `count`, `exists`, `delete`, `findBy`,
* `findOneBy` from `BaseRepository`. Adds bank-specific surface:
* `create`, `update` (typed), `existsByName`, `getByName`,
* `getBySiteId`, `getBySortCode`, `getByStatus`, `getActiveBanks`,
* `getAvailableBanks`, `softDelete`, `hasActiveAccounts`,
* `searchByName`, `getOrCreateDefaultBank`.
*/
export class BankRepository extends BaseRepository<BankRecord> {
public constructor(db: DatabaseAdapter) {
super(db, TABLE);
}
protected getAllowedFields(): readonly string[] {
return [
"name",
"sort_code",
"site_id",
"status",
"created_at",
"updated_at",
];
}
/** Insert a new bank record, normalising `site_id` and defaulting `status`. */
public async create(data: CreateBank): Promise<BankRecord> {
const insert: Record<string, unknown> = {
name: data.name,
status: data.status ?? 4,
};
if (data.sort_code !== undefined) insert.sort_code = data.sort_code;
if (data.site_id !== undefined) {
insert.site_id = normaliseId(data.site_id, "site");
}
try {
const row = await this.db.create<Record<string, unknown>>(
TABLE,
insert
);
getBankingInstruments().banksTotal.add(1, { op: "create" });
return flattenRecordIds(row) as BankRecord;
} catch (cause) {
throw new BankingError(
BANKING_CODES.DATABASE_ERROR,
`Failed to create bank: ${cause instanceof Error ? cause.message : String(cause)}`,
{
cause: cause instanceof Error ? cause : undefined,
context: { table: TABLE, name: data.name },
}
);
}
}
/** Typed update over the writable-field whitelist. */
public async update(
id: string,
data: UpdateBank
): Promise<BankRecord | null> {
const result = await super.update(id, data as Record<string, unknown>);
if (result !== null) {
getBankingInstruments().banksTotal.add(1, { op: "update" });
}
return result;
}
/**
* Returns `true` when a bank with the given `name` exists. When
* `siteId` is provided the search is scoped to that site.
*/
public async existsByName(name: string, siteId?: string): Promise<boolean> {
const params: unknown[] = [name];
let sql = `SELECT id FROM ${TABLE} WHERE name = $p0`;
if (siteId !== undefined) {
sql += " AND site_id = $p1";
params.push(normaliseId(siteId, "site"));
}
sql += " LIMIT 1";
const rows = await this.runQuery<{ id: unknown }>(sql, params);
return rows.length > 0;
}
public async getByName(name: string): Promise<BankRecord | null> {
return this.findOneBy("name", name);
}
public async getBySiteId(siteId: string): Promise<BankRecord[]> {
return this.findBy("site_id", normaliseId(siteId, "site"));
}
/**
* Lookup by formatted sort code (`XX-XX-XX`). Returns `null` on
* miss; raises `BANKING.INVALID_SORT_CODE` for malformed input.
*/
public async getBySortCode(sortCode: string): Promise<BankRecord | null> {
if (!/^\d{2}-\d{2}-\d{2}$/.test(sortCode)) {
throw new BankingError(
BANKING_CODES.INVALID_SORT_CODE,
`Sort code "${sortCode}" must be in format XX-XX-XX`,
{ context: { sortCode } }
);
}
const numeric = Number.parseInt(sortCode.replaceAll("-", ""), 10);
return this.findOneBy("sort_code", numeric);
}
public async getByStatus(
status: number,
limit?: number,
offset?: number
): Promise<BankRecord[]> {
const params: unknown[] = [status];
let sql = `SELECT * FROM ${TABLE} WHERE status = $p0`;
if (limit !== undefined) {
sql += ` LIMIT ${Math.max(0, Math.floor(limit))} START ${Math.max(0, Math.floor(offset ?? 0))}`;
}
const rows = await this.runQuery<BankRecord>(sql, params);
return rows.map((r) => flattenRecordIds(r));
}
public async getActiveBanks(
limit?: number,
offset?: number
): Promise<BankRecord[]> {
return this.getByStatus(4, limit, offset);
}
public async getAvailableBanks(
limit?: number,
offset?: number
): Promise<BankRecord[]> {
let sql = `SELECT * FROM ${TABLE} WHERE status != $p0 ORDER BY created_at DESC`;
if (limit !== undefined) {
sql += ` LIMIT ${Math.max(0, Math.floor(limit))} START ${Math.max(0, Math.floor(offset ?? 0))}`;
}
const rows = await this.runQuery<BankRecord>(sql, [1]);
return rows.map((r) => flattenRecordIds(r));
}
/** Mark bank as closed (status = 1). Returns updated record or null. */
public async softDelete(id: string): Promise<BankRecord | null> {
const result = await this.update(id, { status: 1 });
if (result !== null) {
getBankingInstruments().banksTotal.add(1, { op: "soft-delete" });
}
return result;
}
public async hasActiveAccounts(bankId: string): Promise<boolean> {
const normalised = normaliseId(bankId, TABLE);
const rows = await this.runQuery<{ count?: number }>(
"SELECT count() AS count FROM bank_account WHERE bank_id = $p0 AND status = $p1 GROUP ALL",
[normalised, 4]
);
return Number(rows[0]?.count ?? 0) > 0;
}
/** Substring (case-insensitive) name search, excluding closed banks. */
public async searchByName(
searchTerm: string,
limit?: number
): Promise<BankRecord[]> {
let sql = `SELECT * FROM ${TABLE} WHERE string::lowercase(name) CONTAINS string::lowercase($p0) AND status != $p1`;
if (limit !== undefined) {
sql += ` LIMIT ${Math.max(0, Math.floor(limit))}`;
}
const rows = await this.runQuery<BankRecord>(sql, [searchTerm, 1]);
return rows.map((r) => flattenRecordIds(r));
}
/**
* Idempotent: look up a bank by name, creating it with the
* supplied defaults when missing. Used by `onReady` to bootstrap
* the configured default bank.
*/
public async getOrCreateDefaultBank(
name: string,
sortCode: number
): Promise<BankRecord> {
const existing = await this.getByName(name);
if (existing !== null) return existing;
return this.create({ name, sort_code: sortCode, status: 4 });
}
/** Re-export for the plugin's tenant-cleanup hook. */
public async deleteByTenant(tenantId: string): Promise<number> {
const normalised = normaliseId(tenantId, "site");
const rows = await this.runQuery<{ id: unknown }>(
`DELETE ${TABLE} WHERE site_id = $p0 RETURN BEFORE`,
[normalised]
);
return rows.length;
}
/** Resolve a plain `:id` or full RecordId form to the bare id segment. */
public static extractBareId(id: string): string {
return extractId(id);
}
}

View File

@@ -0,0 +1,120 @@
# Base repository
## Purpose
This module owns the shared repository scaffold that `BankRepository`
and `BankAccountRepository` both extend. It holds the `DatabaseAdapter`
reference, the table name, the SQL-injection-proof field-whitelist
guard, generic record-id normalisation helpers, and the common CRUD
primitives (`getById`, `getAll`, `count`, `exists`, `update`, `delete`,
`findBy`, `findOneBy`).
## Features
- Field whitelist enforced on every generated `UPDATE` statement —
unknown field names throw `BankingError(INVALID_FIELD_NAME)`
rather than reaching the SurQL planner.
- Parameterised values via the genesis adapter's positional
`$p0, $p1, ...` convention; SQL strings never carry user-supplied
data inline.
- `flattenRecordIds` collapses RecordId-shaped values (`{ tb, id }`,
driver-specific RecordId instances, plain strings) to the canonical
`table:id` string at the public boundary.
- Errors uniformly wrapped: every thrown SurrealDB error is converted
to `BankingError(DATABASE_ERROR)` with `cause` and structured
`context` preserved.
## Usage: Basic
```ts
import { BaseRepository } from "@suaveplan/api-plugin-banking";
class WidgetRepository extends BaseRepository<{ id: string; name: string }> {
protected getAllowedFields(): readonly string[] {
return ["name", "updated_at"];
}
}
const repo = new WidgetRepository(adapter, "widget");
const w = await repo.getById("widget:abc");
await repo.update("widget:abc", { name: "renamed" });
await repo.delete("widget:abc");
```
## Usage: Advanced
The repository class is generic in the record type. Use the type
parameter to enforce the row shape across the public API:
```ts
import { BaseRepository, type BankRecord } from "@suaveplan/api-plugin-banking";
class BankRepo extends BaseRepository<BankRecord> {
protected getAllowedFields(): readonly string[] {
return ["name", "sort_code", "site_id", "status", "updated_at"];
}
async getActive(): Promise<BankRecord[]> {
return this.findBy("status", 4);
}
}
```
For ad-hoc queries that the base scaffold does not cover, child classes
call the protected `runQuery<T>(sql, params)` helper directly:
```ts
const rows = await this.runQuery<{ count: number }>(
`SELECT count() AS count FROM bank WHERE status = $p0 GROUP ALL`,
[4]
);
```
## API Reference
### `class BaseRepository<TRecord>`
`constructor(db: DatabaseAdapter, tableName: string)`
Protected `runQuery<T>(sql, params?): Promise<T[]>` — every child SQL
call routes through this so the `DATABASE_ERROR` wrapper is uniform.
Protected `validateFieldName(field: string): void` — throws
`BankingError(INVALID_FIELD_NAME)` when the name is outside the
child's whitelist.
Public methods (all `async`, all return flattened records or `null`):
- `getById(id)`, `getAll(limit?, offset?)`, `count()`, `exists(id)`,
- `update(id, data)`, `delete(id)`,
- `findBy(field, value)`, `findOneBy(field, value)`.
Abstract `getAllowedFields(): readonly string[]` — child classes
return the writable-field whitelist (the union of every column the
public surface ever updates plus `updated_at` for the auto-touched
timestamp).
### Helpers
- `extractId(id: string): string` — strips a `table:` prefix.
- `normaliseId(id: string, tableName: string): string` — ensures the
prefix is present.
- `flattenRecordIds<T>(record): T` — flattens nested RecordId-shaped
values to `table:id` strings.
## Implementation Notes
The class is `abstract` rather than instantiable because the
`getAllowedFields()` contract is load-bearing: any concrete repository
that forgets to override it cannot pass field-name validation and so
the SQL-injection guard fails closed. This is enforced at the type
level (`abstract` method) rather than at runtime.
`update()` returns the current record when called with an empty
`data` object (after filtering `undefined` and `updated_at`). This is
the SQL-side equivalent of a no-op and matches the upstream
package's behaviour — call sites that pass `data: {}` get the
fetched record back, not `null`.
`getAll()` short-circuits to `db.selectAll(table)` when no pagination
is requested (the typed adapter path), and only falls back to a raw
SQL pagination query when `limit` or `offset` is non-default. This
preserves the SurrealDB type narrowing for the common case.

View File

@@ -0,0 +1,223 @@
import type { DatabaseAdapter } from "@suaveplan/db";
import { BANKING_CODES, BankingError } from "../error-codes/error-codes.js";
/**
* Strip the `table:` prefix from a RecordId-like string. If the input
* has no `:`, it is returned unchanged.
*/
export function extractId(id: string): string {
const i = id.indexOf(":");
return i === -1 ? id : id.slice(i + 1);
}
/**
* Ensure an id carries the `table:` prefix. If it already does, return
* it unchanged; otherwise prefix with `${tableName}:`.
*/
export function normaliseId(id: string, tableName: string): string {
return id.includes(":") ? id : `${tableName}:${id}`;
}
/**
* Flatten any RecordId-like object on a returned record to its
* `table:id` string form. The SurrealDB driver may return either a
* plain string id, a `RecordId` instance, or a `{ tb, id }` plain
* object — this normaliser collapses all three to a string.
*/
export function flattenRecordIds<T extends Record<string, unknown>>(
record: T
): T {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
out[key] = flattenSingleId(value);
}
return out as T;
}
function flattenSingleId(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (typeof value !== "object") return value;
const obj = value as Record<string, unknown>;
if (
typeof obj.tb === "string" &&
(typeof obj.id === "string" || typeof obj.id === "number")
) {
return `${obj.tb}:${String(obj.id)}`;
}
return value;
}
/**
* Abstract repository scaffold shared by `BankRepository` and
* `BankAccountRepository`. Owns the SurrealDB adapter, the table
* name, and the field-whitelist guard that prevents SQL-injection
* through generated `UPDATE` statements.
*/
export abstract class BaseRepository<TRecord extends Record<string, unknown>> {
protected readonly db: DatabaseAdapter;
protected readonly tableName: string;
public constructor(db: DatabaseAdapter, tableName: string) {
this.db = db;
this.tableName = tableName;
}
/** Child classes return the set of fields permitted in `UPDATE`. */
protected abstract getAllowedFields(): readonly string[];
/**
* Throw `BankingError(INVALID_FIELD_NAME)` when `fieldName` is not
* in the child class's whitelist. Used to gate every dynamically
* constructed `UPDATE` field.
*/
protected validateFieldName(fieldName: string): void {
if (!this.getAllowedFields().includes(fieldName)) {
throw new BankingError(
BANKING_CODES.INVALID_FIELD_NAME,
`Invalid field name "${fieldName}" for table ${this.tableName}. Allowed: ${this.getAllowedFields().join(", ")}`,
{ context: { fieldName, table: this.tableName } }
);
}
}
/** Run a query, wrap any thrown error in `BANKING.DATABASE_ERROR`. */
protected async runQuery<T = Record<string, unknown>>(
sql: string,
params?: readonly unknown[]
): Promise<T[]> {
try {
return await this.db.query<T>(sql, params);
} catch (cause) {
throw new BankingError(
BANKING_CODES.DATABASE_ERROR,
`SurrealDB query failed: ${cause instanceof Error ? cause.message : String(cause)}`,
{
cause: cause instanceof Error ? cause : undefined,
context: { sql, table: this.tableName },
}
);
}
}
/** Get by id (returns null on miss). */
public async getById(id: string): Promise<TRecord | null> {
const cleanId = extractId(id);
try {
const row = await this.db.select<TRecord>(this.tableName, cleanId);
return row === null ? null : flattenRecordIds(row);
} catch (cause) {
throw new BankingError(
BANKING_CODES.DATABASE_ERROR,
`Failed to load ${this.tableName}:${cleanId}: ${cause instanceof Error ? cause.message : String(cause)}`,
{
cause: cause instanceof Error ? cause : undefined,
context: { table: this.tableName, id: cleanId },
}
);
}
}
/** Get all rows, optionally limited / offset. */
public async getAll(limit?: number, offset?: number): Promise<TRecord[]> {
if (limit === undefined && offset === undefined) {
try {
const rows = await this.db.selectAll<TRecord>(this.tableName);
return rows.map((r) => flattenRecordIds(r));
} catch (cause) {
throw new BankingError(
BANKING_CODES.DATABASE_ERROR,
`Failed to list ${this.tableName}: ${cause instanceof Error ? cause.message : String(cause)}`,
{
cause: cause instanceof Error ? cause : undefined,
context: { table: this.tableName },
}
);
}
}
const lim = Math.max(0, Math.floor(limit ?? 100));
const off = Math.max(0, Math.floor(offset ?? 0));
const rows = await this.runQuery<TRecord>(
`SELECT * FROM ${this.tableName} LIMIT ${lim} START ${off}`
);
return rows.map((r) => flattenRecordIds(r));
}
/** Count all rows. */
public async count(): Promise<number> {
const rows = await this.runQuery<{ count?: number }>(
`SELECT count() AS count FROM ${this.tableName} GROUP ALL`
);
return Number(rows[0]?.count ?? 0);
}
/** Existence check by id. */
public async exists(id: string): Promise<boolean> {
const row = await this.getById(id);
return row !== null;
}
/** Soft-style update: build `UPDATE table:id SET k = $pN` from data. */
public async update(
id: string,
data: Partial<Record<string, unknown>>
): Promise<TRecord | null> {
const cleanId = extractId(id);
const fields: string[] = [];
const params: unknown[] = [];
for (const [key, value] of Object.entries(data)) {
if (key === "updated_at" || value === undefined) continue;
this.validateFieldName(key);
fields.push(`${key} = $p${params.length}`);
params.push(value);
}
if (fields.length === 0) {
// Nothing to update — return current.
return this.getById(id);
}
fields.push("updated_at = time::now()");
const sql = `UPDATE ${this.tableName}:${cleanId} SET ${fields.join(", ")}`;
const rows = await this.runQuery<TRecord>(sql, params);
if (rows.length === 0) return null;
return flattenRecordIds(rows[0] as TRecord);
}
/** Delete by id, returns `true` when delete succeeded. */
public async delete(id: string): Promise<boolean> {
const cleanId = extractId(id);
try {
await this.db.delete(this.tableName, cleanId);
return true;
} catch (cause) {
throw new BankingError(
BANKING_CODES.DATABASE_ERROR,
`Failed to delete ${this.tableName}:${cleanId}: ${cause instanceof Error ? cause.message : String(cause)}`,
{
cause: cause instanceof Error ? cause : undefined,
context: { table: this.tableName, id: cleanId },
}
);
}
}
/**
* Find rows where a whitelisted `field` equals `value`. The field
* name is validated against the whitelist; the value is parameterised.
*/
public async findBy(field: string, value: unknown): Promise<TRecord[]> {
this.validateFieldName(field);
const rows = await this.runQuery<TRecord>(
`SELECT * FROM ${this.tableName} WHERE ${field} = $p0`,
[value]
);
return rows.map((r) => flattenRecordIds(r));
}
/** Find one record by field value (the first row, or null). */
public async findOneBy(
field: string,
value: unknown
): Promise<TRecord | null> {
const rows = await this.findBy(field, value);
return rows.length === 0 ? null : rows[0];
}
}

View File

@@ -0,0 +1,122 @@
# Banking routes
## Purpose
This module builds the OpenAPIHono sub-app exposed by the banking
plugin. It takes a `BankingRoutesDeps` containing the
`BankingService` and an optional typed `emitEvent` callback, and
returns a Hono app that mounts `/banks` and `/accounts` route trees.
Every route consumes the Zod schemas from `schemas/schemas.ts` so the
generated OpenAPI document is correct, every error path maps a
`BankingError` to a precise HTTP status, and every state-changing
route fires a typed event through `emitEvent` when supplied.
## Features
- 14 HTTP routes total: 7 on `/banks` (list, create, by-id,
by-sort-code, search, update, soft-delete) and 7 on `/accounts`
(list, create, by-id, by-number, by-user, get-owner, update,
delete, status, transfer, add-funds, subtract-funds — list above
combines the count).
- Uniform error mapping: a single `errorBody(err)` helper maps every
thrown `BankingError` to the canonical status; non-`BankingError`
throws become `500`.
- Typed events emitted on every state transition; the `emitEvent`
callback's signature is constrained by the `BankingEventMap` union
so there are no casts at emit sites.
- Discriminated `RepositoryResponse` outcomes from `transferFunds`
and `subtractFunds` are flattened to `(status, body)` pairs without
exception-driven control flow.
## Usage: Basic
```ts
import { createBankingRoutes, BankingService } from "@suaveplan/api-plugin-banking";
const service = new BankingService(adapter, config);
const app = createBankingRoutes({ service });
host.route("/banking", app);
```
## Usage: Advanced
Wire the event emitter to a typed event bus so audit, projection, and
notification plugins observe every transition:
```ts
import { createBankingRoutes } from "@suaveplan/api-plugin-banking";
import type { BankingEventMap } from "@suaveplan/api-plugin-banking";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<BankingEventMap>();
bus.on("banking:transfer:succeeded", (e) => audit.log(e));
const app = createBankingRoutes({
service,
emitEvent: (type, payload) => bus.emit(type, payload),
});
```
The OpenAPI spec is generated by `@hono/zod-openapi` from each route's
`request` / `responses` Zod schemas — call `OpenAPIHono.doc("/openapi.json", { ... })` on the parent app to expose it.
## API Reference
### `interface BankingRoutesDeps`
- `service: BankingService` — the coordinator; routes resolve every
repository call through it so policy and event emission stay in
one place.
- `emitEvent?: <K extends keyof BankingEventMap>(type: K, payload) => void` —
optional typed event emitter; if absent, every state transition
silently proceeds without fan-out.
### `createBankingRoutes(deps): OpenAPIHono`
Returns the sub-app. Mount it at any prefix; the route paths
themselves are relative.
### Route catalogue
`GET /banks` — list (filterable by `status`, `site_id`, paginated).
`POST /banks` — create. Emits `banking:bank:created`.
`GET /banks/{id}` — by id (404 on miss).
`PATCH /banks/{id}` — update. Emits `banking:bank:updated`.
`DELETE /banks/{id}` — soft-delete. Emits `banking:bank:soft-deleted`.
`GET /banks/sort-code/{sortCode}` — by `XX-XX-XX` sort code.
`GET /banks/search/{query}` — case-insensitive name search.
`GET /accounts` — list (filterable by `user_id`, `bank_id`, `status`).
`POST /accounts` — create. Emits `banking:account:created`.
`GET /accounts/{id}` — by id.
`PUT /accounts/{id}` — update. Emits `banking:account:updated`.
`DELETE /accounts/{id}` — delete. Emits `banking:account:deleted`.
`PATCH /accounts/{id}/status` — status FSM. Emits
`banking:account:status-changed`.
`POST /accounts/transfer` — fund transfer. Emits
`banking:transfer:succeeded` or `banking:transfer:failed`.
`POST /accounts/{id}/add-funds` — add funds.
`POST /accounts/{id}/subtract-funds` — subtract funds.
`GET /accounts/by-number/{accountNumber}` — by numeric account-number.
`GET /accounts/user/{userId}` — list a user's accounts.
`GET /accounts/{id}/owner` — owner user-id lookup.
## Implementation Notes
Routes that may produce both 200/204 success and an error status code
return `c.json(body, statusNumber)` rather than relying on the Hono
default — this lets the `errorBody` helper map every
`BankingError.code` to a precise status without re-running the
narrowing logic in each route handler.
`emitEvent` is invoked **after** the underlying repository write
succeeds. Failure-mode events (`banking:transfer:failed`) are
emitted on the negative branch so projections can record the attempt
even when no balance changed. This matches the upstream package's
contract that "every transfer attempt is observable".
The route layer does NOT re-parse request bodies with `safeParse` —
`@hono/zod-openapi` already validates via `c.req.valid("json")` before
the handler runs, and any validation failure short-circuits with a
400 before the handler executes. Catching at the handler level only
sees thrown `BankingError`s and unexpected database failures.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
# Plugin options schema
## Purpose
This module owns the Zod schema for `createBankingPlugin(options?)`
factory input. All seven public configuration knobs are defaulted at
the boundary so the schema's `z.output` type is the canonical resolved
config shape the plugin descriptor and runtime carry from then on.
## Features
- Seven options with sensible defaults (`enableDefaultBank`,
`defaultBankName`, `defaultSortCode`, `maxAccountsPerUser`,
`enableTransfers`, `enableOverdraft`, `routePrefix`).
- Zod `safeParse` at the factory boundary turns invalid input into
`BankingError(BANKING.INVALID_OPTIONS)` with the `ZodError` as
`cause`.
- `BankingPluginOptionsInput` (`z.input`) and
`BankingPluginOptionsResolved` / `BankingPluginConfig` (`z.output`)
exports so call sites can be precise about pre- vs post-parse shape.
- `routePrefix` is regex-checked: must start with `/`.
## Usage: Basic
```ts
import { createBankingPlugin } from "@suaveplan/api-plugin-banking";
const plugin = createBankingPlugin();
// plugin.defaults equals the fully-resolved default config
```
## Usage: Advanced
Override specific knobs while letting Zod fill the rest:
```ts
import { createBankingPlugin } from "@suaveplan/api-plugin-banking";
const plugin = createBankingPlugin({
routePrefix: "/api/v1/banking",
maxAccountsPerUser: 25,
enableOverdraft: true,
});
// Use the resolved type when threading config through internal layers:
import type { BankingPluginConfig } from "@suaveplan/api-plugin-banking";
function buildService(cfg: BankingPluginConfig) { /* ... */ }
```
## API Reference
### `BankingPluginOptionsSchema`
`z.ZodObject` carrying every option. `.parse` returns
`BankingPluginOptionsResolved`; `.safeParse` is used by the factory so
validation failure becomes a typed `BankingError`, not a thrown
`ZodError`.
### `BankingPluginOptionsInput`
`z.input<typeof BankingPluginOptionsSchema>` — every field optional
because Zod fills defaults at parse time.
### `BankingPluginOptionsResolved`
`z.output<typeof BankingPluginOptionsSchema>` — every field required
(post-default). The plugin descriptor stores this shape.
### `BankingPluginConfig`
Alias for `BankingPluginOptionsResolved`. The repository, service, and
route layers consume this exact type so flipping the descriptor's
config type later does not ripple through call sites.
## Implementation Notes
Defaults are encoded **inside** the schema (not as a separate
`DEFAULTS` constant) so `z.output` carries them as required fields —
the rest of the package then only handles fully-resolved config and
never has to guard against `undefined`. This is the same pattern used
by `@suaveplan/api-plugin-database` and `@suaveplan/api-plugin-search`.
`enableTransfers: false` causes the transfer route to short-circuit
with HTTP 400 before reaching the service layer. The
`enableOverdraft` flag lives here rather than per-account so the
service can be configured without re-writing every account record.
`routePrefix` is regex-checked rather than defaulted to `"/banking"`
silently because hosts mounting multiple plugins frequently need
distinct prefixes (e.g. `/api/v1/banking` and `/api/v2/banking`); a
typo-free check fails loud rather than mounting at the wrong place.

View File

@@ -0,0 +1,47 @@
import z from "zod";
/**
* Factory-time options accepted by `createBankingPlugin(options)`.
*
* - `enableDefaultBank` — when `true` (default), `onReady` creates a
* default bank record named `defaultBankName` with sort code
* `defaultSortCode` if no bank with that name exists.
* - `defaultBankName` — name of the bank ensured by the bootstrap.
* Default `"Test Bank"`.
* - `defaultSortCode` — integer sort code applied to the bootstrap
* bank. Default `123456`.
* - `maxAccountsPerUser` — hard ceiling enforced before `createAccount`
* inserts. Reaching it raises
* `BankingError(BANKING.ACCOUNT_LIMIT_REACHED)`. Default `10`.
* - `enableTransfers` — when `false`, the `transferFunds` route refuses
* with `BANKING.INVALID_OPTIONS`. Default `true`.
* - `enableOverdraft` — when `false`, the service enforces
* `balance - amount >= 0` regardless of `account.overdraft`. When
* `true`, the threshold is `-(account.overdraft)`. Default `false`.
* - `routePrefix` — mount point for the plugin's Hono sub-app. Default
* `"/banking"`.
*/
export const BankingPluginOptionsSchema = z.object({
enableDefaultBank: z.boolean().default(true),
defaultBankName: z.string().min(1).max(200).default("Test Bank"),
defaultSortCode: z.number().int().positive().default(123456),
maxAccountsPerUser: z.number().int().positive().default(10),
enableTransfers: z.boolean().default(true),
enableOverdraft: z.boolean().default(false),
routePrefix: z
.string()
.min(1)
.regex(/^\//, "routePrefix must start with /")
.default("/banking"),
});
export type BankingPluginOptionsInput = z.input<
typeof BankingPluginOptionsSchema
>;
export type BankingPluginOptionsResolved = z.output<
typeof BankingPluginOptionsSchema
>;
/** Public alias for the parsed config carried by the plugin descriptor. */
export type BankingPluginConfig = BankingPluginOptionsResolved;

View File

@@ -0,0 +1,124 @@
# Banking Zod schemas
## Purpose
This module owns every Zod schema for the public boundary of
`@suaveplan/api-plugin-banking`: bank records, bank-account records,
the request bodies for create / update / status / transfer / fund-op
routes, and the literal-union status/type enums. The schemas are the
single source of truth for both runtime validation at the route
boundary and the TypeScript types consumed by the service and
repository layers.
## Features
- 11 Zod schemas covering the full public surface (`BankSchema`,
`CreateBankSchema`, `UpdateBankSchema`, `BankAccountSchema`,
`CreateBankAccountSchema`, `UpdateBankAccountSchema`,
`TransferFundsSchema`, `FundsOperationSchema`,
`UpdateAccountStatusSchema`, plus `SortCodeSchema` and the three
literal-union enums).
- Default values applied via `.default(...)` so omitted fields land at
their canonical state at parse time (e.g. `status` defaults to `4`
active, `balance` defaults to `0`).
- Numeric guards: `account_number` and `sort_code` are positive
integers; `amount` on every fund movement is `z.number().positive()`
so zero-/negative-amount requests reject at the boundary before
reaching the service.
## Usage: Basic
```ts
import { CreateBankSchema } from "@suaveplan/api-plugin-banking";
const parsed = CreateBankSchema.parse({
name: "Bank of Genesis",
sort_code: 123456,
});
```
## Usage: Advanced
The route layer wires each schema to `@hono/zod-openapi` so the OpenAPI
document is generated from the Zod types directly:
```ts
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import {
CreateBankAccountSchema,
BankAccountSchema,
TransferFundsSchema,
} from "@suaveplan/api-plugin-banking";
const createAccountRoute = createRoute({
method: "post",
path: "/",
request: {
body: {
content: {
"application/json": { schema: CreateBankAccountSchema },
},
},
},
responses: {
201: {
description: "Created",
content: {
"application/json": { schema: BankAccountSchema },
},
},
},
});
```
For typed service-layer parsing (without re-parsing in routes):
```ts
import { TransferFundsSchema, type TransferFunds } from "@suaveplan/api-plugin-banking";
const cmd: TransferFunds = TransferFundsSchema.parse(raw);
await service.transfer(cmd);
```
## API Reference
- `BankStatusSchema` / `BankAccountStatusSchema` / `BankAccountTypeSchema`
— literal unions over the integer status / type codes.
- `BankSchema` / `BankAccountSchema` — full normalised record shape
returned by repositories (RecordIds flattened to `table:id` strings).
- `CreateBankSchema` / `CreateBankAccountSchema` — required + optional
input fields, with `status`/`balance`/`overdraft` defaulted.
- `UpdateBankSchema` / `UpdateBankAccountSchema` — `.partial()` over a
whitelisted subset of writable fields. Any field outside the
whitelist is rejected by the repository's `validateFieldName` guard
rather than silently dropped.
- `TransferFundsSchema`, `FundsOperationSchema`,
`UpdateAccountStatusSchema` — fund-movement and status-change
request bodies.
- `SortCodeSchema` — `XX-XX-XX` UK-style sort-code string used by
`GET /banks/sort-code/:sortCode`.
Every schema exports a matching `z.infer<>` alias under the same name
prefixed with the bare type (e.g. `Bank`, `CreateBank`,
`TransferFunds`).
## Implementation Notes
Banking schemas are deliberately **local** to this package rather than
re-exported from `@suaveplan/api-schemas`: the upstream monorepo kept
them in the universal schemas package, but the genesis convention
(rule 4 in `CLAUDE.md`) is "NEVER re-export external deps". Owning
them here also lets the package evolve the schemas (e.g. multi-currency
amount, ledger entries) without forcing a schemas-package version bump
on every consumer.
The `RecordIdSchema` and `TimestampSchema` placeholders are kept
intentionally narrow (non-empty strings) because the SurrealDB layer
normalises both formats before they cross this boundary. Stricter
validation would reject legitimate records.
`balance` is `z.number().default(0)`, not `z.number().nonnegative()` —
the overdraft mechanism intentionally allows negative balances up to
the per-account `overdraft` ceiling. `INSUFFICIENT_FUNDS` is enforced
in the service layer, not at the schema boundary, because the threshold
depends on `account.overdraft` which is not visible to the schema.

View File

@@ -0,0 +1,141 @@
import z from "zod";
/**
* Bank status enum.
*
* - `1` — Closed / inactive (soft-deleted)
* - `2` — Frozen / suspended
* - `3` — Pending review
* - `4` — Active
*/
export const BankStatusSchema = z.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
]);
export type BankStatus = z.infer<typeof BankStatusSchema>;
/**
* Bank-account status enum (same scheme as bank).
*/
export const BankAccountStatusSchema = z.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
]);
export type BankAccountStatus = z.infer<typeof BankAccountStatusSchema>;
/**
* Bank-account type discriminant.
*
* - `1` — Current / checking
* - `2` — Savings
*/
export const BankAccountTypeSchema = z.union([z.literal(1), z.literal(2)]);
export type BankAccountType = z.infer<typeof BankAccountTypeSchema>;
const RecordIdSchema = z.string().min(1);
const TimestampSchema = z.string().min(1);
/**
* Bank institution record (post-DB normalisation: `id`/`site_id`
* RecordIds flattened to `table:id` strings).
*/
export const BankSchema = z.object({
id: RecordIdSchema,
name: z.string().min(1).max(200),
sort_code: z.number().int().positive().optional(),
site_id: RecordIdSchema.optional().nullable(),
status: BankStatusSchema.default(4),
created_at: TimestampSchema,
updated_at: TimestampSchema,
});
export type Bank = z.infer<typeof BankSchema>;
/** Input shape for `createBank`. */
export const CreateBankSchema = z.object({
name: z.string().min(1).max(200),
sort_code: z.number().int().positive().optional(),
site_id: z.string().min(1).optional(),
status: BankStatusSchema.optional(),
});
export type CreateBank = z.infer<typeof CreateBankSchema>;
/** Input shape for `updateBank`. */
export const UpdateBankSchema = z
.object({
name: z.string().min(1).max(200),
sort_code: z.number().int().positive(),
status: BankStatusSchema,
})
.partial();
export type UpdateBank = z.infer<typeof UpdateBankSchema>;
/** Sort-code wire format: `XX-XX-XX` (UK-style 6-digit grouped). */
export const SortCodeSchema = z
.string()
.regex(/^\d{2}-\d{2}-\d{2}$/, "Sort code must be in format XX-XX-XX");
export type SortCode = z.infer<typeof SortCodeSchema>;
/**
* Bank-account record (post-DB normalisation).
*/
export const BankAccountSchema = z.object({
id: RecordIdSchema,
user_id: RecordIdSchema,
bank_id: RecordIdSchema,
account_number: z.number().int().positive(),
account_type: BankAccountTypeSchema.default(1),
balance: z.number().default(0),
overdraft: z.number().nonnegative().default(0),
status: BankAccountStatusSchema.default(4),
description: z.string().optional(),
created_at: TimestampSchema,
updated_at: TimestampSchema,
});
export type BankAccount = z.infer<typeof BankAccountSchema>;
/** Input shape for `createAccount`. */
export const CreateBankAccountSchema = z.object({
user_id: z.string().min(1),
bank_id: z.string().min(1).optional(),
account_number: z.number().int().positive(),
account_type: BankAccountTypeSchema.default(1),
balance: z.number().optional(),
overdraft: z.number().nonnegative().optional(),
status: BankAccountStatusSchema.optional(),
description: z.string().optional(),
});
export type CreateBankAccount = z.infer<typeof CreateBankAccountSchema>;
/** Input shape for `updateAccount`. */
export const UpdateBankAccountSchema = z
.object({
description: z.string(),
status: BankAccountStatusSchema,
overdraft: z.number().nonnegative(),
})
.partial();
export type UpdateBankAccount = z.infer<typeof UpdateBankAccountSchema>;
/** Body for `POST /accounts/transfer`. */
export const TransferFundsSchema = z.object({
fromAccountId: z.string().min(1),
toAccountId: z.string().min(1),
amount: z.number().positive(),
});
export type TransferFunds = z.infer<typeof TransferFundsSchema>;
/** Body for `POST /accounts/:id/{add,subtract}-funds`. */
export const FundsOperationSchema = z.object({
amount: z.number().positive(),
});
export type FundsOperation = z.infer<typeof FundsOperationSchema>;
/** Body for `PATCH /accounts/:id/status`. */
export const UpdateAccountStatusSchema = z.object({
status: BankAccountStatusSchema,
});
export type UpdateAccountStatus = z.infer<typeof UpdateAccountStatusSchema>;

View File

@@ -0,0 +1,124 @@
# Banking service
## Purpose
`BankingService` is the coordinator between the plugin's HTTP routes
and the underlying repositories. It owns the per-plugin
`BankRepository` and `BankAccountRepository` instances, applies
plugin-level policy that the repositories deliberately do not know
about (account-limit guard, transfer-disabled gate, overdraft
tolerance, default-bank bootstrap), and translates repository-level
result wrappers into the typed throw/return contract that the route
layer pattern-matches against.
## Features
- Per-user account-limit enforcement before `createAccount` inserts
(raises `BANKING.ACCOUNT_LIMIT_REACHED`).
- Transfer gate: when `enableTransfers === false`, every
`transferFunds` call short-circuits to
`BANKING.INVALID_OPTIONS` without touching the database.
- Overdraft policy: when `enableOverdraft === true`, the service
threads the account's stored `overdraft` value through to the
repository's `transferFunds` / `subtractFunds` primitive; otherwise
the floor is zero.
- Bank uniqueness: `createBank` rejects duplicate `(name, site_id)`
pairs with `BANKING.BANK_NAME_TAKEN`.
- Active-accounts guard on `softDeleteBank` — refuses to close a
bank that still has active accounts.
- Idempotent `initializeDefaultBank` for the plugin's `onReady` hook.
- `deleteTenantData` wraps repository tenant cleanup in
`BANKING.TENANT_CLEANUP_FAILED` on any underlying failure.
## Usage: Basic
```ts
import { BankingService } from "@suaveplan/api-plugin-banking";
import { BankingPluginOptionsSchema } from "@suaveplan/api-plugin-banking";
const config = BankingPluginOptionsSchema.parse({});
const service = new BankingService(adapter, config);
await service.initializeDefaultBank();
const bank = await service.createBank({ name: "Genesis Bank" });
```
## Usage: Advanced
A typical route handler resolves the service from the host service
registry and pattern-matches on the discriminated return type:
```ts
async function transferRoute(c: Context) {
const body = c.req.valid("json");
const result = await service.transferFunds(
body.fromAccountId,
body.toAccountId,
body.amount
);
if (!result.success) {
const status = result.code === "BANKING.INSUFFICIENT_FUNDS" ? 400 : 404;
return c.json({ success: false, error: result.error }, status);
}
return c.json({ success: true, data: result.data });
}
```
Tenant cleanup, called from the plugin's `onTenantDelete` lifecycle
hook:
```ts
const { banks, accounts } = await service.deleteTenantData("site:tenant-123");
logger.info({ banks, accounts }, "banking tenant data purged");
```
## API Reference
### `class BankingService`
`constructor(db: DatabaseAdapter, config: BankingPluginConfig)`
- `getBankRepository(): BankRepository`
- `getBankAccountRepository(): BankAccountRepository`
- `getConfig(): BankingPluginConfig`
- `initializeDefaultBank(): Promise<BankRecord | null>`
- `createBank(data: CreateBank): Promise<BankRecord>`
- `updateBank(id, data: UpdateBank): Promise<BankRecord>`
- `softDeleteBank(id): Promise<BankRecord>`
- `createAccount(data: CreateBankAccount): Promise<BankAccountRecord>`
- `updateAccount(id, data: UpdateBankAccount): Promise<BankAccountRecord>`
- `updateAccountStatus(id, status): Promise<BankAccountRecord>`
- `transferFunds(from, to, amount): Promise<TransferResult>`
- `addFunds(id, amount): Promise<BankAccountRecord>`
- `subtractFunds(id, amount): Promise<FundsOperationResult>`
- `deleteTenantData(tenantId): Promise<{ banks, accounts }>`
Methods that locate a missing record throw `BANKING.BANK_NOT_FOUND` or
`BANKING.ACCOUNT_NOT_FOUND`. Fund-movement primitives still return a
discriminated `RepositoryResponse` (rather than throwing) because the
"insufficient funds" outcome is an expected business case the route
layer maps to HTTP 400 alongside other validation rejects.
## Implementation Notes
The service deliberately does **not** consume the
`@suaveplan/api-plugin/service-registry` slot — it accepts the
`DatabaseAdapter` and parsed `BankingPluginConfig` as plain
constructor arguments. This keeps the unit testable in isolation
(against a real SurrealDB testcontainer, never a mock) and lets the
plugin's `onInit` resolve the adapter from `ctx.getService("db")` once
and inject it.
`enableOverdraft` is a global policy switch, not per-account: every
account's `overdraft` field is a non-negative ceiling, and the service
either reads-and-passes that value (when overdraft is enabled
plugin-wide) or hardcodes `0` (when disabled). Per-account overdraft
without a global gate would require a second cross-cutting concern
(KYC tier, credit limit) which the upstream package did not have and
this migration does not introduce.
Account-limit enforcement is read-then-write: a concurrent insert
race can race past the check. The SurQL schema's
`(user_id, account_type)` index narrows the duplicate window; full
serialisation would require row-level locking, which the upstream
package did not enforce and which a typed `db.transaction` wrapper
will handle in a follow-up change.

View File

@@ -0,0 +1,245 @@
import type { DatabaseAdapter } from "@suaveplan/db";
import { BANKING_CODES, BankingError } from "../error-codes/error-codes.js";
import { BankAccountRepository } from "../repositories/account-repository.js";
import { BankRepository } from "../repositories/bank-repository.js";
import type { BankingPluginConfig } from "../schemas/options.js";
import type {
BankAccountStatus,
CreateBank,
CreateBankAccount,
UpdateBank,
UpdateBankAccount,
} from "../schemas/schemas.js";
import type {
BankAccountRecord,
BankRecord,
FundsOperationResult,
TransferResult,
} from "../types/types.js";
/**
* Banking service — the coordinator between routes and repositories.
*
* Owns the per-plugin `BankRepository` and `BankAccountRepository`,
* applies plugin-level policy (account-limit guard, overdraft
* tolerance, transfers-disabled gate, default-bank bootstrap), and
* provides the typed public surface that the route handlers and
* external consumers depend on.
*/
export class BankingService {
private readonly bankRepo: BankRepository;
private readonly accountRepo: BankAccountRepository;
private readonly config: BankingPluginConfig;
public constructor(db: DatabaseAdapter, config: BankingPluginConfig) {
this.bankRepo = new BankRepository(db);
this.accountRepo = new BankAccountRepository(db);
this.config = config;
}
public getBankRepository(): BankRepository {
return this.bankRepo;
}
public getBankAccountRepository(): BankAccountRepository {
return this.accountRepo;
}
public getConfig(): BankingPluginConfig {
return this.config;
}
/**
* Bootstrap the configured default bank. Idempotent — safe to
* call on every host start. No-op when
* `config.enableDefaultBank === false`.
*/
public async initializeDefaultBank(): Promise<BankRecord | null> {
if (!this.config.enableDefaultBank) return null;
return this.bankRepo.getOrCreateDefaultBank(
this.config.defaultBankName,
this.config.defaultSortCode
);
}
public async createBank(data: CreateBank): Promise<BankRecord> {
if (await this.bankRepo.existsByName(data.name, data.site_id)) {
throw new BankingError(
BANKING_CODES.BANK_NAME_TAKEN,
`Bank with name "${data.name}" already exists`,
{
context: { name: data.name, siteId: data.site_id ?? null },
}
);
}
return this.bankRepo.create(data);
}
public async updateBank(id: string, data: UpdateBank): Promise<BankRecord> {
const result = await this.bankRepo.update(id, data);
if (result === null) {
throw new BankingError(
BANKING_CODES.BANK_NOT_FOUND,
`Bank ${id} not found`,
{ context: { bankId: id } }
);
}
return result;
}
public async softDeleteBank(id: string): Promise<BankRecord> {
if (await this.bankRepo.hasActiveAccounts(id)) {
throw new BankingError(
BANKING_CODES.BANK_HAS_ACTIVE_ACCOUNTS,
`Bank ${id} still has active accounts; close or transfer them first`,
{ context: { bankId: id } }
);
}
const result = await this.bankRepo.softDelete(id);
if (result === null) {
throw new BankingError(
BANKING_CODES.BANK_NOT_FOUND,
`Bank ${id} not found`,
{ context: { bankId: id } }
);
}
return result;
}
/**
* Enforce per-user account limit before insert. The check is a
* read-then-write race window — concurrent inserts can both pass
* the check; the unique index on `(user_id, account_type,
* bank_id)` rejects duplicates at the SurrealDB level.
*/
public async createAccount(
data: CreateBankAccount
): Promise<BankAccountRecord> {
const count = await this.accountRepo.countByUserId(data.user_id);
if (count >= this.config.maxAccountsPerUser) {
throw new BankingError(
BANKING_CODES.ACCOUNT_LIMIT_REACHED,
`User ${data.user_id} already has ${count} accounts (limit ${this.config.maxAccountsPerUser})`,
{
context: {
userId: data.user_id,
count,
limit: this.config.maxAccountsPerUser,
},
}
);
}
let bankId = data.bank_id;
if (bankId === undefined) {
const defaultBank = await this.bankRepo.getOrCreateDefaultBank(
this.config.defaultBankName,
this.config.defaultSortCode
);
bankId = defaultBank.id;
}
return this.accountRepo.create({ ...data, bank_id: bankId });
}
public async updateAccount(
id: string,
data: UpdateBankAccount
): Promise<BankAccountRecord> {
const result = await this.accountRepo.update(id, data);
if (result === null) {
throw new BankingError(
BANKING_CODES.ACCOUNT_NOT_FOUND,
`Bank account ${id} not found`,
{ context: { accountId: id } }
);
}
return result;
}
public async updateAccountStatus(
id: string,
status: BankAccountStatus
): Promise<BankAccountRecord> {
const result = await this.accountRepo.updateStatus(id, status);
if (result === null) {
throw new BankingError(
BANKING_CODES.ACCOUNT_NOT_FOUND,
`Bank account ${id} not found`,
{ context: { accountId: id } }
);
}
return result;
}
public async transferFunds(
fromAccountId: string,
toAccountId: string,
amount: number
): Promise<TransferResult> {
if (!this.config.enableTransfers) {
throw new BankingError(
BANKING_CODES.INVALID_OPTIONS,
"Banking plugin is configured with enableTransfers = false",
{ context: { fromAccountId, toAccountId, amount } }
);
}
let overdraft = 0;
if (this.config.enableOverdraft) {
const from = await this.accountRepo.getById(fromAccountId);
if (from !== null) overdraft = from.overdraft;
}
return this.accountRepo.transferFunds(
fromAccountId,
toAccountId,
amount,
overdraft
);
}
public async addFunds(
accountId: string,
amount: number
): Promise<BankAccountRecord> {
const result = await this.accountRepo.addFunds(accountId, amount);
if (result === null) {
throw new BankingError(
BANKING_CODES.ACCOUNT_NOT_FOUND,
`Bank account ${accountId} not found`,
{ context: { accountId } }
);
}
return result;
}
public async subtractFunds(
accountId: string,
amount: number
): Promise<FundsOperationResult> {
let overdraft = 0;
if (this.config.enableOverdraft) {
const acc = await this.accountRepo.getById(accountId);
if (acc !== null) overdraft = acc.overdraft;
}
return this.accountRepo.subtractFunds(accountId, amount, overdraft);
}
/** Tenant cleanup — invoked from the plugin's `onTenantDelete`. */
public async deleteTenantData(tenantId: string): Promise<{
readonly banks: number;
readonly accounts: number;
}> {
try {
const accounts = await this.accountRepo.deleteByTenant(tenantId);
const banks = await this.bankRepo.deleteByTenant(tenantId);
return { banks, accounts };
} catch (cause) {
throw new BankingError(
BANKING_CODES.TENANT_CLEANUP_FAILED,
`Tenant ${tenantId} cleanup failed`,
{
cause: cause instanceof Error ? cause : undefined,
context: { tenantId },
}
);
}
}
}

View File

@@ -0,0 +1,86 @@
# Service-registry augmentation
## Purpose
This module is a type-only side-effect import that augments the
canonical `ServiceRegistry` interface from
`@suaveplan/api-plugin/service-registry` to surface the slots the
banking plugin reads and writes: its own `bankingService`, the
host-supplied `app` (the `OpenAPIHono` instance routes are mounted
on), and the upstream-supplied `db` (the `DatabaseAdapter` registered
by `@suaveplan/api-plugin-database`). After the augmentation,
`ctx.getService("bankingService")` types as `BankingService | undefined`
in any consumer file that imports the banking package barrel.
## Features
- Zero-runtime side effect — the `declare module` block compiles to
nothing.
- Cumulative augmentation: re-declaring `app` and `db` does not
conflict with the host packages' own declarations; TypeScript
merges duplicate interface members.
- Picks up the augmentation automatically when consumers import
`@suaveplan/api-plugin-banking` for any reason (the package root
barrel forwards the side effect).
## Usage: Basic
```ts
import "@suaveplan/api-plugin-banking"; // type-only side effect
function handler(ctx: PluginContext) {
const service = ctx.getService("bankingService");
// service: BankingService | undefined
}
```
## Usage: Advanced
Direct subpath import for code that wants the augmentation without
the runtime package — pure-type files such as host scaffolding can
reference the slot type:
```ts
import "@suaveplan/api-plugin-banking/services/registry-augmentation";
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
declare const reg: ServiceRegistry;
reg.bankingService.transferFunds(/* ... */);
```
## API Reference
The module exports nothing at runtime. The interface it augments:
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
bankingService: BankingService;
app: OpenAPIHono;
db: DatabaseAdapter;
}
}
```
After the augmentation:
- `ctx.getService("bankingService")` → `BankingService | undefined`
- `ctx.getService("app")` → `OpenAPIHono | undefined`
- `ctx.getService("db")` → `DatabaseAdapter | undefined`
## Implementation Notes
The pattern (declare-merge the host slot **and** the plugin's own
slot inside the plugin's package) follows the genesis convention set
by `@suaveplan/api-plugin-search`. The host packages (`@suaveplan/api`
for `app`, `@suaveplan/api-plugin-database` for `db`) declare their
own slots; we re-declare them here so the banking plugin compiles
against a consumer's `tsconfig` that does not transitively import
those host modules' augmentation files.
Re-declaration is safe because TypeScript merges interface members
across `declare module` blocks. If two packages declared the same
slot with **conflicting types**, the build would fail at the
augmentation point rather than silently picking one — this gives a
clear compile-time signal if a future change ever drifts the
`bankingService` slot's type out of sync.

View File

@@ -0,0 +1,27 @@
/**
* ServiceRegistry module augmentation — declares the
* `bankingService` slot on `@suaveplan/api-plugin/service-registry`'s
* `ServiceRegistry` interface so downstream plugins typed against
* the augmented registry see `ctx.getService("bankingService")`
* resolve to `BankingService | undefined` without any cast.
*
* Re-declares the host-side `app` (`OpenAPIHono`) and `db`
* (`DatabaseAdapter`) slots cumulatively so the banking plugin's
* `onInit(ctx)` can resolve them with full typing even when the
* consumer has not explicitly imported `@suaveplan/api` or
* `@suaveplan/api-plugin-database`.
*
* Importing this module for its side effects (which the package
* root barrel does automatically) is sufficient.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { DatabaseAdapter } from "@suaveplan/db";
import type { BankingService } from "../service/service.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
bankingService: BankingService;
app: OpenAPIHono;
db: DatabaseAdapter;
}
}

View File

@@ -0,0 +1,109 @@
# Banking plugin internal types
## Purpose
This module owns the plugin-internal type aliases that route, service,
and repository layers share — record shapes, fund-movement result
wrappers, lifecycle event payloads, and the strongly-typed event-bus
topic map for the banking plugin. The schemas module is the source of
truth for the wire shape; this module re-expresses those shapes under
short alias names so the service layer does not need to import Zod.
## Features
- `BankRecord` / `BankAccountRecord` aliases over the Zod-inferred
schema types, so repositories return the same shape consumers
validate against.
- `RepositoryResponse<T>` discriminated union for fund-movement
primitives — pattern-match on `result.success` and TypeScript
narrows `data` on the success branch and `error` on the failure
branch.
- Three lifecycle event payload shapes (`BankLifecycleEvent`,
`AccountLifecycleEvent`, `TransferEvent`) carrying enough context
for audit / projection consumers without re-loading records.
- `BankingEventMap` — strongly-typed event-bus topic union covering
every emitted topic, so route/service emission sites carry no
casts.
## Usage: Basic
```ts
import type {
BankRecord,
BankAccountRecord,
TransferResult,
} from "@suaveplan/api-plugin-banking";
function describe(bank: BankRecord): string {
return `${bank.name} (${bank.sort_code ?? "no sort code"})`;
}
function handle(result: TransferResult): void {
if (result.success) {
console.log("Transferred", result.data.fromAccount.balance);
} else {
console.error("Transfer failed:", result.error);
}
}
```
## Usage: Advanced
Consume the typed event map by augmenting an external `EventBusLike` —
the banking plugin internally uses the genesis `@suaveplan/events`
`EventBus` which carries a topic map generic. Hosts wiring their own
bus type pass the map directly:
```ts
import type { BankingEventMap } from "@suaveplan/api-plugin-banking";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<BankingEventMap>();
bus.on("banking:transfer:succeeded", (e) => {
audit.record({
kind: "transfer",
from: e.fromAccountId,
to: e.toAccountId,
amount: e.amount,
ts: e.at,
});
});
```
## API Reference
### Record aliases
- `BankRecord` — alias for `z.infer<typeof BankSchema>`.
- `BankAccountRecord` — alias for `z.infer<typeof BankAccountSchema>`.
### Fund-movement result types
- `RepositoryResponse<T>` — `{ success: true; data: T } | { success: false; error: string; code?: string }`.
- `TransferResult` — `RepositoryResponse<{ fromAccount; toAccount }>`.
- `FundsOperationResult` — `RepositoryResponse<BankAccountRecord>`.
### Event payloads
- `BankLifecycleEvent` — `{ bankId; status; siteId?; at }`.
- `AccountLifecycleEvent` — `{ accountId; userId; bankId; status; accountType; at }`.
- `TransferEvent` — `{ fromAccountId; toAccountId; amount; at }`.
### Event map
`BankingEventMap` lists every event the plugin emits, with full payload
types. The `:failed` transfer variant adds a `reason: string` so
projections can summarise root causes without round-tripping to logs.
## Implementation Notes
`RepositoryResponse` is a discriminated union rather than a thrown
exception because the service layer uses these for *expected* outcomes
(insufficient funds, account not found at the SurQL transaction
boundary) where the route layer needs to map to HTTP 400 cleanly.
Genuine bugs and database errors still throw `BankingError`.
The event map is wide on purpose: every state transition is observable,
not just the major ones. Hosts pay only for topics they subscribe to —
unsubscribed topics are still emitted but allocate nothing on the
underlying `EventBus` past the listener-set lookup.

View File

@@ -0,0 +1,93 @@
import type {
Bank,
BankAccount,
BankAccountStatus,
BankAccountType,
BankStatus,
} from "../schemas/schemas.js";
/**
* Normalised bank-institution record shape produced by repositories
* after RecordId flattening. Identical to the Zod-inferred {@link Bank}
* but re-exported here so service-layer code does not import schemas.
*/
export type BankRecord = Bank;
/**
* Normalised bank-account record shape produced by repositories.
* Identical to the Zod-inferred {@link BankAccount}.
*/
export type BankAccountRecord = BankAccount;
/**
* Discriminated result wrapper for fund-movement primitives.
*
* On success: `{ success: true, data: <payload> }`.
* On failure: `{ success: false, error: string, code?: BankingErrorCode }`.
*/
export type RepositoryResponse<T> =
| { readonly success: true; readonly data: T }
| {
readonly success: false;
readonly error: string;
readonly code?: string;
};
/** Outcome of `transferFunds`. */
export type TransferResult = RepositoryResponse<{
readonly fromAccount: BankAccountRecord;
readonly toAccount: BankAccountRecord;
}>;
/** Outcome of `subtractFunds`. */
export type FundsOperationResult = RepositoryResponse<BankAccountRecord>;
/**
* Bank lifecycle event payload — emitted on every state transition
* a consumer plugin might want to observe (audit log, projection,
* downstream notification, etc.).
*/
export interface BankLifecycleEvent {
readonly bankId: string;
readonly status: BankStatus;
readonly siteId?: string | null;
readonly at: number;
}
/** Account lifecycle event payload. */
export interface AccountLifecycleEvent {
readonly accountId: string;
readonly userId: string;
readonly bankId: string;
readonly status: BankAccountStatus;
readonly accountType: BankAccountType;
readonly at: number;
}
/** Transfer event payload. */
export interface TransferEvent {
readonly fromAccountId: string;
readonly toAccountId: string;
readonly amount: number;
readonly at: number;
}
/**
* Strongly-typed event-bus topic map for the banking plugin. Routes
* and the service layer emit through this union — no `as any` casts.
*/
export interface BankingEventMap {
"banking:initialized": () => void;
"banking:ready": () => void;
"banking:bank:created": (payload: BankLifecycleEvent) => void;
"banking:bank:updated": (payload: BankLifecycleEvent) => void;
"banking:bank:soft-deleted": (payload: BankLifecycleEvent) => void;
"banking:account:created": (payload: AccountLifecycleEvent) => void;
"banking:account:updated": (payload: AccountLifecycleEvent) => void;
"banking:account:deleted": (payload: AccountLifecycleEvent) => void;
"banking:account:status-changed": (payload: AccountLifecycleEvent) => void;
"banking:transfer:succeeded": (payload: TransferEvent) => void;
"banking:transfer:failed": (
payload: TransferEvent & { readonly reason: string }
) => void;
}

View File

@@ -0,0 +1,345 @@
DEFINE TABLE OVERWRITE bank SCHEMAFULL;
DEFINE FIELD OVERWRITE site_id ON bank TYPE option<record<site>>;
DEFINE FIELD OVERWRITE name ON bank TYPE string;
DEFINE FIELD OVERWRITE sort_code ON bank TYPE int ASSERT $value > 0;
DEFINE FIELD OVERWRITE status ON bank TYPE int ASSERT $value IN [$BANK_CLOSED, $BANK_FROZEN, $BANK_ACTIVE] DEFAULT $BANK_ACTIVE;
DEFINE PARAM OVERWRITE $BANK_CLOSED VALUE 1;
DEFINE PARAM OVERWRITE $BANK_FROZEN VALUE 2;
DEFINE PARAM OVERWRITE $BANK_ACTIVE VALUE 4;
DEFINE FIELD OVERWRITE created_at ON TABLE bank TYPE datetime
DEFAULT time::now() VALUE $before OR time::now();
DEFINE FIELD OVERWRITE updated_at ON TABLE bank TYPE datetime
DEFAULT time::now() VALUE time::now();
DEFINE INDEX OVERWRITE bank_sort_code_idx ON bank FIELDS sort_code;
DEFINE TABLE OVERWRITE bank_account SCHEMAFULL;
DEFINE FIELD OVERWRITE user_id ON bank_account TYPE record<user>;
DEFINE FIELD OVERWRITE bank_id ON bank_account TYPE record<bank>;
DEFINE FIELD OVERWRITE description ON bank_account TYPE string DEFAULT '';
DEFINE FIELD OVERWRITE account_type ON bank_account TYPE int ASSERT $value IN [$BANK_ACCOUNT_CURRENT, $BANK_ACCOUNT_SAVINGS];
DEFINE FIELD OVERWRITE account_number ON bank_account TYPE int;
DEFINE FIELD OVERWRITE balance ON bank_account TYPE float DEFAULT 0.0;
DEFINE FIELD OVERWRITE overdraft ON bank_account TYPE float DEFAULT 0.0;
DEFINE FIELD OVERWRITE status ON bank_account TYPE int ASSERT $value IN [$BANK_ACCOUNT_CLOSED, $BANK_ACCOUNT_FROZEN, $BANK_ACCOUNT_ACTIVE] DEFAULT $BANK_ACCOUNT_ACTIVE;
DEFINE INDEX OVERWRITE user_account ON bank_account FIELDS user_id, account_type;
DEFINE TABLE OVERWRITE user_account TYPE RELATION IN user OUT bank_account SCHEMAFULL;
DEFINE INDEX OVERWRITE user_account_idx ON user_account FIELDS in, out UNIQUE;
DEFINE FIELD OVERWRITE created_at ON TABLE bank_account TYPE datetime
DEFAULT time::now() VALUE $before OR time::now();
DEFINE FIELD OVERWRITE updated_at ON TABLE bank_account TYPE datetime
DEFAULT time::now() VALUE time::now();
DEFINE PARAM OVERWRITE $BANK_ACCOUNT_CURRENT VALUE 1;
DEFINE PARAM OVERWRITE $BANK_ACCOUNT_SAVINGS VALUE 2;
DEFINE PARAM OVERWRITE $BANK_ACCOUNT_CLOSED VALUE 1;
DEFINE PARAM OVERWRITE $BANK_ACCOUNT_FROZEN VALUE 2;
DEFINE PARAM OVERWRITE $BANK_ACCOUNT_ACTIVE VALUE 4;
DEFINE FUNCTION OVERWRITE fn::bank::by_sort_code($sortCode: string) -> option<object> {
RETURN (SELECT * FROM bank WHERE sort_code = fn::bank::number::from_formatted($sortCode))[0];
};
DEFINE FUNCTION OVERWRITE fn::bank::id::by_sort_code($sortCode: string | int) -> option<record<bank>> {
LET $sort_code = IF type::is_int($sortCode) { $sortCode } ELSE { fn::bank::number::from_formatted($sortCode) };
RETURN (SELECT id FROM bank WHERE sort_code = $sort_code)[0].id;
};
DEFINE FUNCTION OVERWRITE fn::bank::get::sort_code($bankId: record<bank>) -> option<string> {
RETURN (SELECT fn::bank::number::formatted(sort_code) as code FROM $bankId)[0].code;
};
DEFINE FUNCTION OVERWRITE fn::bank::get::sort_code_number($bankId: record<bank>) -> option<int> {
RETURN (SELECT sort_code FROM $bankId)[0].sort_code;
};
DEFINE FUNCTION OVERWRITE fn::bank::get::account_string($bankId: record<bank> | int, $accountId: record<bank_account> | int) -> option<string> {
LET $sort_code = IF type::is_int($bankId) { fn::bank::number::formatted($bankId) } ELSE { (SELECT fn::bank::number::formatted(sort_code) as code FROM $bankId)[0].code };
IF ($sort_code IS NONE) {
RETURN NONE;
};
LET $account = IF type::is_int($accountId) { fn::bank_account::number::formatted($accountId) } ELSE { (SELECT fn::bank_account::number::formatted(account_number) as acc FROM $accountId)[0].acc };
IF ($account IS NONE) {
RETURN NONE;
};
RETURN string::concat($sort_code , ' ' , $account);
};
DEFINE FUNCTION OVERWRITE fn::bank::create($site: option<record<site>>, $name: option<string>, $status: option<int>) -> {
success: bool,
errors: option<array<string>>,
bank: option<object>
} {
LET $result = (INSERT INTO bank (site_id, name, sort_code, status) VALUES ($site, $name ?? 'The Bank', fn::counters::inc('banks', 1), $status ?? $BANK_ACTIVE))[0];
IF ($result == NONE) {
RETURN { success: false, errors: ['Unable to create bank']};
} ELSE {
RETURN { success: true, bank: $result };
}
};
DEFINE FUNCTION OVERWRITE fn::bank::exists($name: string, $site: option<record<site>>) -> bool {
IF ($site == NONE) {
RETURN NONE != (SELECT id FROM bank WHERE string::lowercase(name) == string::lowercase(string::trim($name)))[0];
} ELSE {
RETURN NONE != (SELECT id FROM bank WHERE site_id = $site AND string::lowercase(name) == string::lowercase(string::trim($name)))[0];
};
};
DEFINE FUNCTION OVERWRITE fn::bank::by_sort_code($sortCode: string) {
RETURN (SELECT * FROM bank WHERE sort_code = fn::bank::number::from_formatted($sortCode))[0];
};
DEFINE FUNCTION OVERWRITE fn::bank::number::formatted($num: int) -> string {
LET $strNum = string::concat('000000', <string>$num);
LET $paddedNum = string::slice($strNum, -6);
RETURN string::slice($paddedNum, 0, 2) + '-' + string::slice($paddedNum, 2, 2) + '-' + string::slice($paddedNum, 4, 2);
};
DEFINE FUNCTION OVERWRITE fn::bank::number::from_formatted($num: string) -> int {
RETURN <int>string::replace($num, '-', '');
};
DEFINE FUNCTION OVERWRITE fn::bank_account::number::formatted($num: int) -> string {
LET $strNum = string::concat('00000000', <string>$num);
RETURN string::slice($strNum, -8);
};
DEFINE FUNCTION OVERWRITE fn::bank_account::number::from_string($num: string) -> int {
RETURN <int>$num;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::number::split($num: string) -> { sort_code: int, account_number: int } {
IF (!string::matches($num, '^[0-9]{2}-[0-9]{2}-[0-9]{2} [0-9]{8}$')) {
THROW 'Invalid Bank Account Number';
};
LET $sort_code = <int>string::replace(string::slice($num, 0, 8), '-', '');
LET $account_number = <int>string::slice($num, -8);
RETURN {
sort_code: $sort_code,
account_number: $account_number
};
};
DEFINE FUNCTION OVERWRITE fn::bank_account::status::set($user: record<user>, $type: int, $status: int) {
UPDATE bank_account SET status = $status WHERE user_id = $user AND account_type = $type;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::close::all($user: record<user>) {
UPDATE bank_account SET status = $BANK_ACCOUNT_CLOSED WHERE user_id = $user;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::freeze::all($user: record<user>) {
UPDATE bank_account SET status = $BANK_ACCOUNT_FROZEN WHERE user_id = $user;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::activate::all($user: record<user>) {
UPDATE bank_account SET status = $BANK_ACCOUNT_ACTIVE WHERE user_id = $user;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::close($account: string) {
LET $details = fn::bank_account::number::split($account);
LET $bankId = fn::bank::id::by_sort_code($details.sort_code);
RETURN UPDATE bank_account SET status = $BANK_ACCOUNT_CLOSED
WHERE account_number = $details.account_number
AND bank_id = $bankId;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::freeze($account: string) {
LET $details = fn::bank_account::number::split($account);
LET $bankId = fn::bank::id::by_sort_code($details.sort_code);
RETURN UPDATE bank_account SET status = $BANK_ACCOUNT_FROZEN
WHERE account_number = $details.account_number
AND bank_id = $bankId;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::activate($account: string) {
LET $details = fn::bank_account::number::split($account);
LET $bankId = fn::bank::id::by_sort_code($details.sort_code);
RETURN UPDATE bank_account SET status = $BANK_ACCOUNT_ACTIVE
WHERE account_number = $details.account_number
AND bank_id = $bankId;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::details($account: string) {
LET $details = fn::bank_account::number::split($account);
LET $bankId = fn::bank::id::by_sort_code($details.sort_code);
LET $bank_account = (
SELECT * FROM bank_account
WHERE account_number = $details.account_number
AND bank_id = $bankId
)[0];
RETURN $bank_account;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::open($bank: option<record<bank> | string>, $user: record<user>, $type: int, $balance: option<float>, $overdraft: option<float>, $status: option<int>, $description: option<string>) {
LET $bank_id = IF type::is_string($bank) { fn::bank::by_sort_code($bank).id } ELSE { $bank };
LET $bank_account_type = IF $type = 1 { 'Current Account' } ELSE IF $type = 2 { 'Savings Account' } ELSE { 'Bank Account' };
LET $acc_no = fn::counters::inc(<string>$bank_id);
BEGIN;
LET $result = (INSERT INTO bank_account (bank_id, user_id, account_number, description, account_type, balance, status, overdraft) VALUES ($bank_id, $user, $acc_no,
$description ?? $bank_account_type, $type, <float>($balance ?? 0), $status ?? $BANK_ACCOUNT_ACTIVE, <float>($overdraft ?? 0)))[0];
IF ($result == NONE) {
CANCEL;
RETURN { success: false, errors: ['Unable to create bank account']};
} ELSE {
fn::bank_account::link($user, $result.id);
COMMIT;
RETURN { success: true, bank_account: $result, bank_id: $bank_id
-- , account: fn::bank::get::account_string(fn::bank::get::sort_code_number($bank_id), $result.bank_account.account_number)
};
}
};
DEFINE FUNCTION OVERWRITE fn::bank_account::funds($account: string) -> float {
LET $details = fn::bank_account::number::split($account);
RETURN SELECT balance FROM bank_account
WHERE account_number = $details.account_number
AND bank_id.sort_code = $details.sort_code;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::float::add($first: float, $second: float) -> float {
RETURN FUNCTION ($first, $second) {
return (arguments[0] + arguments[1]);
};
};
DEFINE FUNCTION OVERWRITE fn::bank_account::float::subtract($first: float, $second: float) -> float {
RETURN FUNCTION ($first, $second) {
return (arguments[0] - arguments[1]);
};
};
DEFINE FUNCTION OVERWRITE fn::bank_account::funds::transfer($amount: float, $from_account: string, $to_account: string) {
LET $from = fn::bank_account::number::split($from_account);
LET $to = fn::bank_account::number::split($to_account);
IF $from.account_number = $to.account_number AND $from.sort_code = $to.sort_code {
RETURN {
success: false,
message: 'Cannot transfer to the same bank_account',
error: 1000
};
};
LET $from_account = (SELECT * FROM bank_account WHERE account_number = $from.account_number AND bank_id.sort_code = $from.sort_code)[0];
LET $to_account = (SELECT * FROM bank_account WHERE account_number = $to.account_number AND bank_id.sort_code = $to.sort_code)[0];
IF ($from_account.status != $BANK_ACCOUNT_ACTIVE) {
IF ($from_account.status == $BANK_ACCOUNT_CLOSED) {
RETURN {
success: false,
message: 'Account is closed',
error: 1001
};
};
IF ($from_account.status == $BANK_ACCOUNT_FROZEN) {
RETURN {
success: false,
message: 'Account is frozen',
error: 1002
};
}
};
IF ($to_account.status != $BANK_ACCOUNT_ACTIVE) {
IF ($to_account.status == $BANK_ACCOUNT_CLOSED) {
RETURN {
success: false,
message: 'Recipient bank_account is closed',
error: 1003
};
};
IF ($to_account.status == $BANK_ACCOUNT_FROZEN) {
RETURN {
success: false,
message: 'Recipient bank_account is frozen',
error: 1004
};
}
};
IF (fn::bank_account::float::add($from_account.balance , $from_account.overdraft) >= $amount) {
BEGIN;
UPDATE bank_account SET balance = fn::bank_account::float::subtract(balance, $amount) WHERE id = $from_account.id;
UPDATE bank_account SET balance = fn::bank_account::float::add(balance, $amount) WHERE id = $to_account.id;
COMMIT;
RETURN {
success: true,
message:'success'
};
} ELSE {
RETURN {
success: false,
message: 'Insufficient funds',
error: 1005
};
};
};
DEFINE FUNCTION OVERWRITE fn::bank_account::savings::apply::interest($rate: float) {
LET $chosen_rate = IF $rate = NONE { 0 } ELSE { $rate };
LET $savings_accounts = SELECT * FROM bank_account
WHERE account_type = $BANK_ACCOUNT_SAVINGS;
FOR $bank_account IN $savings_accounts {
LET $new_balance = $bank_account.balance * (1 + $chosen_rate);
UPDATE $bank_account SET balance = $new_balance;
};
};
DEFINE FUNCTION OVERWRITE fn::bank_account::apply::charges($rate: float, $fee: float, $type: int) {
LET $chosen_rate = IF $rate = NONE { 0 } ELSE { $rate };
LET $chosen_fee = IF $fee = NONE { 0 } ELSE { $fee };
LET $chosen_type = IF $type = NONE { $BANK_ACCOUNT_CURRENT } ELSE { $BANK_ACCOUNT_SAVINGS };
LET $bank_accounts = SELECT * FROM bank_account
WHERE account_type = $chosen_type;
FOR $bank_account IN $bank_accounts {
LET $new_balance = ($bank_account.balance * (1 + $chosen_rate)) - $chosen_fee;
UPDATE $bank_account SET balance = $new_balance;
};
};
-- Graph relation functions for accounts
DEFINE FUNCTION OVERWRITE fn::bank_account::link($user: record<user>, $bank_account: record<bank_account>) {
LET $result = (RELATE $user->user_account->$bank_account)[0];
RETURN $result;
};
DEFINE FUNCTION OVERWRITE fn::bank_account::get_by_user($user: record<user>) -> array {
LET $bank_accounts = (
SELECT ->user_account->bank_account.* as accounts
FROM $user
)[0].accounts;
RETURN IF $bank_accounts == NONE { [] } ELSE { $bank_accounts };
};
DEFINE FUNCTION OVERWRITE fn::bank_account::get_user($bank_account: record<bank_account>) -> option<object> {
LET $results = (
SELECT <-user_account<-user.* as user
FROM $bank_account
);
LET $user = $results[0].user;
RETURN IF array::len($user) == 0 { NONE } ELSE { $user[0] };
};
DEFINE FUNCTION OVERWRITE fn::bank_account::unlink($user: record<user>, $bank_account: record<bank_account>) {
LET $before = SELECT * FROM user_account WHERE in = $user AND out = $bank_account;
IF array::len($before) > 0 {
DELETE user_account WHERE in = $user AND out = $bank_account;
LET $after = SELECT * FROM user_account WHERE in = $user AND out = $bank_account;
RETURN array::len($after) == 0;
};
RETURN true;
};

View File

@@ -0,0 +1,315 @@
/**
* In-memory `DatabaseAdapter` test double — NOT a mock.
*
* Satisfies the `@suaveplan/db` `DatabaseAdapter` interface with a real
* implementation backed by `Map<table, Map<id, row>>`. Used by unit
* tests for fast feedback. Integration tests exercise the same
* repository / service code against a real SurrealDB testcontainer.
*
* The SurQL subset supported:
*
* - `SELECT * FROM <table>` / with `WHERE <field> = $p0` / `LIMIT n START m` / `ORDER BY created_at DESC`
* - `SELECT count() AS count FROM <table> [WHERE ...] GROUP ALL`
* - `UPDATE <table>:<id> SET k = $pN, ..., updated_at = time::now()` returning the updated row
* - `DELETE <table> WHERE ... RETURN BEFORE`
*
* This is enough surface to cover every code path in
* `BankRepository`, `BankAccountRepository`, and the
* `BankingService` policy layer. Routes are tested by mounting them
* against this adapter via the real `BankingService`.
*/
import type {
DatabaseAdapter,
DbOperationOptions,
MigrationConfig,
MigrationResult,
} from "@suaveplan/db";
import type { DatabaseLike } from "@suaveplan/types";
type Row = Record<string, unknown> & { id: string };
interface SelectFilter {
field?: string;
paramIndex?: number;
extraField?: string;
extraParamIndex?: number;
extraOp?: "=" | "!=";
limit?: number;
offset?: number;
orderByCreatedDesc?: boolean;
containsParamIndex?: number;
countOnly?: boolean;
}
const NOW = () => new Date().toISOString();
export class FakeDatabaseAdapter implements DatabaseAdapter {
public readonly tables = new Map<string, Map<string, Row>>();
public readonly callLog: string[] = [];
private seq = 0;
public failNextQuery = false;
public get isConnected(): boolean {
return true;
}
public async connect(): Promise<void> {}
public async disconnect(): Promise<void> {}
public async close(): Promise<void> {}
public async migrate(
_config?: MigrationConfig,
_opts?: DbOperationOptions
): Promise<MigrationResult> {
return { applied: 0, migrations: [] };
}
public async transaction<T>(
fn: (tx: DatabaseLike) => Promise<T>,
_opts?: DbOperationOptions
): Promise<T> {
return fn(this as unknown as DatabaseLike);
}
public async execute(
sql: string,
_params?: readonly unknown[],
_opts?: DbOperationOptions
): Promise<{ rowsAffected: number }> {
this.callLog.push(`execute:${sql.trim().split(/\s+/, 2).join(" ")}`);
return { rowsAffected: 0 };
}
public async query<T = Record<string, unknown>>(
sql: string,
params?: readonly unknown[],
_opts?: DbOperationOptions
): Promise<T[]> {
this.callLog.push(`query:${sql.trim().slice(0, 80)}`);
if (this.failNextQuery) {
this.failNextQuery = false;
throw new Error("Forced query failure");
}
return this.parseAndRun<T>(sql, params ?? []);
}
public async create<T extends Record<string, unknown>>(
table: string,
data: T,
_opts?: DbOperationOptions
): Promise<T & { id: string }> {
this.callLog.push(`create:${table}`);
const map = this.ensureTable(table);
const id = `${table}:${String(++this.seq)}`;
const row: Row = {
...data,
id,
created_at: NOW(),
updated_at: NOW(),
};
map.set(id, row);
return row as T & { id: string };
}
public async select<T extends Record<string, unknown>>(
table: string,
id: string,
_opts?: DbOperationOptions
): Promise<T | null> {
this.callLog.push(`select:${table}:${id}`);
const map = this.tables.get(table);
if (map === undefined) return null;
const fullId = id.includes(":") ? id : `${table}:${id}`;
const row = map.get(fullId);
return row === undefined ? null : (row as unknown as T);
}
public async update<T extends Record<string, unknown>>(
table: string,
id: string,
data: Partial<T>,
_opts?: DbOperationOptions
): Promise<T | null> {
const fullId = id.includes(":") ? id : `${table}:${id}`;
const map = this.tables.get(table);
if (map === undefined) return null;
const existing = map.get(fullId);
if (existing === undefined) return null;
const updated = { ...existing, ...data, updated_at: NOW() };
map.set(fullId, updated as Row);
return updated as unknown as T;
}
public async delete(
table: string,
id: string,
_opts?: DbOperationOptions
): Promise<void> {
const fullId = id.includes(":") ? id : `${table}:${id}`;
const map = this.tables.get(table);
if (map !== undefined) map.delete(fullId);
}
public async selectAll<T extends Record<string, unknown>>(
table: string,
_opts?: DbOperationOptions
): Promise<T[]> {
const map = this.tables.get(table);
if (map === undefined) return [];
return Array.from(map.values()) as unknown as T[];
}
private ensureTable(table: string): Map<string, Row> {
let map = this.tables.get(table);
if (map === undefined) {
map = new Map();
this.tables.set(table, map);
}
return map;
}
private parseAndRun<T>(sql: string, params: readonly unknown[]): T[] {
const trimmed = sql.replaceAll(/\s+/g, " ").trim();
// UPDATE table:id SET ... — returns updated row
const upd = trimmed.match(
/^UPDATE\s+([a-z_]+):([^\s]+)\s+SET\s+(.+)$/i
);
if (upd !== null) {
const table = upd[1];
const id = `${table}:${upd[2]}`;
const setClause = upd[3];
const map = this.tables.get(table);
if (map === undefined) return [];
const row = map.get(id);
if (row === undefined) return [];
const updated: Row = { ...row };
for (const part of setClause.split(",")) {
const m = part
.trim()
.match(
/^([a-z_]+)\s*=\s*(?:\$p(\d+)|([a-z_]+)\s*([+-])\s*\$p(\d+)|time::now\(\))/i
);
if (m === null) continue;
const field = m[1];
if (m[2] !== undefined) {
updated[field] = params[Number(m[2])];
} else if (m[3] !== undefined) {
const cur = Number(updated[m[3]] ?? 0);
const delta = Number(params[Number(m[5])]);
updated[field] = m[4] === "+" ? cur + delta : cur - delta;
} else {
updated[field] = NOW();
}
}
updated.updated_at = NOW();
map.set(id, updated);
return [updated as unknown as T];
}
// DELETE table WHERE ... RETURN BEFORE
const del = trimmed.match(
/^DELETE\s+([a-z_]+)\s+WHERE\s+(.+?)\s+RETURN\s+BEFORE$/i
);
if (del !== null) {
const table = del[1];
const map = this.tables.get(table);
if (map === undefined) return [];
const removed: Row[] = [];
const filter = this.parseWhereExpression(del[2], params);
for (const [id, row] of Array.from(map.entries())) {
if (filter(row)) {
removed.push(row);
map.delete(id);
}
}
return removed as unknown as T[];
}
// SELECT count() AS count FROM table [WHERE ...] GROUP ALL
const cnt = trimmed.match(
/^SELECT\s+count\(\)\s+AS\s+count\s+FROM\s+([a-z_]+)(?:\s+WHERE\s+(.+?))?\s+GROUP\s+ALL$/i
);
if (cnt !== null) {
const table = cnt[1];
const map = this.tables.get(table);
if (map === undefined) return [{ count: 0 } as unknown as T];
const where = cnt[2];
if (where === undefined) {
return [{ count: map.size } as unknown as T];
}
const filter = this.parseWhereExpression(where, params);
let n = 0;
for (const row of map.values()) if (filter(row)) n++;
return [{ count: n } as unknown as T];
}
// SELECT * FROM table [WHERE ...] [ORDER BY created_at DESC] [LIMIT n START m]
const sel = trimmed.match(
/^SELECT\s+(?:id|\*)\s+FROM\s+([a-z_]+)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+created_at\s+DESC)?(?:\s+LIMIT\s+(\d+)\s+START\s+(\d+))?(?:\s+LIMIT\s+(\d+))?$/i
);
if (sel !== null) {
const table = sel[1];
const where = sel[2];
const limit = sel[3] ?? sel[5];
const start = sel[4];
const map = this.tables.get(table);
if (map === undefined) return [];
let rows = Array.from(map.values());
if (where !== undefined) {
const filter = this.parseWhereExpression(where, params);
rows = rows.filter(filter);
}
if (/ORDER\s+BY\s+created_at\s+DESC/i.test(trimmed)) {
rows = rows.sort((a, b) =>
String(b.created_at).localeCompare(String(a.created_at))
);
}
if (start !== undefined) {
rows = rows.slice(Number(start));
}
if (limit !== undefined) {
rows = rows.slice(0, Number(limit));
}
return rows as unknown as T[];
}
return [];
}
private parseWhereExpression(
expr: string,
params: readonly unknown[]
): (row: Row) => boolean {
const clauses = expr.split(/\s+AND\s+/i).map((c) => c.trim());
const predicates: ((row: Row) => boolean)[] = [];
for (const c of clauses) {
const eq = c.match(/^([a-z_]+)\s*=\s*\$p(\d+)$/i);
if (eq !== null) {
const f = eq[1];
const v = params[Number(eq[2])];
predicates.push((r) => r[f] === v);
continue;
}
const neq = c.match(/^([a-z_]+)\s*!=\s*\$p(\d+)$/i);
if (neq !== null) {
const f = neq[1];
const v = params[Number(neq[2])];
predicates.push((r) => r[f] !== v);
continue;
}
const cont = c.match(
/^string::lowercase\(\s*([a-z_]+)\s*\)\s+CONTAINS\s+string::lowercase\(\s*\$p(\d+)\s*\)$/i
);
if (cont !== null) {
const f = cont[1];
const v = String(params[Number(cont[2])] ?? "").toLowerCase();
predicates.push((r) =>
String(r[f] ?? "")
.toLowerCase()
.includes(v)
);
}
}
return (row) => predicates.every((p) => p(row));
}
}

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/api-plugin-banking.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any
* source module imports so the meter resolved on first call binds to
* it. Tests that need fresh metric state per case call
* `resetBankingInstruments()` and `flushTestMetrics()` themselves.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-banking",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 120000,
},
});