Files
2026-05-27 23:29:53 +01:00

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

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(): 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.