factory/treasury (0.5.0)

Published 2026-08-07 18:30:30 +00:00 by bmiadmin

Installation

{
	"repositories": [{
			"type": "composer",
			"url": ""
		}
	]
}
composer require factory/treasury:0.5.0

About this package

SaaS Factory treasury: double-entry ledger, guarded balance moves, intake splitting, payout lifecycle, per-project float ceilings, and reconciliation. Pure self-funded (PLAN A) — no credit, no overdraft, no fronting.

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:

  1. Balanced — lines sum to zero or UnbalancedEntryException, before anything is written.
  2. Floored — nothing goes negative except external, checked under a row lock.
  3. 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.
  4. 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.
  5. 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 makes held == owed + revenue + external an identity, which is exactly what makes this a useful check rather than a tautology: it can only fail if external went 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/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/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
Details
Composer
2026-08-07 18:30:30 +00:00
50
proprietary
70 KiB
Assets (1)
Versions (4) View all
0.7.0 2026-08-16
0.6.0 2026-08-14
0.5.0 2026-08-07
0.4.0 2026-08-06