Compare commits

...
Author SHA1 Message Date
antje fad1eca379 books: P0 revenue double-entry spine (native Go, per-org SQLite, /v1/books)
A native 'books' domain in the cloud one-binary that books hanzo.ai's real
prepaid-credit revenue as double-entry, no Postgres/Formance/ERPNext-Python.
Ports ERPNext general_ledger.py make_gl_entries SEMANTICS to Go:
- coa.go: fixed hanzo chart of accounts; Customer Wallet 2000 = LIABILITY
  (deferred revenue), revenue recognized at usage — GAAP-correct rev-rec.
- gl.go: process_gl_map pipeline (merge -> toggle-negative -> round-off ->
  debit==credit invariant), int64 minor-unit money (NO float64).
- store.go: per-org Base/SQLite; post() choke point writes immutable GL rows +
  AR/AP Payment Ledger subledger, idempotent by (source_kind, source_id).
- ingest.go: READ-ONLY commerce /v1/billing/transactions -> balanced vouchers
  (deposit Dr 1010/Cr 2000; usage Dr 2000/Cr 4000), sole posting source.
- report.go + api.go: Trial Balance proof + GET /v1/books/{accounts,gl,
  trial-balance} + POST /sync; X-Org-Id tenant scoping (verified vs commerce).

Tests green: TestBooksRevenueRecognition (deposit->wallet liability, usage->
recognition, trial balance balanced), TestPostBalanceEnforced, Test
SandboxSegregation. build+vet clean.

