Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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