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