Documentation
¶
Overview ¶
Package ledger is the native, double-entry accounting core of the Hanzo finance stack — the store-agnostic engine that owns EVERY accounting rule (balanced postings, a non-negative reserve fund, idempotent journal entries, revenue-share math) and NOTHING about how those facts are persisted or served.
It has ZERO coupling to the cloud binary: no zip/HTTP, no cloud.Deps, no IAM, no SQLite. Persistence is a PORT — the Store/Tx interfaces below — so the same core runs on Hanzo Base (HIP-0105 per-tenant SQLite), an in-memory fake, or any future backend by swapping the adapter. This is deliberate: the package is the SEED of the native `hanzoai/finance` central ledger (the Go replacement for the Formance Ledger/Wallets/Reconciliation stack). Lifting it to that repo is a directory move + import-path rewrite — no logic changes — because all the domain lives here and every dependency points INWARD (adapters depend on the core, never the reverse).
The model is the classic one, reduced to its essence:
- An ACCOUNT is an id in a chart (e.g. "fund:reserve", "revenue:platform", "payout:referral"). Its BALANCE is the signed sum of every posting to it.
- A JOURNAL ENTRY is an atomic set of POSTINGS whose signed amounts sum to EXACTLY zero (double-entry: every debit has an equal credit). An entry that does not balance is refused — the ledger can never be made inconsistent.
- Money in minor units (cents), int64. No floats ever touch a balance.
The reserve fund is one account ("fund:reserve"): revenue-share accruals credit it, backed payouts debit it, and a debit that would drive it negative is refused. That single rule — the fund cannot overdraw — is what makes the growth-loop payouts a REAL, backed reserve instead of unbounded minting.
Index ¶
- Constants
- Variables
- func ComputeRoot(entries []JournalEntry, reserve money.Amount) [32]byte
- func PayoutAccount(program string) string
- type Backend
- type JournalEntry
- type Ledger
- func (l *Ledger) AccountsWithPrefix(ctx context.Context, prefix string) (map[string]int64, error)
- func (l *Ledger) Accrue(ctx context.Context, period string, revenueCents, now int64) (JournalEntry, bool, error)
- func (l *Ledger) DebitReserve(ctx context.Context, program, ref, memo string, amountCents, now int64) (entry JournalEntry, backed, created bool, err error)
- func (l *Ledger) Entries(ctx context.Context, limit int) ([]JournalEntry, error)
- func (l *Ledger) Name() string
- func (l *Ledger) Policy(ctx context.Context) (SharePolicy, error)
- func (l *Ledger) ReserveCents(ctx context.Context) (int64, error)
- func (l *Ledger) Root(ctx context.Context) (root [32]byte, entryCount int, err error)
- func (l *Ledger) Seed(ctx context.Context, ref, memo string, amountCents, now int64) (JournalEntry, bool, error)
- func (l *Ledger) SetPolicy(ctx context.Context, bps, now int64) (SharePolicy, error)
- func (l *Ledger) Snapshot(ctx context.Context) (TreasuryReport, error)
- func (l *Ledger) Store() Store
- type PolicyStore
- type Posting
- type SharePolicy
- type Store
- type TreasuryReport
- type Tx
Constants ¶
const ( // AccountReserve is the shared reserve fund — the OSS/affiliate/referral pool. // Positive balance == money available to back payouts. Never goes negative. AccountReserve = "fund:reserve" // AccountRevenue is the source counter-account: revenue-share accruals move // value FROM here INTO the fund, so its balance is the negative of the total // ever allocated to the reserve (its magnitude == lifetime accrued). AccountRevenue = "revenue:platform" )
Canonical chart-of-accounts ids. ONE shared reserve pool with per-program payout sinks — a single reserve-health number plus a payouts-by-program breakdown, without fragmenting the fund. (A caller wanting isolated per-program funds would use distinct reserve account ids; the engine is agnostic — these are the Hanzo treasury's convention, kept in one place.)
const ( KindAccrual = "accrual" // revenue-share allocation: revenue:platform → fund:reserve KindPayout = "payout" // backed disbursement: fund:reserve → payout:<program> KindSeed = "seed" // an explicit capital injection into the fund (bootstrap) )
JournalEntry kinds classify a journal entry's economic meaning (also the first half of its idempotency key). Kept small and closed.
DefaultRevenueShareBps is the canonical revenue-share when a treasury has no explicit policy row yet: 20% (2000 bps) — the ONE creator/revenue share across the platform (the OSS author royalty, the marketplace "Earn 20%" CTA, and this reserve-fund sweep are the SAME 20%). A store reads this for an unset policy; a SuperAdmin can still set any value in [0,10000], including 0.
Variables ¶
var ( // ErrUnbalanced is returned when an entry's postings do not sum to zero — a // double-entry violation the engine refuses to persist. ErrUnbalanced = errors.New("ledger: entry postings do not sum to zero") // ErrNonPositive is returned for a non-positive money amount where a positive // one is required (an accrual/payout of ≤ 0). ErrNonPositive = errors.New("ledger: amount must be positive") // ErrEmptyRef is returned when an idempotency ref is missing — every entry MUST // carry one so a retry is a no-op, never a double post. ErrEmptyRef = errors.New("ledger: entry ref is required") )
Sentinel errors. Callers map these to transport status; the engine never knows about HTTP.
Functions ¶
func ComputeRoot ¶
func ComputeRoot(entries []JournalEntry, reserve money.Amount) [32]byte
ComputeRoot hashes a journal (in a canonical created-at,id order) plus the reserve balance into a 32-byte commitment — the value the Hanzo L1 anchor commits on-chain. Amounts are serialized as their EXACT 18-decimal USD decimal string (the on-chain uint256 value), so the off-chain preimage and the on-chain balance agree bit-for-bit. Both backends (native + Formance) use it, so the on-chain root is computed one way regardless of which ledger owns the books.
func PayoutAccount ¶
PayoutAccount is the sink account id for a program's backed payouts.
Types ¶
type Backend ¶
type Backend interface {
// Name identifies the backend of record ("native" | "formance").
Name() string
Policy(ctx context.Context) (SharePolicy, error)
SetPolicy(ctx context.Context, bps, now int64) (SharePolicy, error)
Accrue(ctx context.Context, period string, revenueCents, now int64) (JournalEntry, bool, error)
Seed(ctx context.Context, ref, memo string, amountCents, now int64) (JournalEntry, bool, error)
DebitReserve(ctx context.Context, program, ref, memo string, amountCents, now int64) (JournalEntry, bool, bool, error)
Snapshot(ctx context.Context) (TreasuryReport, error)
Entries(ctx context.Context, limit int) ([]JournalEntry, error)
ReserveCents(ctx context.Context) (int64, error)
Root(ctx context.Context) ([32]byte, int, error)
// AccountsWithPrefix returns account→balance for every account under prefix — the
// scope-aware read primitive: a per-org caller reads its own "org:<tenant>:"
// prefix; SuperAdmin reads house prefixes ("fund:", "payout:", "revenue:").
AccountsWithPrefix(ctx context.Context, prefix string) (map[string]int64, error)
}
Backend is the ledger-of-record PORT the treasury posts through. TWO adapters satisfy it: the native engine below (Base/SQLite — the offline/default backend, so the reserve fund ships today) and the Formance adapter in clients/treasury/formance (the Postgres-backed Formance Ledger, the production ledger of record when FORMANCE_LEDGER_URL is wired — HIP finance stack). The treasury client holds the Hanzo policy/fund/payout logic ABOVE this port; the port GUARANTEES double-entry + the reserve overdraw guard (Formance via Numscript source semantics; the native engine via its balance guard). The two adapters are byte-compatible on the accounts that matter (fund:reserve, payout:<program>), so switching backend is a config flip.
type JournalEntry ¶ added in v1.801.350
type JournalEntry struct {
ID string `json:"id"`
Kind string `json:"kind"`
Program string `json:"program,omitempty"` // referral|affiliate|author for payouts; "" otherwise
Ref string `json:"ref"` // idempotency ref (unique within Kind+Program)
Memo string `json:"memo,omitempty"`
Amount money.Amount `json:"amount"` // the entry's magnitude (display; postings hold the signed truth)
CreatedAt int64 `json:"createdAt"`
Postings []Posting `json:"postings,omitempty"`
}
JournalEntry is one balanced journal entry. Ref (with Kind+Program) is the idempotency key: re-posting the same (Kind, Program, Ref) is a no-op that returns the original — so a crashed-and-retried payout debits the fund AT MOST ONCE.
type Ledger ¶
type Ledger struct {
// contains filtered or unexported fields
}
Ledger is the native double-entry engine bound to a Store — the offline/default Backend. It is safe for concurrent use to the extent the Store's Tx is serializable (the SQLite adapter serializes on a single connection, so the balance guard holds under load).
func (*Ledger) AccountsWithPrefix ¶
AccountsWithPrefix returns account→balance (in cents, the Backend facade unit) for accounts under prefix.
func (*Ledger) Accrue ¶
func (l *Ledger) Accrue(ctx context.Context, period string, revenueCents, now int64) (JournalEntry, bool, error)
Accrue posts the revenue-share allocation for a period: it reads the policy, computes share = revenue * bps / 10000, and posts (revenue:platform −share, fund:reserve +share). Idempotent by period (ref "accrual:<period>") so re-running a sweep for the same period never double-accrues. A zero share (no policy, or revenue too small to yield a cent) is a no-op returning created=false.
func (*Ledger) DebitReserve ¶
func (l *Ledger) DebitReserve(ctx context.Context, program, ref, memo string, amountCents, now int64) (entry JournalEntry, backed, created bool, err error)
DebitReserve is the BACKED-PAYOUT primitive: it posts (fund:reserve −amount, payout:<program> +amount) ONLY when the reserve can cover it, atomically. It returns:
- backed=true, created=true → the fund had the funds; the entry was posted. The caller MUST now credit the recipient wallet (the external commerce deposit): fund down, wallet up — reconciled.
- backed=true, created=false → an entry for this ref already exists (idempotent replay); the fund was debited exactly once on the first call.
- backed=false → INSUFFICIENT RESERVE; nothing was posted. The caller MUST NOT credit the wallet — the payout is honestly pending/blocked until the fund is replenished.
The balance read and the insert happen in one Store transaction, so two concurrent debits cannot both pass the guard and overdraw the fund. Idempotency by (KindPayout, program, ref) makes a retry a no-op — the fund is charged AT MOST ONCE per ref, the mirror of the loops' own at-most-once credit latch.
func (*Ledger) Entries ¶
Entries returns the most recent journal entries (newest first, with postings).
func (*Ledger) Policy ¶
func (l *Ledger) Policy(ctx context.Context) (SharePolicy, error)
Policy returns the current revenue-share policy.
func (*Ledger) ReserveCents ¶
ReserveCents is the fund's available balance — the ceiling on total backable payouts right now.
func (*Ledger) Root ¶
Root computes a deterministic commitment over the ENTIRE journal plus the reserve balance: a SHA-256 hash-chain of every entry (in a canonical created-at,id order) and each of its postings, terminated by the fund balance. It is what the Hanzo L1 anchor commits on-chain — a change to any historical posting changes the root, so the on-chain value makes the off-chain books tamper-evident. Returns the 32-byte root and the number of entries covered. (A flat hash-chain, not a Merkle tree: the treasury's entry volume is modest and the anchor needs a single commitment, not per-entry inclusion proofs — YAGNI until it does.)
func (*Ledger) Seed ¶
func (l *Ledger) Seed(ctx context.Context, ref, memo string, amountCents, now int64) (JournalEntry, bool, error)
Seed posts an explicit capital injection into the reserve fund (revenue:platform −amount, fund:reserve +amount), idempotent by ref. It bootstraps a fresh fund so backed payouts can begin before the first revenue-share sweep. Distinct kind (seed) so it is auditable apart from an accrual.
func (*Ledger) SetPolicy ¶
SetPolicy persists a revenue-share policy after validating the basis points are in range [0, 10000] (0%–100%).
type PolicyStore ¶
type PolicyStore interface {
Policy(ctx context.Context) (SharePolicy, error)
SetPolicy(ctx context.Context, p SharePolicy) error
}
PolicyStore is the small native config store the Formance backend borrows to hold the Hanzo revenue-share policy (which Formance does not model — it is Hanzo config, not accounting). sqlstore satisfies it, so policy persists identically regardless of which backend owns the journal.
type Posting ¶
type Posting struct {
Account string `json:"account"`
Amount money.Amount `json:"amount"` // signed 18-decimal USD (1e-18); Σ over an entry == 0
}
Posting is one leg of a journal entry: a signed amount against an account. The sign carries direction (a debit is negative to the drawn-down account, the equal credit positive to the funded account); the engine only requires the legs to sum to zero.
type SharePolicy ¶ added in v1.801.350
type SharePolicy struct {
}
Policy is the revenue-share configuration: the fraction of net platform revenue, in basis points, that a sweep accrues into the reserve fund. One value, one place; SuperAdmin adjusts it. An unset policy defaults to DefaultRevenueShareBps.
type Store ¶
type Store interface {
// Tx runs fn inside one serializable transaction, committing on nil and rolling
// back on error.
Tx(ctx context.Context, fn func(Tx) error) error
// Balance returns an account's signed balance (read path, no tx).
Balance(ctx context.Context, account string) (money.Amount, error)
// BalancesWithPrefix returns balances for every account whose id has prefix
// (e.g. "payout:") — the per-program rollup.
BalancesWithPrefix(ctx context.Context, prefix string) (map[string]money.Amount, error)
// Entries returns the most recent entries (newest first, with postings), bounded.
Entries(ctx context.Context, limit int) ([]JournalEntry, error)
// Policy returns the current revenue-share policy (zero value if never set).
Policy(ctx context.Context) (SharePolicy, error)
// SetPolicy persists the revenue-share policy.
SetPolicy(ctx context.Context, p SharePolicy) error
}
Store is the persistence port. The engine owns all rules; the Store owns storage and atomicity only. A Base/SQLite adapter, an in-memory fake, or a future backend all satisfy this — the engine never changes.
type TreasuryReport ¶ added in v1.801.350
type TreasuryReport struct {
ReserveCents int64 `json:"reserveCents"` // fund:reserve balance (available now)
AccruedCents int64 `json:"accruedCents"` // lifetime revenue-share into the fund
PaidCents int64 `json:"paidCents"` // lifetime backed payouts out of the fund
ByProgramCents map[string]int64 `json:"byProgramCents"` // program → lifetime paid
Policy SharePolicy `json:"policy"` // current revenue-share policy
SolventForPayout bool `json:"solventForPayout"` // reserve > 0: at least some payout is backable
}
TreasuryReport is the reserve-fund health snapshot: what's available now, what's been accrued and paid over all time, and the per-program payout breakdown. Every figure is derived from the same postings, so it always reconciles (Fund == Accrued − Paid).
type Tx ¶
type Tx interface {
// EntryByRef returns the existing entry for (kind, program, ref) if present —
// the idempotency lookup performed inside the same tx as the insert.
EntryByRef(kind, program, ref string) (JournalEntry, bool, error)
// Balance returns an account's signed balance as visible inside this tx.
Balance(account string) (money.Amount, error)
// Insert upserts every referenced account then writes the entry and its
// postings. The engine has already validated the postings balance and the ref
// is free; the adapter only persists.
Insert(e JournalEntry, postings []Posting) error
}
Tx is the transactional port: the engine performs a read-then-write (balance guard, idempotency check, insert) inside a single Store-provided transaction so concurrent debits can never overdraw the fund. Adapters implement it over their native transaction; the engine composes the DOMAIN rules on top.
Directories
¶
| Path | Synopsis |
|---|---|
|
manager.go is the PER-TENANT selector over the treasury Store: it resolves each request to its OWN Hanzo Base (SQLite) file instead of a process-wide singleton, so one tenant's finance/ledger writes can NEVER appear in another tenant's reads.
|
manager.go is the PER-TENANT selector over the treasury Store: it resolves each request to its OWN Hanzo Base (SQLite) file instead of a process-wide singleton, so one tenant's finance/ledger writes can NEVER appear in another tenant's reads. |