factory/treasury (0.6.0)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require factory/treasury:0.6.0About this package
factory/treasury — v0.2.0
Every franc the fleet touches moves through this package. Products consume it as a
versioned dependency (never copied); it depends on factory/kernel ^0.2 for money,
audit, and idempotency.
Supports Laravel 10 · 11 · 12 on PHP 8.2+ — both ends proven by the suite.
PLAN A — pure self-funded
The founder's rule, and the only money model this package implements:
The platform never fronts money. A payout draws the beneficiary's own live balance or it is refused.
There is no Plan B in here — no credit line, no advance, no overdraft, no
"settle later". That is not a policy this code enforces at runtime; it is a shape the
code has. Four structural facts, each with a test in PlanATest.php that fails the
moment somebody widens it:
| The temptation | Why it cannot be done here |
|---|---|
| Let a merchant go slightly negative | AccountType::allowsNegative() is true for exactly one case — external, the world outside our books. Ledger::write() floors every other pot at zero with no flag, no force, no privileged caller. |
| Charge a withdrawal fee | Treasury::payout() has no fee parameter. D26: a merchant withdraws what it earned, whenever it likes, and pays nothing. A fee cannot be passed to a method with nowhere to put it. |
| Record what a merchant owes us | No column in any of the four tables can hold it. A merchant never gets there. |
| Bill the provider's transfer fee to a merchant | bookGatewayFee() debits platform revenue and the gateway. Its signature cannot reach a merchant pot. |
The backstop the whole thing exists to hold: sum(everything we owe) ≤ real money at the gateway. Reconciler::solvency() is the alarm on it.
The books
Double entry, stored so the database itself can check it: journal_lines.amount_minor
is debit-positive, so a balanced entry is literally SUM(amount_minor) = 0.
Callers never think in debits and credits — they say Line::increase($account, N) /
Line::decrease($account, N) in the account's own direction, and AccountType::sign()
does the translation once, inside Line::amountMinor().
ledger_accounts.balance_minor is the natural-direction projection, written in the
same transaction as the lines. Reconciler::reconcileAccounts() re-derives it from the
journal and reports any drift — which is the answer to the defect the fleet audit found
six times over: a wallet balance that was a mutable column nothing ever cross-checked.
The pots
gateway_clearing is the only asset (debit-normal, +1) — real money at the
processor. Everything else is credit-normal (−1): withdrawable (a merchant's own
earnings), escrow, consumer_wallet (tops up to spend, never withdraws — D26),
tax_payable (the authority's, not ours), payout_clearing (claimed, not yet gone),
platform_revenue (ours), and external (the world; the only pot that may go negative).
The flows
Every one of these sums to zero:
collect increase(gateway, G) + increase(withdrawable, B) + increase(revenue, P) + increase(tax, T)
where G = B + P + T # split at intake, never later
payout decrease(withdrawable, A) + increase(payoutClearing, A)
settle decrease(payoutClearing, A) + decrease(gateway, A) # the money really left
release decrease(payoutClearing, A) + increase(withdrawable, A) # the provider said no
topUp increase(gateway, A) + increase(consumerWallet, A) # no split — nothing bought yet
spendWallet decrease(consumerWallet, G) + increase(withdrawable, B) + increase(revenue, P) + increase(tax, T)
gatewayFee decrease(revenue, F) + decrease(gateway, F) # our cost, out of our cut
adjust increase(target, A) + decrease(external, A) # the one door left open
Split::of() gives the merchant the remainder, so no franc is ever stranded: tax
rounds half-up (it must match what the customer was shown), the platform cut rounds
down (a sub-unit that cannot be split lands with the merchant).
When the split is not a rate — collectShares() (0.2.0)
Split divides a gross by rates, which assumes two parties and one base. A marketplace
order is a different animal: one tap collects money belonging to three parties on three
different bases — commission is charged on the goods and not on the courier's delivery
fee — and at the moment the customer pays there is no courier yet. There is no rate that
means "the delivery fee, exactly".
So the product computes the exact amounts and hands over a list of Shares, and treasury's
job becomes arithmetic checking:
collectShares increase(gateway, G) + Σ increase(share.pot, share.amount)
+ increase(revenue, P) + increase(tax, T)
refused unless G == Σshares + P + T # to the unit, or nothing is written
Shares naming the same owner and the same pot are merged first — the ledger refuses one account twice inside an entry. Be clear about what that refusal buys: an unallocated remainder would fail the zero-sum check anyway, so this is defence in depth for diagnosis. "Your shares are 500 short" names the caller's bug; "entry off by 500" would have named ours.
Share::held() puts a party's cut in escrow instead of withdrawable. Money that has
arrived always belongs to somebody here, so the delivery fee is held against the order
until assignEscrow() (escrow → escrow, EntryKind::EscrowAssigned) moves it to the courier
who accepts. Accepting a job is not finishing one — it does not become drawable there.
releaseEscrow() remains the only door out.
Chokepoint
Ledger::post() is the only thing in the fleet that writes a balance. Five properties,
each with tests:
- Balanced — lines sum to zero or
UnbalancedEntryException, before anything is written. - Floored — nothing goes negative except
external, checked under a row lock. - Atomic — accounts are locked in sorted id order (so concurrent posts queue rather than deadlock), and the entry, its lines, and every balance move in one transaction.
- Audited or it didn't happen — the kernel's hash-chained audit is inside the transaction; a failed audit takes the movement down with it. The refusal audit is written outside it, or an insufficient-funds refusal would roll back along with the movement it refused and leave no trace.
- Replay-safe —
UNIQUE(product_slug, idempotency_token); a repeated webhook gets the original entry back, not a boolean, because a retrying provider cannot otherwise tell "already done" from "never happened".
Floats — spend ceilings, not custody
The fleet shares one gateway credential. A ProjectFloat is a per-project
authorisation to instruct that rail — it holds no money (asserted in the suite).
Default limit is zero: a new product can spend nothing until an operator raises it.
reserve() books headroom before the gateway call, under a row lock, so two
concurrent spends cannot both take the last franc. Then settle() (usage stands) or
release() (headroom back). Release is the one operation with a natural replay risk —
a retried failure callback would hand headroom back twice — so it claims through the
kernel's IdempotencyGuard; there is no row of its own to collide on. setLimit()
sets, never adds, and resetUsage() is explicit: a ceiling that quietly refills
itself is not a ceiling.
Reconciler — reusable engine 4
Continuous verification, made real for money. Every check reports; not one writes. A reconciler that silently corrects a drift destroys the only evidence of the bug that caused it.
reconcileAccounts()— projection vs. journal.balanced()— journal vs. arithmetic.solvency()— books vs. the founder's rule. Double entry makesheld == owed + revenue + externalan identity, which is exactly what makes this a useful check rather than a tautology: it can only fail ifexternalwent negative, i.e. somebody used an adjustment to conjure money that never arrived.reconcileGateway()— books vs. the world. Reports the gap and takes no action; the provider is not the source of truth, and neither side overwrites the other on the strength of one disagreement.report()— all of the above, per currency in use. What the scheduled check calls.
Tests
composer update && vendor/bin/phpunit # highest supported (Laravel 12)
composer update --prefer-lowest && vendor/bin/phpunit # lowest supported (Laravel 10.0.0)
88 tests over Testbench on in-memory SQLite with foreign keys ON. They pin invariants that must fail loudly: a refused move that rolls back completely and still leaves an audit trail, a raw-SQL balance tamper caught by the reconciler, a replayed collection webhook that collects once, a refund refused rather than fronted once the merchant has drawn the money down, one project's float never borrowing another's headroom, and the doctrine tests that fail if the fee parameter, the overdraft column, or the second negative-capable account type ever appears.
Balances are asserted with raw DB::table(...)->value(...), never through a model —
see docs/dev/AI_CODE_PITFALLS.md #30 for why the model layer is not a witness to its
own writes.
Endpoints
None — the treasury is infrastructure; shapes and the operator layer own routes.
Dependencies
Dependencies
| ID | Version |
|---|---|
| factory/auth | ^0.3 || ^0.4 |
| factory/infra | ^0.1 || ^0.2 || ^0.3 |
| factory/kernel | ^0.2 || ^0.3 || ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 |
| illuminate/database | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/http | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/routing | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/support | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| php | ^8.2 |
Development dependencies
| ID | Version |
|---|---|
| orchestra/testbench | ^8.0 || ^9.0 || ^10.0 || ^11.0 |
| phpunit/phpunit | ^10.1 || ^11.0 || ^12.0 |