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,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.