Documentation
¶
Overview ¶
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.
Index ¶
- Constants
- func Mount(app cloud.Router, deps cloud.Deps) error
- func Shutdown() error
- type Account
- type AccountType
- type AskRequest
- type AskResponse
- type BalanceLine
- type BalanceSheet
- type BankQuestion
- type BankResult
- type BankTally
- type BankTxn
- type BankTxnRow
- type BookRequest
- type BookResponse
- type Connector
- type Direction
- type Extracted
- type Figure
- type FinancialPackage
- type GLRow
- type Importer
- type InboxItem
- type Leg
- type LineItem
- type Metrics
- type MetricsResponse
- type PartyType
- type PnL
- type PnLLine
- type Question
- type QuestionsResponse
- type Rule
- type ScanDraft
- type TellerLink
- type TrialBalance
- type TrialBalanceRow
- type Txn
- type Vendor
- type Voucher
Constants ¶
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 VendorPayable = "2001" // accounts payable to a vendor for a scanned bill (party) OSSPayout = "2100" // owed to OSS maintainers (party) SalesTaxPayable = "2200" // collected sales tax owed to authorities (party) AccruedInfra = "2300" // accrued cloud/GPU infra cost owed (COGS accrual sink) 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 // Scanner expense categories — the fixed buckets a scanned bill (scan.go) maps its // merchant/category into. Each is a real debit-normal expense account so a booked bill // always lands in a named line, never a guessed one; an unrecognized category falls to // 5900 Uncategorized (below) for a human to reclassify. SoftwareExpense = "5300" // software & SaaS subscriptions OfficeExpense = "5400" // office supplies & equipment TravelExpense = "5500" // travel & transport MealsExpense = "5600" // meals & entertainment MarketingExpense = "5700" // marketing & advertising ServicesExpense = "5800" // professional & contractor services // UncategorizedExpense is the DEFAULT debit sink for a bank OUTFLOW the bank // engine could not categorize (bank_map.go). It exists so an outflow always // books against a real expense account — never a guessed one — and surfaces as // a bucket a human recategorizes, rather than silently landing in COGS. UncategorizedExpense = "5900" // outflow pending human categorization )
Account numbers — the ONE set of posting keys the rule map and reports reference.
const RoundOffAllowance int64 = 2
RoundOffAllowance bounds the debit−credit 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 1–2¢ artifact of an upstream split; a larger gap is a real imbalance and MUST fail closed rather than silently plug equity.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Account ¶
type Account struct {
Number string `json:"number"`
Name string `json:"name"`
Type AccountType `json:"type"`
Party PartyType `json:"party,omitempty"`
}
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 AccountType ¶
type AccountType string
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.
const ( Asset AccountType = "asset" Liability AccountType = "liability" Income AccountType = "income" Expense AccountType = "expense" Equity AccountType = "equity" )
type AskRequest ¶
type AskRequest struct {
Question string `json:"question"`
// From/To optionally scope the metric window (RFC3339). Empty = all-time, treated as a
// single reporting period (see monthsBetween).
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
}
AskRequest is the POST /v1/books/ask body.
type AskResponse ¶
type AskResponse struct {
Answer string `json:"answer"`
Figures []Figure `json:"figures"`
Followups []string `json:"followups"`
Sources []string `json:"sources"`
}
AskResponse is the Ask contract: a natural-language answer grounded in Figures, with followup questions and the report Sources the figures were computed from.
type BalanceLine ¶
type BalanceLine struct {
Account string `json:"account,omitempty"`
Name string `json:"name"`
Type AccountType `json:"type,omitempty"`
Amount int64 `json:"amount"` // cents, display sign
}
BalanceLine is one line of the sheet, in NATURAL (positive in normal operation) sign. A derived line (retained earnings) carries no account number.
type BalanceSheet ¶
type BalanceSheet struct {
AsOf string `json:"asOf,omitempty"`
Assets []BalanceLine `json:"assets"`
Liabilities []BalanceLine `json:"liabilities"`
Equity []BalanceLine `json:"equity"`
TotalAssets int64 `json:"totalAssets"`
TotalLiabilities int64 `json:"totalLiabilities"`
TotalEquity int64 `json:"totalEquity"`
Balanced bool `json:"balanced"` // TotalAssets == TotalLiabilities + TotalEquity
}
BalanceSheet is the point-in-time statement as of a posting time, with the equation proof.
type BankQuestion ¶
type BankQuestion struct {
Connector string `json:"connector"`
ExternalID string `json:"externalId"`
Prompt string `json:"prompt"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
}
BankQuestion is one open clarifying question about an unmatched bank inflow — the persistence row. It adapts to the founder-facing canonical Question (anomalies.go) via [BankQuestion.toQuestion] so the one /v1/books/questions surface can present anomaly and bank questions through a single type.
type BankResult ¶
type BankResult struct {
Status string `json:"status"`
VoucherPosted bool `json:"voucherPosted"`
QuestionRaised bool `json:"questionRaised"`
Skipped bool `json:"skipped"` // already processed (idempotent no-op)
}
BankResult reports what mapAndPost did with one transaction — for the sync/import response tallies and for tests to assert the taken path.
type BankTally ¶
type BankTally struct {
Ingested int `json:"ingested"` // transactions seen
Posted int `json:"posted"` // vouchers newly posted (outflow + reconciled)
Reconciled int `json:"reconciled"` // inflows cleared against Square-clearing
Questions int `json:"questions"` // unmatched inflows that raised a question
Transfers int `json:"transfers"` // own-account moves recorded (no P&L)
Skipped int `json:"skipped"` // already-processed idempotent no-ops
}
BankTally is the per-request summary of a bank ingest (import or sync).
type BankTxn ¶
type BankTxn struct {
Connector string `json:"connector"`
ExternalID string `json:"externalId"`
PostedAt string `json:"postedAt"` // RFC3339 posting time, ledger-comparable
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Description string `json:"description"`
Merchant string `json:"merchant"`
Direction Direction `json:"direction"`
}
BankTxn is the normalized bank transaction — the single shape every connector emits and the bank engine consumes. Connector + ExternalID is the idempotency key: the same statement row, re-imported or re-synced, always carries the same pair, so it maps and posts exactly once.
type BankTxnRow ¶
type BankTxnRow struct {
Connector string `json:"connector"`
ExternalID string `json:"externalId"`
PostedAt string `json:"postedAt"`
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
Direction Direction `json:"direction"`
Description string `json:"description,omitempty"`
Merchant string `json:"merchant,omitempty"`
MatchedVoucher string `json:"matchedVoucher,omitempty"`
Status string `json:"status"`
}
BankTxnRow is a persisted bank_txn as the read API surfaces it.
type BookRequest ¶
type BookRequest struct {
ScanID string `json:"scanId"`
Voucher Voucher `json:"voucher"`
// Override books this bill even when one of the SAME economic identity
// (vendor, total, issue date) already posted — the explicit human confirmation that a
// same-looking bill is a genuine second spend, not the same receipt re-scanned.
Override bool `json:"override,omitempty"`
}
BookRequest is the POST /v1/books/scan/book body: the reviewed voucher (the frontend may have edited any field) plus the ScanID that keys idempotency. SourceKind/SourceID are FORCED server-side to (scan, ScanID) so a client can never post a scan under another source's key or double-book by editing the id.
type BookResponse ¶
BookResponse reports the outcome of a scan book: posted=false means the same scan already booked (idempotent no-op), never a second voucher.
type Connector ¶
type Connector interface {
Name() string
Fetch(ctx context.Context, org, cursor string) (txns []BankTxn, nextCursor string, err error)
}
Connector is the pull seam to one bank source. Name is the stable connector id (the bank_txn.connector / cursor key). Fetch returns the batch of transactions AFTER cursor plus the nextCursor to persist; a connector resolves its own credentials from KMS inside Fetch and NEVER returns or logs them. A returned nextCursor equal to the input cursor means "no advance" (nothing new).
type Direction ¶
type Direction string
Direction is a bank transaction's money direction relative to our account. It pairs with the (always non-negative) AmountCents magnitude: an Outflow is money leaving the account (an expense), an Inflow is money arriving (a deposit — reconciled or new), and a Transfer is a move between our OWN accounts with no profit-and-loss effect.
type Extracted ¶
type Extracted struct {
Merchant string `json:"merchant"`
IssuedAt string `json:"issuedAt"` // YYYY-MM-DD
TotalCents int64 `json:"totalCents"`
TaxCents int64 `json:"taxCents"`
Currency string `json:"currency"`
Category string `json:"category"` // proposed slug (software|cloud|office|…)
LineItems []LineItem `json:"lineItems,omitempty"`
Note string `json:"note,omitempty"`
}
Extracted is the structured shape the AI extracts from a receipt/invoice. Amounts are exact int64 cents (the AI is instructed to return integer cents, never a decimal), so no float rounding can enter downstream. Category is the AI's proposed slug — a HINT that vendor/rule resolution overrides when it knows better.
type Figure ¶
type Figure struct {
Label string `json:"label"`
Value string `json:"value"`
Period string `json:"period,omitempty"`
}
Figure is one grounded number in the answer: a label, its formatted value, and the period it covers. This is the exact shape the Ask contract fixes.
type FinancialPackage ¶
type FinancialPackage struct {
Org string `json:"org"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
GeneratedAt string `json:"generatedAt"`
TrialBalance TrialBalance `json:"trialBalance"`
PnL PnL `json:"pnl"`
BalanceSheet BalanceSheet `json:"balanceSheet"`
GL []GLRow `json:"gl"`
}
FinancialPackage is the export bundle over a [From, To] period. GLLimit rows of the most recent GL detail are included as the audit trail behind the statements.
type GLRow ¶
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"`
}
GLRow is one persisted GL Entry, as the read API surfaces it.
type Importer ¶
Importer is the OPTIONAL capability a push-based connector (OFX/QFX/CSV import) adds on top of Connector: it parses an uploaded file body into BankTxns. The import route type-asserts to it, so a connector that has not yet implemented parsing simply is not an Importer and the route reports "not yet available" rather than mis-handling the upload.
type InboxItem ¶
type InboxItem struct {
ID string `json:"id"` // the file hash (== a scan's ScanID)
Filename string `json:"filename,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
Extracted *Extracted `json:"extracted,omitempty"`
Vendor string `json:"vendor,omitempty"`
Category string `json:"category,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
InboxItem is one queued document as GET /v1/books/inbox surfaces it: its hash id, filename, status, and — once scanned — the extracted summary and its confidence.
type Leg ¶
type Leg struct {
Account string `json:"account"`
Debit int64 `json:"debit"`
Credit int64 `json:"credit"`
}
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 LineItem ¶
type LineItem struct {
Description string `json:"description"`
AmountCents int64 `json:"amountCents"`
}
LineItem is one line of a scanned document — a description and its amount in exact cents.
type Metrics ¶
type Metrics struct {
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Period string `json:"period"` // human label, e.g. "2026-07" or "all-time"
Months int `json:"months"` // window length used to normalize MRR / burn
MRR int64 `json:"mrr"` // recurring revenue (4100) per month
ARR int64 `json:"arr"` // MRR * 12
Revenue int64 `json:"revenue"` // recognized revenue (all Income roots) over the period
COGS int64 `json:"cogs"` // cost of goods (5000) over the period
Burn int64 `json:"burn"` // total expense (all Expense roots) over the period
GrossProfit int64 `json:"grossProfit"` // Revenue − COGS
GrossMarginBps int64 `json:"grossMarginBps"` // GrossProfit / Revenue in basis points (7000 = 70%)
NetIncome int64 `json:"netIncome"` // Revenue − Burn
Cash int64 `json:"cash"` // Bank (1000) + Square clearing (1010) as of `to`
DeferredRevenue int64 `json:"deferredRevenue"` // Customer Wallet (2000) liability as of `to`
MonthlyBurn int64 `json:"monthlyBurn"` // net cash burned per month (>0 ⇒ losing cash)
RunwayMonths int64 `json:"runwayMonths"` // Cash / MonthlyBurn; -1 = infinite (not burning)
}
Metrics is the deterministic SaaS-metrics snapshot over a reporting window. Every field is int64 CENTS except the two ratios/counters (basis points, months), matching the ledger's exact-integer money model. RunwayMonths == -1 is the INFINITE sentinel (the org is not burning cash), never a real month count.
type MetricsResponse ¶
MetricsResponse is the GET /v1/books/metrics payload: the raw deterministic snapshot (int64 cents, the canonical numbers) PLUS the same figures already formatted through the ONE money formatter (formatUSD), so a consumer surfaces books' numbers in books' format and never re-derives either. It is the single grounded read the unified /v1/ask advisor composes — every figure is the ledger, aggregated, never a guess.
type PartyType ¶
type PartyType string
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 PnL ¶
type PnL struct {
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Income []PnLLine `json:"income"`
Expense []PnLLine `json:"expense"`
TotalIncome int64 `json:"totalIncome"`
TotalExpense int64 `json:"totalExpense"`
NetIncome int64 `json:"netIncome"` // TotalIncome − TotalExpense
}
PnL is the period income statement: income lines, expense lines, and the net. The period is (From, To] — movement strictly after From, up to and including To — matching the trial balance's opening/closing boundary convention (from exclusive).
type PnLLine ¶
type PnLLine struct {
Account string `json:"account"`
Name string `json:"name"`
Type AccountType `json:"type"`
Amount int64 `json:"amount"` // cents, display sign (income & expense both positive when normal)
}
PnLLine is one account's contribution to the statement, shown in its NATURAL (positive in normal operation) sign: income as its credit balance, expense as its debit balance.
type Question ¶
type Question struct {
ID string `json:"id"` // the source transaction id it concerns
Kind string `json:"kind"` // outlier|reversal|roundoff|uncosted|overdrawn
Text string `json:"text"` // the specific question to ask the founder
Amount string `json:"amount"` // formatted figure ($…)
Account string `json:"account,omitempty"`
PostedAt string `json:"postedAt,omitempty"`
}
Question is one clarifying question about an unusual transaction: the sharp text, the formatted amount that makes it concrete, and the source/account/time that anchor it.
type QuestionsResponse ¶
type QuestionsResponse struct {
Questions []Question `json:"questions"`
}
QuestionsResponse is the GET /v1/books/questions payload.
type Rule ¶
type Rule struct {
Pattern string `json:"pattern"`
Category string `json:"category"` // COA account number
Priority int `json:"priority"`
}
Rule is one categorization rule: a merchant substring pattern, the category (COA account) it books to, and a priority (higher wins when several patterns match).
type ScanDraft ¶
type ScanDraft struct {
ScanID string `json:"scanId"` // file hash — the (scan, id) idempotency key
Extracted Extracted `json:"extracted"`
Vendor string `json:"vendor"`
Category string `json:"category"` // resolved COA expense account number
Confidence string `json:"confidence"` // auto | low
Voucher Voucher `json:"voucher"` // PROPOSED — not yet posted
Balanced bool `json:"balanced"` // Σdebit == Σcredit (always true when built)
Questions []Question `json:"questions,omitempty"`
}
ScanDraft is the POST /v1/books/scan response: the extracted fields plus a PROPOSED balanced voucher that has NOT been posted. Confidence is "auto" when a vendor/rule resolved the category, else "low" — in which case Questions carries a clarifying prompt and the frontend should confirm the category (and can persist a rule) before booking.
type TellerLink ¶
type TellerLink struct {
ApplicationID string `json:"applicationId"`
Environment string `json:"environment"`
}
TellerLink is the client-side config for Teller Connect (the browser widget that produces an enrollment). application_id is PUBLIC (not a secret); environment selects sandbox vs production. This is the link-token analog — Teller Connect runs client-side, so there is no server-minted token, only this public config.
type TrialBalance ¶
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 is the whole-ledger report: per-account rows + totals + the balance proof.
type TrialBalanceRow ¶
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"`
}
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 Txn ¶
type Txn struct {
Date string `json:"date"`
Description string `json:"description"`
Vendor string `json:"vendor,omitempty"`
Category string `json:"category"` // COA account number of the P&L line
CategoryName string `json:"categoryName,omitempty"`
Source string `json:"source"` // source_kind: bank_txn | scan | commerce_txn
AmountCents int64 `json:"amountCents"`
VoucherID int64 `json:"voucherId"`
}
Txn is one register row — a booked voucher projected to a single line.
type Vendor ¶
type Vendor struct {
Canonical string `json:"canonical"`
Aliases []string `json:"aliases,omitempty"`
DefaultCategory string `json:"defaultCategory,omitempty"` // COA account number
}
Vendor is one row of the vendor book: a canonical name, its alias spellings, and the COA expense account new bills from it default to.
type Voucher ¶
type Voucher struct {
SourceKind string `json:"sourceKind"`
SourceID string `json:"sourceId"`
PostingAt string `json:"postingAt"`
Description string `json:"description"`
Legs []Leg `json:"legs"`
}
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.