5.0 KiB
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
createAccountinserts (raisesBANKING.ACCOUNT_LIMIT_REACHED). - Transfer gate: when
enableTransfers === false, everytransferFundscall short-circuits toBANKING.INVALID_OPTIONSwithout touching the database. - Overdraft policy: when
enableOverdraft === true, the service threads the account's storedoverdraftvalue through to the repository'stransferFunds/subtractFundsprimitive; otherwise the floor is zero. - Bank uniqueness:
createBankrejects duplicate(name, site_id)pairs withBANKING.BANK_NAME_TAKEN. - Active-accounts guard on
softDeleteBank— refuses to close a bank that still has active accounts. - Idempotent
initializeDefaultBankfor the plugin'sonReadyhook. deleteTenantDatawraps repository tenant cleanup inBANKING.TENANT_CLEANUP_FAILEDon any underlying failure.
Usage: Basic
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:
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:
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(): BankRepositorygetBankAccountRepository(): BankAccountRepositorygetConfig(): BankingPluginConfiginitializeDefaultBank(): 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.