Files
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

286 lines
8.9 KiB
Go

package billing
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json/http"
)
// Document is a rendered attachment: the bytes and the base name they are
// offered under, without the extension the endpoint appends. The name travels with
// the bytes because it is derived from the row, and a reader that rebuilt it
// would need its own copy of that rule.
type Document struct {
Filename string `json:"filename"`
Body []byte `json:"body"`
}
// InvoicePDF renders one invoice as an attachment — the RENDER, with no HTTP in
// it.
//
// It takes values so a caller that is not a request can ask: the download is
// served by a peer process that holds no ledger, and the alternative is shipping
// the invoice row across and re-rendering it there, which is a second renderer
// and therefore a second-looking invoice.
//
// Bytes cross this seam happily because the render is a PURE function of the
// row — one page, no timestamps, no random ids — so the same invoice renders the
// same bytes however often it is asked for, and a retry costs a re-render and
// nothing else.
//
// The org scopes the lookup at the storage layer, so an id from another tenant
// resolves to nothing rather than being found and then filtered.
func InvoicePDF(ctx context.Context, org *organization.Organization, id string) (*Document, error) {
if org == nil {
return nil, errors.New("invoice pdf: no organization")
}
inv := billinginvoice.New(datastore.New(org.Namespaced(ctx)))
if err := inv.GetById(id); err != nil {
return nil, fmt.Errorf("invoice not found: %w", err)
}
orgName := org.FullName
if orgName == "" {
orgName = org.Name
}
name := inv.NumberStr
if name == "" {
name = "invoice-" + inv.Id()
}
return &Document{Filename: name, Body: renderInvoicePDF(inv, orgName)}, nil
}
// DownloadInvoicePDF renders and serves an invoice as a PDF.
//
// GET /v1/billing/invoices/:id/pdf
//
// Tenant isolation: the invoice is loaded from the caller's OWN org namespace
// (datastore.New(org.Namespaced(c.Context()))). GetById scopes the lookup to that
// namespace at the storage layer, so an org can only ever fetch its own
// invoice — a foreign id resolves to nothing and returns 404. It is mounted on
// the user group so a normal authenticated org member can download their own
// invoice.
func DownloadInvoicePDF(c *zip.Ctx) error {
// #146 class: never panic on a missing org (co-resident embed path — see ListInvoices).
// No validated org ⇒ a clean 401 (no namespace to resolve the invoice in), never a panic.
org, ok := middleware.GetOrganizationOK(c)
if !ok || org == nil {
return http.Fail(c, 401, "authentication required", nil)
}
doc, err := InvoicePDF(c.Context(), org, c.Param("id"))
if err != nil {
return http.Fail(c, 404, "invoice not found", err)
}
c.SetHeader("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.pdf"`, doc.Filename))
c.SetHeader("Content-Type", "application/pdf")
return c.Bytes(200, doc.Body)
}
// ── Deterministic, dependency-free PDF writer ────────────────────────────────
//
// A single-page PDF-1.4 with the built-in Helvetica font. No images, no
// external fonts, no compression — the content stream is plain text so the
// invoice number and totals are greppable in the raw bytes. The output is a
// pure function of the invoice: same invoice → identical bytes (no timestamps,
// no random ids), so a render is idempotent.
// textLine is one positioned line of text in PDF user space (origin bottom-left).
type textLine struct {
x, y, size int
text string
}
// renderInvoicePDF produces the PDF bytes for an invoice. orgName defaults to
// "Hanzo" when empty (white-label: the org's own name is used when present).
func renderInvoicePDF(inv *billinginvoice.BillingInvoice, orgName string) []byte {
if orgName == "" {
orgName = "Hanzo"
}
content := buildContentStream(invoiceLines(inv, orgName))
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
// offsets[i] is the byte offset of object (i+1).
offsets := make([]int, 0, 5)
writeObj := func(body string) {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", len(offsets), body)
}
writeObj("<< /Type /Catalog /Pages 2 0 R >>")
writeObj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
writeObj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " +
"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>")
writeObj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
// Object 5: the content stream (length = raw content byte count).
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "5 0 obj\n<< /Length %d >>\nstream\n%s\nendstream\nendobj\n", len(content), content)
// Cross-reference table.
xrefOffset := buf.Len()
size := len(offsets) + 1 // + the free object 0
fmt.Fprintf(&buf, "xref\n0 %d\n", size)
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", size, xrefOffset)
return buf.Bytes()
}
// invoiceLines lays out the invoice as positioned text lines, top to bottom.
func invoiceLines(inv *billinginvoice.BillingInvoice, orgName string) []textLine {
const (
left = 72
qtyX = 300
unitX = 360
amountX = 460
)
y := 750
var lines []textLine
at := func(x, size int, text string) {
lines = append(lines, textLine{x: x, y: y, size: size, text: text})
}
// Header: org name, "Hanzo" issuer, invoice number, period, status.
at(left, 22, orgName)
y -= 28
at(left, 10, "Billed by Hanzo")
y -= 22
num := inv.NumberStr
if num == "" {
num = "INV"
}
at(left, 14, "Invoice "+num)
y -= 18
at(left, 10, "Status: "+string(inv.Status))
y -= 14
at(left, 10, "Period: "+fmtDate(inv.PeriodStart)+" to "+fmtDate(inv.PeriodEnd))
y -= 28
// Line-items table header.
at(left, 10, "Description")
at(qtyX, 10, "Qty")
at(unitX, 10, "Unit")
at(amountX, 10, "Amount")
y -= 16
for _, li := range inv.LineItems {
at(left, 10, truncate(li.Description, 45))
if li.Quantity != 0 {
at(qtyX, 10, fmt.Sprintf("%d", li.Quantity))
}
if li.UnitPrice != 0 {
at(unitX, 10, fmtAmount(inv.Currency, li.UnitPrice))
}
at(amountX, 10, fmtAmount(inv.Currency, li.Amount))
y -= 14
}
y -= 12
// Totals.
total := func(label string, cents int64) {
at(qtyX, 10, label)
at(amountX, 10, fmtAmount(inv.Currency, cents))
y -= 14
}
total("Subtotal", inv.Subtotal)
total("Tax", inv.Tax)
total("Discount", -inv.Discount)
total("Credit Applied", -inv.CreditApplied)
total("Amount Due", inv.AmountDue)
return lines
}
// buildContentStream emits the PDF text-showing operators for the lines. Each
// line sets the font, an absolute text matrix, then shows the (escaped) text.
func buildContentStream(lines []textLine) string {
var b strings.Builder
b.WriteString("BT\n")
for _, ln := range lines {
fmt.Fprintf(&b, "/F1 %d Tf\n", ln.size)
fmt.Fprintf(&b, "1 0 0 1 %d %d Tm\n", ln.x, ln.y)
fmt.Fprintf(&b, "(%s) Tj\n", escapePDFText(ln.text))
}
b.WriteString("ET")
return b.String()
}
// escapePDFText escapes a string for a PDF literal string `( … )`. The special
// bytes `\`, `(`, `)` are backslash-escaped; control/non-ASCII bytes are
// dropped so the stream stays in Helvetica's printable range and deterministic.
func escapePDFText(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '\\':
b.WriteString(`\\`)
case '(':
b.WriteString(`\(`)
case ')':
b.WriteString(`\)`)
default:
if r < 0x20 || r > 0x7e {
continue
}
b.WriteRune(r)
}
}
return b.String()
}
// fmtAmount renders a cents figure in the invoice's own currency ("$33.34",
// "-$5.00"), with no thousands separators so the amount column stays aligned to
// its absolute x position.
//
// The rendering is currency.Type's — the hand-rolled spelling this replaces
// printed cents/100, a dot, then cents%100, which needs a manual sign fix
// because Go's % keeps the sign of the dividend.
//
// An invoice written before the currency column existed carries none, and that
// column's default is usd, so an unset currency reads as usd here rather than
// rendering a bare number with no symbol at all.
func fmtAmount(cur currency.Type, cents int64) string {
if cur == "" {
cur = currency.USD
}
return cur.ToString(currency.Cents(cents))
}
// fmtDate renders a UTC calendar date (no time-of-day) for determinism.
func fmtDate(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.UTC().Format("2006-01-02")
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}