@bevingh/ledger
v0.1.0
Published
Wallet row-lock debit/credit helpers parameterized by DB executor (FOR UPDATE + balance mutation + idempotency_key) — wallet_row_lock_engine only.
Readme
@bevingh/ledger
Phase 3 / PR-17 — extracted (last package).
wallet_row_lock_engineonly. No Prisma, no Moolre, no tax ledger.
Purpose
Wallet row-lock debit helpers parameterized by a DB executor (SELECT … FOR UPDATE + balance mutation + unique idempotency_key) — Didipay champion.
| Field | Value |
|---|---|
| surfaceShape | pure_core |
| dependsOnPackages | @bevingh/money, @bevingh/idempotency (real implementations) |
| extractionOrderHint | 7 |
| status | extracted (implementation + tests) |
Permanent boundaries (do not “resolve and remove”)
| Out of scope | Why |
|---|---|
| tax_accounting_ledger / LedgerEntry P&L | Different semantic (KD5); document-only in Phase 1 |
| loan_payment_lock (swift-cedi) | Project-specific per KD5 — permanently |
| fulfillment status flips | @bevingh/fulfillment |
| Prisma hard-coded | Caller supplies WalletLedgerExecutor |
| Moolre / any external disbursement | Product side effect after ledger commit — see below |
Public API
import { executeWalletDebit, checkSpendEligibility } from '@bevingh/ledger';
import { assertPesewas } from '@bevingh/money'; // used internally for amounts
const result = await executeWalletDebit(
{
walletId,
amount: 600, // integer pesewas — validated via @bevingh/money
idempotencyKey: key, // domain unique via @bevingh/idempotency runWithDomainUniqueKey
sourceBucket: 'primary',
},
executor, // your Prisma/SQL implementation of WalletLedgerExecutor
);
if (result.outcome === 'applied') {
// ONLY NOW: external disbursement (Moolre, etc.) — not inside the package
await initiateDisbursement(...).catch(queueRetry);
} else {
// outcome === 'replayed' — same idempotency key, no second debit
}| Export | Role |
|---|---|
| executeWalletDebit | Atomic debit orchestration |
| WalletLedgerExecutor | Injected lock / plan / debit / insert / unique-detect |
| checkSpendEligibility / computeAllocationTick | Pure Didipay policy |
| LedgerError | Domain errors |
Correct vs Didipay’s current coupling
Didipay today (do not copy into package core):
BEGIN; FOR UPDATE; debit; insert txn; COMMIT;
Moolre.initiateDisbursement(...).catch(...) // fire-and-forget, no ledger rollbackPackage contract:
result = executeWalletDebit(...) // atomic wallet + txn only
if (result.outcome === 'applied') {
// caller-owned disbursement + retry — cannot be accidentally inside executor
}Idempotency + money wiring
| Dependency | How used |
|---|---|
| @bevingh/money assertPesewas | Reject float / zero / negative spend amounts |
| @bevingh/idempotency runWithDomainUniqueKey | Fits Didipay unique idempotency_key column — findExisting → execute → unique race re-find |
Tests (adapted from Didipay scripts)
| Scenario | Source |
|---|---|
| 3× concurrent spend 600 on balance 1000 → 1 win, balance 400 | concurrency-test.ts |
| Deposit 1000, spend 300, funds reconcile | integrity-test.ts |
| Same idempotency key → no double-debit | domain unique |
| Invalid pesewas rejected | @bevingh/money |
| Disbursement failure does not undo debit | separation doc test |
npm run test -w @bevingh/ledger
npm run build -w @bevingh/ledgerChampion files (read-only)
- Didipay
ledger.service.ts,policyEngine.ts - Didipay
scripts/concurrency-test.ts,integrity-test.ts
