Initial Commit
This commit is contained in:
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>;
|
||||
Reference in New Issue
Block a user