# 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` - `createBank(data: CreateBank): Promise` - `updateBank(id, data: UpdateBank): Promise` - `softDeleteBank(id): Promise` - `createAccount(data: CreateBankAccount): Promise` - `updateAccount(id, data: UpdateBankAccount): Promise` - `updateAccountStatus(id, status): Promise` - `transferFunds(from, to, amount): Promise` - `addFunds(id, amount): Promise` - `subtractFunds(id, amount): Promise` - `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.