Initial Commit
This commit is contained in:
113
packages/api/api-plugin-banking/src/error-codes/error-codes.md
Normal file
113
packages/api/api-plugin-banking/src/error-codes/error-codes.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
89
packages/api/api-plugin-banking/src/index.ts
Normal file
89
packages/api/api-plugin-banking/src/index.ts
Normal 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";
|
||||
109
packages/api/api-plugin-banking/src/otel/instruments.md
Normal file
109
packages/api/api-plugin-banking/src/otel/instruments.md
Normal 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.
|
||||
82
packages/api/api-plugin-banking/src/otel/instruments.ts
Normal file
82
packages/api/api-plugin-banking/src/otel/instruments.ts
Normal 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;
|
||||
}
|
||||
116
packages/api/api-plugin-banking/src/plugin/plugin.md
Normal file
116
packages/api/api-plugin-banking/src/plugin/plugin.md
Normal 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.
|
||||
148
packages/api/api-plugin-banking/src/plugin/plugin.ts
Normal file
148
packages/api/api-plugin-banking/src/plugin/plugin.ts
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
122
packages/api/api-plugin-banking/src/routes/build-routes.md
Normal file
122
packages/api/api-plugin-banking/src/routes/build-routes.md
Normal 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.
|
||||
1070
packages/api/api-plugin-banking/src/routes/build-routes.ts
Normal file
1070
packages/api/api-plugin-banking/src/routes/build-routes.ts
Normal file
File diff suppressed because it is too large
Load Diff
92
packages/api/api-plugin-banking/src/schemas/options.md
Normal file
92
packages/api/api-plugin-banking/src/schemas/options.md
Normal 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.
|
||||
47
packages/api/api-plugin-banking/src/schemas/options.ts
Normal file
47
packages/api/api-plugin-banking/src/schemas/options.ts
Normal 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;
|
||||
124
packages/api/api-plugin-banking/src/schemas/schemas.md
Normal file
124
packages/api/api-plugin-banking/src/schemas/schemas.md
Normal 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.
|
||||
141
packages/api/api-plugin-banking/src/schemas/schemas.ts
Normal file
141
packages/api/api-plugin-banking/src/schemas/schemas.ts
Normal 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>;
|
||||
124
packages/api/api-plugin-banking/src/service/service.md
Normal file
124
packages/api/api-plugin-banking/src/service/service.md
Normal 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.
|
||||
245
packages/api/api-plugin-banking/src/service/service.ts
Normal file
245
packages/api/api-plugin-banking/src/service/service.ts
Normal 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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
109
packages/api/api-plugin-banking/src/types/types.md
Normal file
109
packages/api/api-plugin-banking/src/types/types.md
Normal 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.
|
||||
93
packages/api/api-plugin-banking/src/types/types.ts
Normal file
93
packages/api/api-plugin-banking/src/types/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user