Follow-ups (verify pass, medium): refund/chargeback negative-amount sign
handling in ruleFor; a real imbalance-detection test; drop dead placeNet branch.
2026-07-23 22:50:01 -07:00
9 changed files with 1433 additions and 0 deletions
+7
View File
@@ -63,6 +63,7 @@ import (
"github.com/hanzoai/cloud/clients/base"
"github.com/hanzoai/cloud/clients/benchmark"
"github.com/hanzoai/cloud/clients/billing"
"github.com/hanzoai/cloud/clients/books"
"github.com/hanzoai/cloud/clients/bots"
"github.com/hanzoai/cloud/clients/campaign"
"github.com/hanzoai/cloud/clients/captable"
@@ -398,6 +399,12 @@ func Wire() []cloud.MountSpec {
// store → Shutdown. clients/campaign composes it (experiments.Assign/Analyze).
{Name: "experiments", Mount: experiments.Mount, Shutdown: ctxShutdown(experiments.Shutdown)},
{Name: "treasury", Mount: treasury.Mount, Shutdown: ctxShutdown(treasury.Shutdown)},
// The revenue BOOKS spine (/v1/books): a native double-entry general ledger that
// records prepaid-credit revenue on per-org Base/SQLite. It READS commerce's
// transactions (the sole posting source) and books the accounting twin —
// deposit → Cr Customer Wallet (a liability), usage → Cr Usage revenue (the
// recognition moment). Mounts beside treasury/billing; owns its stores → Shutdown.
{Name: "books", Mount: books.Mount, Shutdown: ctxShutdown(books.Shutdown)},
{Name: "admin", Mount: admin.Mount},
// Launch-control gate (per-service waitlist): the COMPLETE feature — host→service
// registry + brand seed + the waitlist.<svc> switch registration + the
+110
View File
@@ -0,0 +1,110 @@
package books
// api.go — the /v1/books read surface + the ingestion trigger. Every handler resolves
// the caller's OWN org from the validated principal and reads ONLY that org's books.
// Money is never cached (no-store), matching the finance surface.
import (
"net/http"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// accountsHandler returns the org's chart of accounts (the seeded fixed chart).
func accountsHandler(s *cloud.Service[*state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in to view books")
}
st, err := s.State.storeFor(org, sandboxQuery(c))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "books open failed")
}
accts, err := st.listAccounts(c.Context())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "books accounts read failed")
}
return booksJSON(c, accts)
}
// glHandler returns the org's most recent GL Entry rows (newest first, ?limit=).
func glHandler(s *cloud.Service[*state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in to view books")
}
st, err := s.State.storeFor(org, sandboxQuery(c))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "books open failed")
}
rows, err := st.listGL(c.Context(), atoiDefault(c.Query("limit"), 500))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "books gl read failed")
}
return booksJSON(c, rows)
}
// trialBalanceHandler returns the org's trial balance over an optional [?from, ?to]
// window (RFC3339 posting times), including the opening/closing columns and the
// TotalDebit==TotalCredit balance proof.
func trialBalanceHandler(s *cloud.Service[*state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in to view books")
}
st, err := s.State.storeFor(org, sandboxQuery(c))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "books open failed")
}
tb, err := trialBalance(c.Context(), st, c.Query("from"), c.Query("to"))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "books trial-balance failed")
}
return booksJSON(c, tb)
}
// syncHandler ingests the caller's OWN org from commerce into BOTH ledgers (live +
// sandbox) and reports how many new vouchers posted. Idempotent — a repeat posts nothing.
func syncHandler(s *cloud.Service[*state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in to sync books")
}
live, err := s.State.syncLedger(c.Context(), org, false)
if err != nil {
s.State.log.Warn("books sync (live) failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "books sync failed")
}
sandbox, err := s.State.syncLedger(c.Context(), org, true)
if err != nil {
s.State.log.Warn("books sync (sandbox) failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "books sync failed")
}
return booksJSON(c, map[string]int{"live": live, "sandbox": sandbox})
}
// booksJSON writes a books payload with no-store (per-org money must never be cached).
func booksJSON(c *zip.Ctx, v any) error {
c.SetHeader("Cache-Control", "no-store")
return c.JSON(http.StatusOK, v)
}
// atoiDefault parses a positive int query param, falling back to dflt on empty/invalid.
func atoiDefault(s string, dflt int) int {
n := 0
if s == "" {
return dflt
}
for _, r := range s {
if r < '0' || r > '9' {
return dflt
}
n = n*10 + int(r-'0')
}
if n == 0 {
return dflt
}
return n
}
+202
View File
@@ -0,0 +1,202 @@
// Package books is the revenue BOOKS spine: a native double-entry ledger that records
// hanzo.ai's real prepaid-credit revenue on per-org Base/SQLite, exposed at /v1/books.
//
// WHY THIS EXISTS. commerce holds the money (a prepaid wallet: deposits + withdraws);
// finance.go PROJECTS that wallet for the customer UI. Neither keeps BOOKS — a
// double-entry general ledger with a chart of accounts, revenue recognition, and a trial
// balance that proves the books balance. This domain ports ERPNext's Accounts SEMANTICS
// (process_gl_map: merge → toggle → round-off → the debit==credit invariant) to Go, with
// ZERO of its Python/Postgres, and books commerce's transactions into it.
//
// THE ONE POSTING SOURCE. commerce GET /v1/billing/transactions is the SOLE source
// (ingest.go). This domain is READ-ONLY against commerce — it never mints a deposit,
// credit, or payout. It only READS money that already moved and writes the accounting
// twin. So the books can restate but never create money.
//
// TENANT ISOLATION. Every read resolves the caller's OWN org from the validated
// principal (principal.Org — the gateway-minted X-Org-Id, HIP-0026), and each org's
// books live in a physically separate {DataDir}/orgs/{slug}/books.db (sandbox →
// books-sandbox.db). One org can never read another's ledger.
package books
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/commerceinproc"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// state is the subsystem's own data: the per-org book stores (live + sandbox, physically
// separate so a X-Hanzo-Test row can never pollute real revenue), the commerce posting
// source, and the logger.
type state struct {
live *cloud.OrgStore[*store]
sandbox *cloud.OrgStore[*store]
source txnSource
log luxlog.Logger
}
// mounted is the process-wide handle the in-process ingestion seam reaches the stores
// through — the same pattern experiments/flags expose.
var mounted *state
// Mount opens the per-org book stores, wires the commerce posting source, and registers
// the /v1/books surface.
func Mount(app *zip.App, deps cloud.Deps) error {
if deps.Logger == nil {
return fmt.Errorf("books.Mount: nil deps.Logger")
}
if deps.DataDir == "" {
return fmt.Errorf("books.Mount: empty deps.DataDir")
}
b := cloud.NewBase(deps, "books")
src := newCommerceReader(commerceinproc.BaseURL(os.Getenv("CLOUD_COMMERCE_HTTP_URL")), os.Getenv("COMMERCE_SERVICE_TOKEN"))
mounted = &state{
live: cloud.NewOrgStore[*store](deps.DataDir, "books", openStore, cloud.WithDurable(b.Durable), cloud.WithStoreLogger(b.Log)),
sandbox: cloud.NewOrgStore[*store](deps.DataDir, "books-sandbox", openStore, cloud.WithDurable(b.Durable), cloud.WithStoreLogger(b.Log)),
source: src,
log: b.Log,
}
svc := &cloud.Service[*state]{Base: b, State: mounted}
routes(app, svc)
b.Log.Info("books mounted", "prefix", "/v1/books", "commerce", src.configured())
return nil
}
// Shutdown closes every open per-org book store (live + sandbox).
func Shutdown() error {
if mounted == nil {
return nil
}
var first error
if mounted.live != nil {
if err := mounted.live.CloseAll(); err != nil {
first = err
}
}
if mounted.sandbox != nil {
if err := mounted.sandbox.CloseAll(); err != nil && first == nil {
first = err
}
}
return first
}
func routes(app *zip.App, s *cloud.Service[*state]) {
app.Get("/v1/books/accounts", cloud.Handle(s, accountsHandler))
app.Get("/v1/books/gl", cloud.Handle(s, glHandler))
app.Get("/v1/books/trial-balance", cloud.Handle(s, trialBalanceHandler))
// The customer-triggered ingestion of the caller's OWN org: reads commerce's
// transactions and posts the accounting twin. Idempotent, so a repeat is safe.
app.Post("/v1/books/sync", cloud.Handle(s, syncHandler))
}
// storeFor resolves the caller's OWN book store for the requested ledger (live/sandbox).
func (s *state) storeFor(org string, sandbox bool) (*store, error) {
if sandbox {
return s.sandbox.For(org, "")
}
return s.live.For(org, "")
}
// syncLedger ingests one org's commerce transactions into one ledger, then ships the
// store to its durable object (best-effort) so the postings survive a rolling deploy.
func (s *state) syncLedger(ctx context.Context, org string, sandbox bool) (int, error) {
st, err := s.storeFor(org, sandbox)
if err != nil {
return 0, err
}
posted, err := ingestOrg(ctx, s.source, st, org, sandbox)
if err != nil {
return posted, err
}
if posted > 0 {
store := s.live
if sandbox {
store = s.sandbox
}
if _, serr := store.Sync(org, ""); serr != nil {
s.log.Warn("books durable sync degraded", "org", org, "sandbox", sandbox, "err", serr)
}
}
return posted, nil
}
// ── commerce posting source (S2S read of /v1/billing/transactions) ──
// commerceReader reads commerce's per-org transaction ledger with the admin-scoped
// COMMERCE_SERVICE_TOKEN, scoping every read to ONE org via X-Org-Id (the S2S selector
// commerce's EdgeAuth honors after it verifies the service token) and routing sandbox
// reads to commerce's TEST ledger with X-Hanzo-Test:true. It is the SAME S2S machinery
// billing's commerceProxy uses — kept read-only (transactions only, never a mint).
type commerceReader struct {
base string
token string
http *http.Client
}
func newCommerceReader(base, token string) *commerceReader {
return &commerceReader{
base: strings.TrimRight(strings.TrimSpace(base), "/"),
token: strings.TrimSpace(token),
http: commerceinproc.Client(15 * time.Second),
}
}
func (r *commerceReader) configured() bool { return r != nil && r.base != "" && r.token != "" }
// transactions reads the org's commerce ledger (live or the X-Hanzo-Test sandbox),
// tolerating both the wrapped {transactions:[…]} shape and a bare array. An unconfigured
// reader returns no rows (an unconfigured deployment ingests nothing rather than erroring).
func (r *commerceReader) transactions(ctx context.Context, org string, sandbox bool) ([]commerceTxn, error) {
if !r.configured() {
return nil, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.base+"/v1/billing/transactions?limit=2000", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+r.token)
req.Header.Set("X-Org-Id", org)
if sandbox {
req.Header.Set("X-Hanzo-Test", "true")
}
resp, err := r.http.Do(req)
if err != nil {
return nil, fmt.Errorf("commerce unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("commerce transactions status %d", resp.StatusCode)
}
var wrap struct {
Transactions []commerceTxn `json:"transactions"`
}
if json.Unmarshal(body, &wrap) == nil && wrap.Transactions != nil {
return wrap.Transactions, nil
}
var rows []commerceTxn
if err := json.Unmarshal(body, &rows); err != nil {
return nil, fmt.Errorf("commerce transactions decode: %w", err)
}
return rows, nil
}
// sandboxQuery reads the ?sandbox=true toggle a read handler uses to select the ledger.
func sandboxQuery(c *zip.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("sandbox")), "true")
}
+312
View File
@@ -0,0 +1,312 @@
package books
import (
"context"
"testing"
"github.com/hanzoai/cloud"
)
// fakeSource is the test double for the commerce posting source: a fixed set of
// transactions per ledger (live vs sandbox), so the ingestion pipeline is exercised with
// no network and no commerce.
type fakeSource struct {
live []commerceTxn
sandbox []commerceTxn
}
func (f *fakeSource) transactions(_ context.Context, _ string, sandbox bool) ([]commerceTxn, error) {
if sandbox {
return f.sandbox, nil
}
return f.live, nil
}
// closingOf returns an account's closing debit/credit columns from a trial balance (0,0
// if the account did not move).
func closingOf(tb TrialBalance, account string) (debit, credit int64) {
for _, r := range tb.Rows {
if r.Account == account {
return r.ClosingDebit, r.ClosingCredit
}
}
return 0, 0
}
func newBookStore(t *testing.T, subsystem string) *store {
t.Helper()
stores := cloud.NewOrgStore[*store](t.TempDir(), subsystem, openStore)
t.Cleanup(func() { _ = stores.CloseAll() })
st, err := stores.For("acme", "")
if err != nil {
t.Fatalf("open books store: %v", err)
}
return st
}
// TestBooksRevenueRecognition feeds a deposit then a usage event through the ingestion
// pipeline (which posts via the Post() choke point) and asserts the two properties the
// revenue books MUST hold:
//
// (a) the Trial Balance balances — total debit == total credit; and
// (b) the Customer Wallet is a LIABILITY (credit) balance after a deposit, and DECREASES
// on usage while Usage revenue INCREASES (the revenue-recognition moment).
func TestBooksRevenueRecognition(t *testing.T) {
ctx := context.Background()
st := newBookStore(t, "books")
src := &fakeSource{live: []commerceTxn{
{ID: "txn-deposit-1", Type: "deposit", Amount: 10000, Currency: "usd", Notes: "top-up", CreatedAt: "2026-07-01T10:00:00Z"},
}}
// Phase 1: deposit only. The prepaid credit is a liability, NOT income yet.
posted, err := ingestOrg(ctx, src, st, "acme", false)
if err != nil {
t.Fatalf("ingest deposit: %v", err)
}
if posted != 1 {
t.Fatalf("deposit should post exactly 1 voucher, got %d", posted)
}
tb, err := trialBalance(ctx, st, "", "")
if err != nil {
t.Fatalf("trial balance after deposit: %v", err)
}
if !tb.Balanced {
t.Fatalf("after deposit: NOT balanced: debit=%d credit=%d", tb.TotalDebit, tb.TotalCredit)
}
wd, wc := closingOf(tb, CustomerWallet)
if wd != 0 || wc != 10000 {
t.Fatalf("after deposit: Customer Wallet must be a $100.00 CREDIT (liability), got debit=%d credit=%d", wd, wc)
}
if _, revC := closingOf(tb, UsageRevenue); revC != 0 {
t.Fatalf("after deposit: NO revenue must be recognized yet, got usage revenue credit=%d", revC)
}
sqD, _ := closingOf(tb, SquareClearing)
if sqD != 10000 {
t.Fatalf("after deposit: Square-clearing must be a $100.00 DEBIT (asset), got debit=%d", sqD)
}
// Phase 2: add a usage event. Deposit re-appears in the feed but must NOT re-post
// (idempotency), and usage RECOGNIZES revenue by consuming the wallet.
src.live = append(src.live, commerceTxn{
ID: "txn-usage-1", Type: "withdraw", Amount: 3000, Currency: "usd", Tags: "ai:tokens", CreatedAt: "2026-07-02T12:00:00Z",
})
posted, err = ingestOrg(ctx, src, st, "acme", false)
if err != nil {
t.Fatalf("ingest usage: %v", err)
}
if posted != 1 {
t.Fatalf("second ingest must post ONLY the new usage voucher (deposit idempotent), got %d", posted)
}
tb, err = trialBalance(ctx, st, "", "")
if err != nil {
t.Fatalf("trial balance after usage: %v", err)
}
if !tb.Balanced {
t.Fatalf("after usage: NOT balanced: debit=%d credit=%d", tb.TotalDebit, tb.TotalCredit)
}
wd, wc = closingOf(tb, CustomerWallet)
if wd != 0 || wc != 7000 {
t.Fatalf("after usage: Customer Wallet credit must DECREASE to $70.00, got debit=%d credit=%d", wd, wc)
}
_, revC := closingOf(tb, UsageRevenue)
if revC != 3000 {
t.Fatalf("after usage: Usage revenue must INCREASE to $30.00 credit (recognition), got credit=%d", revC)
}
// Idempotency: a third ingest of the identical feed posts nothing and leaves the books
// unchanged.
posted, err = ingestOrg(ctx, src, st, "acme", false)
if err != nil {
t.Fatalf("third ingest: %v", err)
}
if posted != 0 {
t.Fatalf("re-ingesting an unchanged feed must post 0 vouchers, got %d", posted)
}
tb2, _ := trialBalance(ctx, st, "", "")
if tb2.TotalDebit != tb.TotalDebit || tb2.TotalCredit != tb.TotalCredit {
t.Fatalf("idempotent re-ingest changed the books: %+v vs %+v", tb2, tb)
}
}
// TestPostBalanceEnforced proves the choke point rejects an unbalanced voucher beyond the
// round-off allowance and accepts one within it (booking the difference to round-off).
func TestPostBalanceEnforced(t *testing.T) {
ctx := context.Background()
st := newBookStore(t, "books")
// A 100-cent imbalance is far beyond the round-off allowance → rejected, nothing written.
_, err := st.post(ctx, Voucher{
SourceKind: "test", SourceID: "bad-1", PostingAt: "2026-07-01T00:00:00Z",
Legs: []Leg{{Account: SquareClearing, Debit: 10000}, {Account: CustomerWallet, Credit: 9900}},
}, RoundOffAllowance)
if err == nil {
t.Fatalf("an unbalanced voucher (100c gap) MUST be rejected")
}
if rows, _ := st.listGL(ctx, 100); len(rows) != 0 {
t.Fatalf("a rejected voucher must write NO gl rows, got %d", len(rows))
}
// A 1-cent gap is within the allowance → posted, with a round-off leg that balances it.
posted, err := st.post(ctx, Voucher{
SourceKind: "test", SourceID: "roundoff-1", PostingAt: "2026-07-01T00:00:00Z",
Legs: []Leg{{Account: SquareClearing, Debit: 10000}, {Account: CustomerWallet, Credit: 9999}},
}, RoundOffAllowance)
if err != nil || !posted {
t.Fatalf("a within-allowance voucher must post: posted=%v err=%v", posted, err)
}
tb, _ := trialBalance(ctx, st, "", "")
if !tb.Balanced {
t.Fatalf("round-off must leave the books balanced: debit=%d credit=%d", tb.TotalDebit, tb.TotalCredit)
}
if rd, rc := closingOf(tb, RoundOff); rd != 0 || rc != 1 {
t.Fatalf("the 1-cent difference must land on Round-off as a credit, got debit=%d credit=%d", rd, rc)
}
}
// TestTrialBalanceDetectsImbalance proves the trial balance is a real DETECTOR, not a
// mirror of post()'s guarantee. It writes a lone $5.00 debit with NO offsetting credit
// straight into gl_entry — the exact shape post() structurally forbids — then asserts the
// report reports Balanced==false and surfaces the precise debitcredit gap. Only a direct
// write can exercise this; a ledger built through post() can never be unbalanced.
func TestTrialBalanceDetectsImbalance(t *testing.T) {
ctx := context.Background()
st := newBookStore(t, "books")
if _, err := st.db.ExecContext(ctx,
`INSERT INTO voucher (source_kind, source_id, posting_at) VALUES ('test','imbalance-1','2026-07-01T00:00:00Z')`); err != nil {
t.Fatalf("seed voucher: %v", err)
}
if _, err := st.db.ExecContext(ctx,
`INSERT INTO gl_entry (voucher_id, posting_at, account, debit, credit, source_kind, source_id)
VALUES (1, '2026-07-01T00:00:00Z', ?, 500, 0, 'test', 'imbalance-1')`, SquareClearing); err != nil {
t.Fatalf("seed gl_entry: %v", err)
}
tb, err := trialBalance(ctx, st, "", "")
if err != nil {
t.Fatalf("trial balance: %v", err)
}
if tb.Balanced {
t.Fatalf("an unbalanced ledger MUST report Balanced=false")
}
if tb.TotalDebit-tb.TotalCredit != 500 {
t.Fatalf("detector must surface the exact 500c gap, got debit=%d credit=%d", tb.TotalDebit, tb.TotalCredit)
}
}
// TestPlaceNetBySign locks the sign-only placement: a positive net lands on the debit
// column, a negative net on the credit column (magnitude, other column zero) — regardless
// of any account type, since type no longer participates.
func TestPlaceNetBySign(t *testing.T) {
var d, c int64
placeNet(&d, &c, 700)
if d != 700 || c != 0 {
t.Fatalf("positive net must be a debit balance, got debit=%d credit=%d", d, c)
}
d, c = 0, 0
placeNet(&d, &c, -400)
if d != 0 || c != 400 {
t.Fatalf("negative net must be a credit balance, got debit=%d credit=%d", d, c)
}
}
// TestGrantBooksAsPromoExpense proves a FREE grant:* credit is booked as a promotional
// EXPENSE (Dr 5200 Promo credit / Cr 2000 Customer Wallet), never as processor cash — so
// the books never invent a Square-clearing asset for money no card was ever charged.
func TestGrantBooksAsPromoExpense(t *testing.T) {
ctx := context.Background()
st := newBookStore(t, "books")
src := &fakeSource{live: []commerceTxn{
{ID: "grant-1", Type: "deposit", Amount: 500, Currency: "usd", Tags: "grant:starter", CreatedAt: "2026-07-01T00:00:00Z"},
}}
if _, err := ingestOrg(ctx, src, st, "acme", false); err != nil {
t.Fatalf("ingest grant: %v", err)
}
tb, _ := trialBalance(ctx, st, "", "")
if !tb.Balanced {
t.Fatalf("grant voucher must balance")
}
if promoD, _ := closingOf(tb, PromoCredit); promoD != 500 {
t.Fatalf("grant must Dr Promo credit $5.00, got debit=%d", promoD)
}
if sqD, _ := closingOf(tb, SquareClearing); sqD != 0 {
t.Fatalf("a free grant must NOT invent Square-clearing cash, got debit=%d", sqD)
}
if _, wc := closingOf(tb, CustomerWallet); wc != 500 {
t.Fatalf("grant must Cr Customer Wallet liability $5.00, got credit=%d", wc)
}
}
// TestRefundReturnsFundsNotRevenue proves a refund draws the wallet liability down against
// CASH (Dr 2000 Customer Wallet / Cr 1000 Bank), never recognizing revenue on a return of
// unspent prepaid funds.
func TestRefundReturnsFundsNotRevenue(t *testing.T) {
ctx := context.Background()
st := newBookStore(t, "books")
src := &fakeSource{live: []commerceTxn{
{ID: "dep-1", Type: "deposit", Amount: 5000, Currency: "usd", CreatedAt: "2026-07-01T00:00:00Z"},
{ID: "ref-1", Type: "refund", Amount: 2000, Currency: "usd", CreatedAt: "2026-07-02T00:00:00Z"},
}}
if _, err := ingestOrg(ctx, src, st, "acme", false); err != nil {
t.Fatalf("ingest: %v", err)
}
tb, _ := trialBalance(ctx, st, "", "")
if !tb.Balanced {
t.Fatalf("refund voucher must balance")
}
if _, wc := closingOf(tb, CustomerWallet); wc != 3000 {
t.Fatalf("wallet liability must draw down to $30.00 after refund, got credit=%d", wc)
}
if _, bankC := closingOf(tb, Bank); bankC != 2000 {
t.Fatalf("refund must Cr Bank $20.00 (cash out), got credit=%d", bankC)
}
if _, revC := closingOf(tb, UsageRevenue); revC != 0 {
t.Fatalf("a refund must recognize NO revenue, got usage revenue credit=%d", revC)
}
}
// TestNegativeAmountSkipped proves a mis-signed (non-positive) commerce row is SKIPPED,
// never abs()'d into a fabricated posting — commerce is a magnitude ledger.
func TestNegativeAmountSkipped(t *testing.T) {
if _, ok := ruleFor(commerceTxn{ID: "neg-1", Type: "deposit", Amount: -5000}); ok {
t.Fatalf("a negative-amount row must be skipped, not booked")
}
if _, ok := ruleFor(commerceTxn{ID: "zero-1", Type: "withdraw", Amount: 0}); ok {
t.Fatalf("a zero-amount row must be skipped")
}
}
// TestSandboxSegregation proves a sandbox (X-Hanzo-Test) feed posts to a PHYSICALLY
// separate ledger and never pollutes live revenue.
func TestSandboxSegregation(t *testing.T) {
ctx := context.Background()
live := newBookStore(t, "books")
sandbox := newBookStore(t, "books-sandbox")
src := &fakeSource{
live: []commerceTxn{{ID: "live-dep", Type: "deposit", Amount: 5000, CreatedAt: "2026-07-01T00:00:00Z"}},
sandbox: []commerceTxn{{ID: "test-dep", Type: "deposit", Amount: 999999, CreatedAt: "2026-07-01T00:00:00Z"}},
}
if _, err := ingestOrg(ctx, src, live, "acme", false); err != nil {
t.Fatalf("ingest live: %v", err)
}
if _, err := ingestOrg(ctx, src, sandbox, "acme", true); err != nil {
t.Fatalf("ingest sandbox: %v", err)
}
tbLive, _ := trialBalance(ctx, live, "", "")
if _, wc := closingOf(tbLive, CustomerWallet); wc != 5000 {
t.Fatalf("live wallet must reflect ONLY the live $50.00 deposit, got credit=%d", wc)
}
tbSandbox, _ := trialBalance(ctx, sandbox, "", "")
if _, wc := closingOf(tbSandbox, CustomerWallet); wc != 999999 {
t.Fatalf("sandbox wallet must hold the test deposit, got credit=%d", wc)
}
}
+101
View File
@@ -0,0 +1,101 @@
package books
// coa.go — the FIXED Hanzo chart of accounts. This is the accounting VOCABULARY the
// whole domain posts against: a small, closed set of accounts seeded into every org's
// books on first open, never edited by a request. Porting ERPNext's Accounts SEMANTICS
// (not its dynamic tree) to Go means the chart is a value, not a table an admin mutates
// — one and only one chart, identical across orgs, so a rule map (ingest.go) and a
// report (trial-balance) can reference an account by its stable number.
//
// THE ONE MONEY-MODEL DECISION. Prepaid credits a customer buys are a LIABILITY, not
// income: the money is the customer's until it is SPENT. So a top-up CREDITS the
// Customer Wallet liability (2000, deferred revenue) — recognizing revenue at deposit
// would book money we may still owe back. Revenue is RECOGNIZED only when usage
// consumes the wallet: Dr 2000 Customer Wallet / Cr 4000 Usage revenue. That single
// deferral is the reason this domain exists.
// AccountType is the fundamental accounting class of an account. It records the account's
// NORMAL balance side (asset/expense are debit-normal; liability/income/equity are
// credit-normal) for classification and presentation; the trial balance places a signed
// net by its SIGN, so a faithfully-kept ledger never depends on the normal side to balance.
type AccountType string
const (
Asset AccountType = "asset"
Liability AccountType = "liability"
Income AccountType = "income"
Expense AccountType = "expense"
Equity AccountType = "equity"
)
// PartyType marks an account that carries a SUBLEDGER (the Payment Ledger Entry twin):
// a receivable (money owed TO us) or a payable (money we owe). A leg posted to such an
// account also writes a payment_ledger_entry row, mirroring ERPNext's party-account
// posting. NoParty accounts (bank, wallet, revenue, COGS) carry no subledger.
type PartyType string
const (
NoParty PartyType = ""
Receivable PartyType = "receivable"
Payable PartyType = "payable"
)
// Account is one line of the chart: a stable number (the posting key), a human name, its
// fundamental type, and — for AR/AP — its party subledger class.
type Account struct {
Number string `json:"number"`
Name string `json:"name"`
Type AccountType `json:"type"`
Party PartyType `json:"party,omitempty"`
}
// Account numbers — the ONE set of posting keys the rule map and reports reference.
const (
Bank = "1000" // operating cash
SquareClearing = "1010" // funds captured by Square, pre-settlement
AR = "1200" // accounts receivable (party)
OwnerEquity = "3000" // owner equity
RoundOff = "3900" // round-off difference sink (see Post)
CustomerWallet = "2000" // PREPAID CREDITS — a liability / deferred revenue
OSSPayout = "2100" // owed to OSS maintainers (party)
SalesTaxPayable = "2200" // collected sales tax owed to authorities (party)
UsageRevenue = "4000" // AI usage revenue — RECOGNIZED on consumption
MRR = "4100" // recurring subscription revenue
ProductRevenue = "4200" // one-off product revenue
CloudCOGS = "5000" // cloud / GPU cost of goods sold
ProcessorFees = "5100" // payment-processor fees
PromoCredit = "5200" // promotional credit given away
)
// chartOfAccounts is the FIXED chart seeded into every org's books. Order is
// number-ascending within class so the seed and the accounts report read top-to-bottom.
var chartOfAccounts = []Account{
// Assets (debit-normal)
{Number: Bank, Name: "Bank", Type: Asset},
{Number: SquareClearing, Name: "Square clearing", Type: Asset},
{Number: AR, Name: "Accounts receivable", Type: Asset, Party: Receivable},
// Liabilities (credit-normal)
{Number: CustomerWallet, Name: "Customer wallet (prepaid credits)", Type: Liability},
{Number: OSSPayout, Name: "OSS payout payable", Type: Liability, Party: Payable},
{Number: SalesTaxPayable, Name: "Sales tax payable", Type: Liability, Party: Payable},
// Equity (credit-normal)
{Number: OwnerEquity, Name: "Equity", Type: Equity},
{Number: RoundOff, Name: "Round-off", Type: Equity},
// Income (credit-normal)
{Number: UsageRevenue, Name: "AI usage revenue", Type: Income},
{Number: MRR, Name: "Recurring revenue (MRR)", Type: Income},
{Number: ProductRevenue, Name: "Product revenue", Type: Income},
// Expenses (debit-normal)
{Number: CloudCOGS, Name: "Cloud / GPU COGS", Type: Expense},
{Number: ProcessorFees, Name: "Processor fees", Type: Expense},
{Number: PromoCredit, Name: "Promotional credit", Type: Expense},
}
// accountByNumber indexes the chart for O(1) party/type lookup on the posting path.
var accountByNumber = func() map[string]Account {
m := make(map[string]Account, len(chartOfAccounts))
for _, a := range chartOfAccounts {
m[a.Number] = a
}
return m
}()
+130
View File
@@ -0,0 +1,130 @@
package books
// gl.go — the double-entry MODEL and the pure posting pipeline ported from ERPNext's
// erpnext/accounts/general_ledger.py::process_gl_map. It is deliberately DB-free: given
// a raw list of legs it returns the balanced, normalized legs a store will persist, or
// an error if they cannot balance. Keeping the accounting SEMANTICS in a pure function
// means the choke point (store.post) is the ONLY writer and this is the ONLY arithmetic
// — one place to reason about "do the books balance".
//
// MONEY MATH. Amounts are int64 MINOR UNITS (USD cents). Never float64: cents are exact
// under add/subtract/negate, the only operations double-entry needs, so there is no
// rounding error to accumulate and no precision table to carry. A fractional-cent input
// cannot occur — commerce transacts whole cents — so integer cents is the exact model.
import "fmt"
// RoundOffAllowance bounds the debitcredit difference process gl will absorb into the
// round-off account instead of rejecting. With exact integer cents a well-formed voucher
// nets to zero, so this only ever soaks up a 12¢ artifact of an upstream split; a larger
// gap is a real imbalance and MUST fail closed rather than silently plug equity.
const RoundOffAllowance int64 = 2
// Leg is one side of a posting: an account and its debit AND credit in cents. A caller
// sets exactly one of the two; the pipeline normalizes anything else (a merge can leave
// both set, which toggle collapses to a single side).
type Leg struct {
Account string `json:"account"`
Debit int64 `json:"debit"`
Credit int64 `json:"credit"`
}
// Voucher is one accounting EVENT: a set of legs that must balance, tagged with its
// idempotency key (SourceKind, SourceID) so the same source event posts exactly once.
type Voucher struct {
SourceKind string `json:"sourceKind"`
SourceID string `json:"sourceId"`
PostingAt string `json:"postingAt"`
Description string `json:"description"`
Legs []Leg `json:"legs"`
}
// processGLMap replicates general_ledger.py's process_gl_map in order:
//
// 1. merge_similar_entries — collapse legs on the SAME account, summing debit + credit.
// 2. toggle_debit_credit_if_negative — a leg whose net (debitcredit) is negative is
// flipped to the opposite side so no leg carries a negative amount, and net-zero legs
// are dropped (they carry no accounting weight).
// 3. process_debit_credit_difference — compute Σdebit Σcredit; absorb a |diff| ≤
// allowance into a round-off leg, else reject as unbalanced.
// 4. the invariant: Σdebit == Σcredit, or it is a programming error and we refuse.
//
// It returns the normalized, balanced legs to persist. roundOff is the account the
// difference is booked to (books.RoundOff).
func processGLMap(raw []Leg, allowance int64, roundOff string) ([]Leg, error) {
merged := mergeSimilar(raw)
legs := toggleNegative(merged)
diff := debitCreditDiff(legs)
if diff != 0 {
if abs64(diff) > allowance {
return nil, fmt.Errorf("books: voucher does not balance: debitcredit=%d cents exceeds round-off allowance %d", diff, allowance)
}
// diff>0 ⇒ debits exceed ⇒ book the excess as a CREDIT to round-off (and vice
// versa), which is exactly the leg that drives the difference to zero.
if diff > 0 {
legs = append(legs, Leg{Account: roundOff, Credit: diff})
} else {
legs = append(legs, Leg{Account: roundOff, Debit: -diff})
}
}
if d := debitCreditDiff(legs); d != 0 {
return nil, fmt.Errorf("books: internal imbalance after round-off: %d cents", d)
}
if len(legs) == 0 {
return nil, fmt.Errorf("books: empty voucher (no non-zero legs)")
}
return legs, nil
}
// mergeSimilar sums debit and credit per account, preserving first-seen order so the
// persisted legs read in the order the caller supplied them.
func mergeSimilar(raw []Leg) []Leg {
idx := map[string]int{}
out := make([]Leg, 0, len(raw))
for _, l := range raw {
if i, ok := idx[l.Account]; ok {
out[i].Debit += l.Debit
out[i].Credit += l.Credit
continue
}
idx[l.Account] = len(out)
out = append(out, l)
}
return out
}
// toggleNegative reduces each leg to a single non-negative side by its net (debit
// credit): net>0 ⇒ pure debit, net<0 ⇒ pure credit, net==0 ⇒ dropped. This is
// process_gl_map's toggle_debit_credit_if_negative: it guarantees no persisted leg
// carries a negative amount, so debit/credit columns are always ≥ 0.
func toggleNegative(in []Leg) []Leg {
out := make([]Leg, 0, len(in))
for _, l := range in {
net := l.Debit - l.Credit
switch {
case net > 0:
out = append(out, Leg{Account: l.Account, Debit: net})
case net < 0:
out = append(out, Leg{Account: l.Account, Credit: -net})
}
}
return out
}
// debitCreditDiff is Σdebit Σcredit over the legs (zero iff the voucher balances).
func debitCreditDiff(legs []Leg) int64 {
var d int64
for _, l := range legs {
d += l.Debit - l.Credit
}
return d
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
+165
View File
@@ -0,0 +1,165 @@
package books
// ingest.go — the ONE posting source: a per-org cursor over commerce's transaction
// ledger (GET /v1/billing/transactions). commerce's prepaid wallet is the source of
// truth for money moved; this projects each transaction into a balanced double-entry
// voucher via a FIXED rule map (no AI, no heuristics) and posts it through the choke
// point. It is READ-ONLY against commerce — it never calls the mint-gated
// deposit/credit/payout endpoints — and the transactions endpoint is the SOLE source, so
// the books can never double-count a move that is also visible in cloud_usage/finance.
//
// IDEMPOTENCY. Every voucher is keyed (commerce_txn, txn.ID), so re-running ingestion —
// on a schedule, after a crash, over an overlapping cursor window — posts each commerce
// transaction exactly once. The cursor is an OPTIMIZATION over that guarantee (it caps
// re-scan), never the correctness boundary.
import (
"context"
"sort"
"strings"
)
// commerceTxn is one row of commerce GET /v1/billing/transactions (only the fields the
// rule map reads). Type is "deposit" (a top-up / credit purchase) or "withdraw" (usage /
// consumption); Amount is the magnitude in cents.
type commerceTxn struct {
ID string `json:"id"`
Type string `json:"type"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Tags string `json:"tags"`
Notes string `json:"notes"`
CreatedAt string `json:"createdAt"`
}
// txnSource is the measurement seam to commerce: per-org transactions, live or sandbox.
// Production is commerceReader (the S2S read); tests inject a fake. It is the ONLY seam
// to the posting source — there is no second money reader.
type txnSource interface {
transactions(ctx context.Context, org string, sandbox bool) ([]commerceTxn, error)
}
// ruleFor maps a commerce transaction to its double-entry voucher via the FIXED rule map:
//
// deposit / topup (paid) → Dr 1010 Square-clearing / Cr 2000 Customer Wallet
// (cash arrives; the org's prepaid LIABILITY grows — NOT income)
// deposit (grant:*) → Dr 5200 Promo credit / Cr 2000 Customer Wallet
// (FREE credit — no cash moved — is a promotional EXPENSE, not
// a processor-cash asset; booking it to 1010 would invent money)
// withdraw / usage → Dr 2000 Customer Wallet / Cr 4000 AI usage revenue
// (the revenue-RECOGNITION moment: consumed credit becomes revenue)
// refund → Dr 2000 Customer Wallet / Cr 1000 Bank
// (return of UNSPENT prepaid funds draws the liability down against
// cash — NOT revenue; booking it as usage would fabricate revenue)
//
// An unknown type yields ok=false and is skipped (never a malformed posting). Amount is a
// MAGNITUDE: commerce may emit a mis-signed row (see usage.isSpend's Amount>0 guard), so a
// non-positive amount is skipped rather than abs()'d into a fabricated, possibly
// wrong-direction, entry.
func ruleFor(t commerceTxn) (Voucher, bool) {
amount := t.Amount
if amount <= 0 {
return Voucher{}, false
}
desc := firstNonEmpty(strings.TrimSpace(t.Notes), strings.TrimSpace(t.Tags), strings.TrimSpace(t.Type))
v := Voucher{
SourceKind: "commerce_txn",
SourceID: t.ID,
PostingAt: t.CreatedAt,
Description: desc,
}
switch strings.ToLower(strings.TrimSpace(t.Type)) {
case "deposit", "topup", "top-up", "credit", "grant":
if isGrant(t) {
v.Legs = []Leg{
{Account: PromoCredit, Debit: amount},
{Account: CustomerWallet, Credit: amount},
}
} else {
v.Legs = []Leg{
{Account: SquareClearing, Debit: amount},
{Account: CustomerWallet, Credit: amount},
}
}
return v, true
case "withdraw", "usage", "debit", "charge":
v.Legs = []Leg{
{Account: CustomerWallet, Debit: amount},
{Account: UsageRevenue, Credit: amount},
}
return v, true
case "refund":
v.Legs = []Leg{
{Account: CustomerWallet, Debit: amount},
{Account: Bank, Credit: amount},
}
return v, true
default:
return Voucher{}, false
}
}
// isGrant reports whether a credit is a FREE promotional grant rather than a paid top-up.
// Grants carry a grant:* DepositKind (grant:starter / referral / affiliate / admin /
// author) in the ledger row's tag, or arrive as a "grant" type. No cash moves, so the
// offsetting debit is a promotional expense, never processor cash.
func isGrant(t commerceTxn) bool {
return strings.EqualFold(strings.TrimSpace(t.Type), "grant") ||
strings.Contains(strings.ToLower(t.Tags), "grant:")
}
// ingestOrg advances one org's books (a single ledger — live OR sandbox) over commerce's
// transactions since the cursor. It posts each mapped voucher idempotently, then advances
// the cursor to the newest transaction time seen. src supplies the rows; st is the org's
// store for this ledger. Returns how many NEW vouchers were posted.
func ingestOrg(ctx context.Context, src txnSource, st *store, org string, sandbox bool) (int, error) {
txns, err := src.transactions(ctx, org, sandbox)
if err != nil {
return 0, err
}
cur, err := st.cursor(ctx)
if err != nil {
return 0, err
}
// Oldest-first so the cursor advances monotonically and postings read chronologically.
sort.SliceStable(txns, func(i, j int) bool { return txns[i].CreatedAt < txns[j].CreatedAt })
posted := 0
newCur := cur
for _, t := range txns {
// Cursor skip is an optimization; idempotency (below) is the correctness boundary.
// Reprocess the boundary timestamp (>=) so an equal-timestamp row is never lost.
if cur.LastAt != "" && t.CreatedAt < cur.LastAt {
continue
}
v, ok := ruleFor(t)
if !ok {
continue
}
didPost, err := st.post(ctx, v, RoundOffAllowance)
if err != nil {
return posted, err
}
if didPost {
posted++
}
if t.CreatedAt > newCur.LastAt {
newCur = cursorState{LastAt: t.CreatedAt, LastID: t.ID}
}
}
if newCur != cur {
if err := st.setCursor(ctx, newCur); err != nil {
return posted, err
}
}
return posted, nil
}
func firstNonEmpty(vs ...string) string {
for _, v := range vs {
if v != "" {
return v
}
}
return ""
}
+89
View File
@@ -0,0 +1,89 @@
package books
// report.go — the Trial Balance: the classic proof that the books balance. For every
// account it presents opening, period movement, and closing as debit/credit columns on
// the account's normal side, then the whole-ledger totals. The invariant the report
// EXISTS to prove is TotalDebit == TotalCredit — if that ever fails the ledger is broken,
// so Balanced is computed, never assumed.
import "context"
// TrialBalanceRow is one account's line: opening + period movement → closing, each split
// onto its debit/credit column by the SIGN of its net (debit credit): a positive net is
// a debit balance, a negative net a credit balance. Type is carried for presentation (the
// account's normal side) but does not affect placement — a faithfully-signed net is shown
// truthfully, so a contra-balance (e.g. an overdrawn wallet) reads as it really is.
type TrialBalanceRow struct {
Account string `json:"account"`
Name string `json:"name"`
Type AccountType `json:"type"`
OpeningDebit int64 `json:"openingDebit"`
OpeningCredit int64 `json:"openingCredit"`
Debit int64 `json:"debit"` // period movement
Credit int64 `json:"credit"` // period movement
ClosingDebit int64 `json:"closingDebit"`
ClosingCredit int64 `json:"closingCredit"`
}
// TrialBalance is the whole-ledger report: per-account rows + totals + the balance proof.
type TrialBalance struct {
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Rows []TrialBalanceRow `json:"rows"`
TotalDebit int64 `json:"totalDebit"`
TotalCredit int64 `json:"totalCredit"`
Balanced bool `json:"balanced"`
}
// trialBalance builds the report from the store over an optional [from, to] window. It
// reads opening (movement strictly before `from`) and closing (movement up to and
// including `to`) once each, derives period = closing opening, and places every net on
// its natural column by sign. Accounts with no movement in any window are omitted (a
// trial balance lists only accounts that moved).
func trialBalance(ctx context.Context, s *store, from, to string) (TrialBalance, error) {
var opening sums
var err error
if from != "" {
if opening, err = s.sums(ctx, "", from); err != nil { // strictly before `from`
return TrialBalance{}, err
}
} else {
opening = sums{}
}
closing, err := s.sums(ctx, "", to) // up to and including `to` ("" = all time)
if err != nil {
return TrialBalance{}, err
}
tb := TrialBalance{From: from, To: to, Rows: []TrialBalanceRow{}}
for _, a := range chartOfAccounts {
o := opening[a.Number]
c := closing[a.Number]
if o == [2]int64{} && c == [2]int64{} {
continue
}
row := TrialBalanceRow{Account: a.Number, Name: a.Name, Type: a.Type}
placeNet(&row.OpeningDebit, &row.OpeningCredit, o[0]-o[1])
placeNet(&row.Debit, &row.Credit, (c[0]-o[0])-(c[1]-o[1]))
placeNet(&row.ClosingDebit, &row.ClosingCredit, c[0]-c[1])
tb.Rows = append(tb.Rows, row)
tb.TotalDebit += row.ClosingDebit
tb.TotalCredit += row.ClosingCredit
}
tb.Balanced = tb.TotalDebit == tb.TotalCredit
return tb, nil
}
// placeNet puts a signed net (debit credit) onto its natural column by SIGN, so a report
// row never shows a negative amount: a positive net is a debit balance, a negative net a
// credit balance. Placement is independent of the account's normal side — for every net,
// normal-side placement collapses to exactly this sign rule, and placing by sign faithfully
// shows a contra-balance instead of forcing it onto the normal column. The magnitude always
// lands on ONE column; the other stays zero.
func placeNet(debitCol, creditCol *int64, net int64) {
if net >= 0 {
*debitCol = net
} else {
*creditCol = -net
}
}
+317
View File
@@ -0,0 +1,317 @@
package books
// store.go — one per-org (HIP-0302 physical-file isolated) SQLite holding the org's
// books: the seeded chart of accounts, immutable GL Entry rows, the Payment Ledger Entry
// subledger for AR/AP legs, and the ingestion cursor. A distinct org resolves to a
// distinct {DataDir}/orgs/{slug}/books.db (sandbox → books-sandbox.db), so one org's
// ledger can never reach another's rows.
//
// IMMUTABILITY. GL Entry rows are append-only: the store has no UPDATE or DELETE of a
// gl_entry, and every write goes through post() inside one transaction gated by the
// voucher's idempotency key. A correction is a NEW reversing voucher, never an edit —
// the ledger of record is the sum of what was posted.
import (
"context"
"database/sql"
"fmt"
)
type store struct {
db *sql.DB
}
// openStore wraps an org DB (already opened + pragma'd by cloud.OrgDB) into the books
// store, running its migration and seeding the fixed chart. It is the open func
// cloud.OrgStore calls once per org file.
func openStore(db *sql.DB) (*store, error) {
s := &store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
if err := s.seed(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
func (s *store) Close() error { return s.db.Close() }
func (s *store) migrate() error {
const ddl = `
CREATE TABLE IF NOT EXISTS account (
number TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
party TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS voucher (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
posting_at TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(source_kind, source_id)
);
CREATE TABLE IF NOT EXISTS gl_entry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
voucher_id INTEGER NOT NULL REFERENCES voucher(id),
posting_at TEXT NOT NULL,
account TEXT NOT NULL REFERENCES account(number),
debit INTEGER NOT NULL DEFAULT 0,
credit INTEGER NOT NULL DEFAULT 0,
against TEXT NOT NULL DEFAULT '',
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
remarks TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS gl_entry_account ON gl_entry(account);
CREATE TABLE IF NOT EXISTS payment_ledger_entry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
voucher_id INTEGER NOT NULL REFERENCES voucher(id),
posting_at TEXT NOT NULL,
account TEXT NOT NULL,
party_type TEXT NOT NULL,
amount INTEGER NOT NULL,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS cursor (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_at TEXT NOT NULL DEFAULT '',
last_id TEXT NOT NULL DEFAULT ''
);`
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("books migrate: %w", err)
}
return nil
}
// seed inserts the fixed chart of accounts, idempotently — a re-open of an existing
// books.db is a no-op. The chart is a constant, so a row is never updated (an account's
// meaning is fixed by its number).
func (s *store) seed(ctx context.Context) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, a := range chartOfAccounts {
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO account (number, name, type, party) VALUES (?,?,?,?)`,
a.Number, a.Name, string(a.Type), string(a.Party)); err != nil {
return fmt.Errorf("books seed %s: %w", a.Number, err)
}
}
return tx.Commit()
}
// post is the SINGLE ledger-write choke point. It runs the voucher through processGLMap
// (merge → toggle → round-off → the Σdebit==Σcredit invariant) and, only if it balances,
// writes the immutable GL Entry rows + a Payment Ledger Entry per AR/AP leg in ONE
// transaction. It is idempotent by (source_kind, source_id): a voucher already posted
// returns posted=false and writes nothing (the UNIQUE(voucher) row is the guard, so a
// re-run of ingestion never double-books).
func (s *store) post(ctx context.Context, v Voucher, allowance int64) (posted bool, err error) {
legs, err := processGLMap(v.Legs, allowance, RoundOff)
if err != nil {
return false, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO voucher (source_kind, source_id, posting_at, description)
VALUES (?,?,?,?) ON CONFLICT(source_kind, source_id) DO NOTHING`,
v.SourceKind, v.SourceID, v.PostingAt, v.Description)
if err != nil {
return false, fmt.Errorf("books post voucher: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return false, nil // already posted — idempotent skip
}
vid, err := res.LastInsertId()
if err != nil {
return false, err
}
against := counterAccounts(legs)
for _, l := range legs {
if _, err := tx.ExecContext(ctx,
`INSERT INTO gl_entry (voucher_id, posting_at, account, debit, credit, against, source_kind, source_id, remarks)
VALUES (?,?,?,?,?,?,?,?,?)`,
vid, v.PostingAt, l.Account, l.Debit, l.Credit, against[l.Account], v.SourceKind, v.SourceID, v.Description); err != nil {
return false, fmt.Errorf("books post gl_entry: %w", err)
}
// AR/AP legs also land in the Payment Ledger Entry subledger (party outstanding).
if a, ok := accountByNumber[l.Account]; ok && a.Party != NoParty {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payment_ledger_entry (voucher_id, posting_at, account, party_type, amount, source_kind, source_id)
VALUES (?,?,?,?,?,?,?)`,
vid, v.PostingAt, l.Account, string(a.Party), l.Debit-l.Credit, v.SourceKind, v.SourceID); err != nil {
return false, fmt.Errorf("books post payment_ledger_entry: %w", err)
}
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
// counterAccounts returns, per account, a summary of the OTHER accounts in the voucher —
// the "against" column ERPNext keeps so a single GL row shows what it moved against.
func counterAccounts(legs []Leg) map[string]string {
out := make(map[string]string, len(legs))
for _, a := range legs {
var s string
for _, b := range legs {
if b.Account == a.Account {
continue
}
if s != "" {
s += ","
}
s += b.Account
}
out[a.Account] = s
}
return out
}
// GLRow is one persisted GL Entry, as the read API surfaces it.
type GLRow struct {
ID int64 `json:"id"`
PostingAt string `json:"postingAt"`
Account string `json:"account"`
Debit int64 `json:"debit"`
Credit int64 `json:"credit"`
Against string `json:"against,omitempty"`
SourceKind string `json:"sourceKind"`
SourceID string `json:"sourceId"`
Remarks string `json:"remarks,omitempty"`
}
// listGL returns the most recent GL Entry rows (newest first), capped at limit.
func (s *store) listGL(ctx context.Context, limit int) ([]GLRow, error) {
if limit <= 0 || limit > 5000 {
limit = 500
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, posting_at, account, debit, credit, against, source_kind, source_id, remarks
FROM gl_entry ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("books listGL: %w", err)
}
defer func() { _ = rows.Close() }()
out := []GLRow{}
for rows.Next() {
var r GLRow
if err := rows.Scan(&r.ID, &r.PostingAt, &r.Account, &r.Debit, &r.Credit, &r.Against, &r.SourceKind, &r.SourceID, &r.Remarks); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// listAccounts returns the seeded chart of accounts (number-ascending within class,
// exactly the seed order via id ordering is not guaranteed, so order by number).
func (s *store) listAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT number, name, type, party FROM account ORDER BY number`)
if err != nil {
return nil, fmt.Errorf("books listAccounts: %w", err)
}
defer func() { _ = rows.Close() }()
out := []Account{}
for rows.Next() {
var a Account
var typ, party string
if err := rows.Scan(&a.Number, &a.Name, &typ, &party); err != nil {
return nil, err
}
a.Type = AccountType(typ)
a.Party = PartyType(party)
out = append(out, a)
}
return out, rows.Err()
}
// sums is the debit/credit total per account over an optional posting-time window:
// startExclusive (>) and endInclusive (<=); either "" drops that bound. It is the ONE
// aggregation the trial balance composes into opening/period/closing.
type sums map[string][2]int64 // account → {debit, credit}
func (s *store) sums(ctx context.Context, startExclusive, endInclusive string) (sums, error) {
q := `SELECT account, COALESCE(SUM(debit),0), COALESCE(SUM(credit),0) FROM gl_entry`
var args []any
var where []string
if startExclusive != "" {
where = append(where, "posting_at > ?")
args = append(args, startExclusive)
}
if endInclusive != "" {
where = append(where, "posting_at <= ?")
args = append(args, endInclusive)
}
for i, w := range where {
if i == 0 {
q += " WHERE " + w
} else {
q += " AND " + w
}
}
q += " GROUP BY account"
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("books sums: %w", err)
}
defer func() { _ = rows.Close() }()
out := sums{}
for rows.Next() {
var acct string
var d, c int64
if err := rows.Scan(&acct, &d, &c); err != nil {
return nil, err
}
out[acct] = [2]int64{d, c}
}
return out, rows.Err()
}
// cursorState is the ingestion high-water mark for this org's books.
type cursorState struct {
LastAt string
LastID string
}
func (s *store) cursor(ctx context.Context) (cursorState, error) {
row := s.db.QueryRowContext(ctx, `SELECT last_at, last_id FROM cursor WHERE id = 1`)
var cs cursorState
err := row.Scan(&cs.LastAt, &cs.LastID)
if err == sql.ErrNoRows {
return cursorState{}, nil
}
if err != nil {
return cursorState{}, fmt.Errorf("books cursor: %w", err)
}
return cs, nil
}
func (s *store) setCursor(ctx context.Context, cs cursorState) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO cursor (id, last_at, last_id) VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET last_at = excluded.last_at, last_id = excluded.last_id`,
cs.LastAt, cs.LastID)
if err != nil {
return fmt.Errorf("books setCursor: %w", err)
}
return nil
}