mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Square keeps an event's created_at across every retry, and the receiver refused anything older than five minutes, so every retry of an event first sent while cloud was down (Recreate, ten rollouts a day) was refused until Square gave up. The verifier now lives in payment/providers/square and needs only the key and the URL; thirdparty/square is the API client and no longer registers itself or verifies anything, so every registry holds one Square type. Replay is stopped by the event id instead: each verified event is recorded once for the whole deployment, keyed on the verifying processor and the event id with an insert-if-absent swap, so a repeat is a duplicate under any path or org. The path names the one processor that verifies, and only one that holds its webhook secret may: an unknown name, or a verifier that needs no secret, is refused rather than tried. The books an event lands in are the verifying account's, else the deployment's brand — never a header or session, which on a replay are the replayer's. An effect that did not land (a store or the ledger did not answer) leaves the delivery open with a 503, so the provider redelivers; it used to be acknowledged and never retried. A settlement reads the payment index before the org's books, because an in-session charge's receipt is in the payer's org. POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so standalone and embedded commerce serve the same address; the /v1/billing/webhooks spelling is gone. thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer serves, and was off in production anyway, so per-org payment credentials never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through the host's reader, the caller re-pointed at that org with zip.ActingAs so the KMS tenancy check admits it, and payment.Hydrate loads an org's credentials where the org receives the money: storefront checkout, storefront subscriptions and wire, and the brand's bank for the billing wire rail. Hanzo's own billing no longer reads the paying org's credentials; it charges the deployment's account, as it did. The standalone binary reads through hanzoai/kms/sdk/go.
425 lines
16 KiB
Go
425 lines
16 KiB
Go
// Copyright 2014-present Hanzo AI Inc. Licensed under MIT OR Apache-2.0.
|
|
|
|
package billing
|
|
|
|
// The INVOICE LIFECYCLE, as cores that take values instead of requests.
|
|
//
|
|
// Same split, same reason as payment_core.go: create, issue, collect and void
|
|
// were reachable only through a *zip.Ctx, so the whole outbound-billing lifecycle
|
|
// was invisible to the registry that yields the OpenAPI operation, the MCP tool,
|
|
// the SDK method and the CLI command. An agent could read invoices and download
|
|
// their PDFs and could not raise one.
|
|
//
|
|
// The lifecycle is a STATE MACHINE and the model owns it (billinginvoice's
|
|
// Finalize / MarkPaid / MarkVoid each refuse from the wrong state), so these
|
|
// functions deliberately do not re-check what the model already refuses. They
|
|
// resolve the row, ask the model to move, persist, and emit — and where the model
|
|
// says no, that no is the caller's 400.
|
|
//
|
|
// COLLECTION IS NOT A SEPARATE PAYMENT PATH. CollectInvoice runs the same
|
|
// credits → balance → card waterfall the dunning workflow runs, on the same
|
|
// per-org Square processor the card top-up charges. What lives here is the guard
|
|
// and the emit around it, which is exactly what the HTTP handler had; nothing
|
|
// about how money moves is restated.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/billing/engine"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/events"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/models/billinginvoice"
|
|
"github.com/hanzoai/commerce/models/idempotencykey"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// InvoiceLine is one charge on an invoice.
|
|
type InvoiceLine struct {
|
|
// Description is the human-readable line, e.g. "Advisory retainer — August".
|
|
Description string `json:"description"`
|
|
// Amount is the line total in whole cents.
|
|
Amount int64 `json:"amount"`
|
|
// Quantity is the number of units, when the line is metered. Optional.
|
|
Quantity int64 `json:"quantity,omitempty"`
|
|
// UnitPrice is the per-unit price in cents, when the line is metered. Optional.
|
|
UnitPrice int64 `json:"unitPrice,omitempty"`
|
|
}
|
|
|
|
// InvoiceIn is a draft invoice to raise.
|
|
type InvoiceIn struct {
|
|
// UserID is the customer this invoice bills. Required.
|
|
UserID string
|
|
// CustomerEmail is where the invoice is sent. Optional.
|
|
CustomerEmail string
|
|
// Currency is the ISO code. Empty means the model default (usd).
|
|
Currency string
|
|
// Lines are the charges. The subtotal and amount due are computed from them.
|
|
Lines []InvoiceLine
|
|
}
|
|
|
|
// InvoiceView is an invoice as a TYPED value. The HTTP handlers answer with a
|
|
// map built by invoiceResponse; this is the same facts with a schema, which is
|
|
// what lets the lifecycle be published as tools an agent can actually read.
|
|
type InvoiceView struct {
|
|
// ID is the invoice id.
|
|
ID string `json:"id"`
|
|
// Number is the human-facing invoice number, e.g. "INV-0042". Empty until issued.
|
|
Number string `json:"number,omitempty"`
|
|
// UserID is the customer billed.
|
|
UserID string `json:"userId"`
|
|
// CustomerEmail is where it is sent.
|
|
CustomerEmail string `json:"customerEmail,omitempty"`
|
|
// Status is draft, open, paid, void or uncollectible.
|
|
Status string `json:"status"`
|
|
// Currency is the ISO code.
|
|
Currency string `json:"currency"`
|
|
// SubtotalCents is the sum of the lines, before tax, discount and credit.
|
|
SubtotalCents int64 `json:"subtotalCents"`
|
|
// AmountDueCents is what remains collectible.
|
|
AmountDueCents int64 `json:"amountDueCents"`
|
|
// AmountPaidCents is what has been collected so far.
|
|
AmountPaidCents int64 `json:"amountPaidCents"`
|
|
// Lines are the charges on the invoice.
|
|
Lines []InvoiceLine `json:"lines,omitempty"`
|
|
// PaymentRef is the processor reference for the collection, once paid.
|
|
PaymentRef string `json:"paymentRef,omitempty"`
|
|
// CreatedAt is when the draft was raised, RFC3339.
|
|
CreatedAt string `json:"createdAt,omitempty"`
|
|
}
|
|
|
|
// InvoiceCollection is the outcome of attempting to collect an invoice.
|
|
type InvoiceCollection struct {
|
|
// Invoice is the invoice after the attempt — the authoritative status.
|
|
Invoice *InvoiceView `json:"invoice"`
|
|
// Paid reports whether the invoice is now settled in full.
|
|
Paid bool `json:"paid"`
|
|
// CreditUsedCents is how much was covered by credit grants.
|
|
CreditUsedCents int64 `json:"creditUsedCents"`
|
|
// BalanceUsedCents is how much was covered by prepaid balance.
|
|
BalanceUsedCents int64 `json:"balanceUsedCents"`
|
|
// CardChargedCents is how much was charged to the card on file.
|
|
CardChargedCents int64 `json:"cardChargedCents"`
|
|
// ProcessorRef is the processor's reference for any card charge.
|
|
ProcessorRef string `json:"processorRef,omitempty"`
|
|
// Reason explains a decline or partial collection. Empty on success.
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// viewInvoice projects the model onto the typed view. ONE projection, so the
|
|
// lifecycle ops cannot describe the same invoice two ways.
|
|
func viewInvoice(inv *billinginvoice.BillingInvoice) *InvoiceView {
|
|
v := &InvoiceView{
|
|
ID: inv.Id(),
|
|
Number: inv.NumberStr,
|
|
UserID: inv.UserId,
|
|
CustomerEmail: inv.CustomerEmail,
|
|
Status: string(inv.Status),
|
|
Currency: string(inv.Currency),
|
|
SubtotalCents: inv.Subtotal,
|
|
AmountDueCents: inv.AmountDue,
|
|
AmountPaidCents: inv.AmountPaid,
|
|
PaymentRef: inv.PaymentRef,
|
|
}
|
|
for _, li := range inv.LineItems {
|
|
v.Lines = append(v.Lines, InvoiceLine{
|
|
Description: li.Description,
|
|
Amount: li.Amount,
|
|
Quantity: li.Quantity,
|
|
UnitPrice: li.UnitPrice,
|
|
})
|
|
}
|
|
if !inv.CreatedAt.IsZero() {
|
|
v.CreatedAt = inv.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// loadInvoice resolves one invoice inside the org's own namespace. The namespace
|
|
// IS the tenant boundary: an id from another org is not found rather than found
|
|
// and then filtered, so there is no window in which the wrong row is in hand.
|
|
func loadInvoice(ctx context.Context, org *organization.Organization, id string) (*billinginvoice.BillingInvoice, *datastore.Datastore, *PaymentFault) {
|
|
if org == nil {
|
|
return nil, nil, fault(401, "missing identity headers", nil)
|
|
}
|
|
if strings.TrimSpace(id) == "" {
|
|
return nil, nil, fault(400, "invoice id is required", nil)
|
|
}
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
inv := billinginvoice.New(db)
|
|
if err := inv.GetById(strings.TrimSpace(id)); err != nil {
|
|
return nil, nil, fault(404, "invoice not found", err)
|
|
}
|
|
return inv, db, nil
|
|
}
|
|
|
|
// RaiseInvoice creates a DRAFT invoice against the org's customer.
|
|
//
|
|
// A draft is deliberately not collectible: it exists to be edited and then
|
|
// issued, and IssueInvoice is what makes it a demand for payment. Raising and
|
|
// issuing are separate acts because an invoice that became collectible the moment
|
|
// it was created could be collected before anyone had read it.
|
|
func RaiseInvoice(ctx context.Context, org *organization.Organization, in InvoiceIn) (*InvoiceView, *PaymentFault) {
|
|
inv, f := raiseInvoice(ctx, org, in)
|
|
if f != nil {
|
|
return nil, f
|
|
}
|
|
return viewInvoice(inv), nil
|
|
}
|
|
|
|
// raiseInvoice is the worker: it returns the MODEL, so the HTTP handler can render
|
|
// its long-standing invoiceResponse body while the typed op renders InvoiceView.
|
|
// Two projections of one result, never two implementations.
|
|
func raiseInvoice(ctx context.Context, org *organization.Organization, in InvoiceIn) (*billinginvoice.BillingInvoice, *PaymentFault) {
|
|
if org == nil {
|
|
return nil, fault(401, "missing identity headers", nil)
|
|
}
|
|
if strings.TrimSpace(in.UserID) == "" {
|
|
return nil, fault(400, "userId is required", nil)
|
|
}
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
inv := billinginvoice.New(db)
|
|
inv.UserId = strings.TrimSpace(in.UserID)
|
|
inv.CustomerEmail = strings.TrimSpace(in.CustomerEmail)
|
|
if c := normalizeCurrency(in.Currency); c != "" {
|
|
inv.Currency = c
|
|
}
|
|
for _, l := range in.Lines {
|
|
inv.LineItems = append(inv.LineItems, billinginvoice.LineItem{
|
|
Description: l.Description,
|
|
Amount: l.Amount,
|
|
Quantity: l.Quantity,
|
|
UnitPrice: l.UnitPrice,
|
|
})
|
|
}
|
|
// The subtotal is COMPUTED from the lines, never taken from the caller: a
|
|
// caller-supplied total that disagreed with its own lines is an invoice that
|
|
// bills a number nobody can derive.
|
|
inv.RecalculateSubtotal()
|
|
if err := inv.Create(); err != nil {
|
|
log.Error("Failed to create invoice: %v", err)
|
|
return nil, fault(500, "failed to create invoice", err)
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
// ReadInvoice returns one invoice from the org's own namespace.
|
|
func ReadInvoice(ctx context.Context, org *organization.Organization, id string) (*InvoiceView, *PaymentFault) {
|
|
inv, _, f := loadInvoice(ctx, org, id)
|
|
if f != nil {
|
|
return nil, f
|
|
}
|
|
return viewInvoice(inv), nil
|
|
}
|
|
|
|
// IssueInvoice moves a draft to OPEN — the act that makes it collectible and
|
|
// gives it its number.
|
|
//
|
|
// The model refuses from any state but draft, and that refusal is the caller's
|
|
// 400 rather than something re-checked here: one state machine, in one place.
|
|
func IssueInvoice(ctx context.Context, org *organization.Organization, id string, ev *events.Client) (*InvoiceView, *PaymentFault) {
|
|
inv, f := issueInvoice(ctx, org, id, ev)
|
|
if f != nil {
|
|
return nil, f
|
|
}
|
|
return viewInvoice(inv), nil
|
|
}
|
|
|
|
// issueInvoice is the worker. See raiseInvoice.
|
|
func issueInvoice(ctx context.Context, org *organization.Organization, id string, ev *events.Client) (*billinginvoice.BillingInvoice, *PaymentFault) {
|
|
inv, _, f := loadInvoice(ctx, org, id)
|
|
if f != nil {
|
|
return nil, f
|
|
}
|
|
if err := inv.Finalize(); err != nil {
|
|
return nil, fault(400, err.Error(), nil)
|
|
}
|
|
if err := inv.Update(); err != nil {
|
|
log.Error("Failed to finalize invoice: %v", err)
|
|
return nil, fault(500, "failed to finalize invoice", err)
|
|
}
|
|
emitInvoiceCtx(ctx, ev, org.Name, inv, evInvoiceFinalized)
|
|
return inv, nil
|
|
}
|
|
|
|
// VoidInvoiceIn cancels a draft or open invoice.
|
|
func VoidInvoiceIn(ctx context.Context, org *organization.Organization, id string, ev *events.Client) (*InvoiceView, *PaymentFault) {
|
|
inv, f := voidInvoice(ctx, org, id, ev)
|
|
if f != nil {
|
|
return nil, f
|
|
}
|
|
return viewInvoice(inv), nil
|
|
}
|
|
|
|
// voidInvoice is the worker. See raiseInvoice.
|
|
func voidInvoice(ctx context.Context, org *organization.Organization, id string, ev *events.Client) (*billinginvoice.BillingInvoice, *PaymentFault) {
|
|
inv, _, f := loadInvoice(ctx, org, id)
|
|
if f != nil {
|
|
return nil, f
|
|
}
|
|
if err := inv.MarkVoid(); err != nil {
|
|
return nil, fault(400, err.Error(), nil)
|
|
}
|
|
if err := inv.Update(); err != nil {
|
|
log.Error("Failed to void invoice: %v", err)
|
|
return nil, fault(500, "failed to void invoice", err)
|
|
}
|
|
emitInvoiceCtx(ctx, ev, org.Name, inv, evInvoiceVoid)
|
|
return inv, nil
|
|
}
|
|
|
|
// CollectInvoice attempts to settle an OPEN invoice: credits, then prepaid
|
|
// balance, then the card on file — the same waterfall a renewal runs.
|
|
//
|
|
// The per-invoice idempotency guard is what makes two concurrent collections one
|
|
// collection. A DECLINE deliberately RELEASES the guard rather than sealing it:
|
|
// sealing a decline would wedge dunning behind a replayed failure and, because
|
|
// only a successful collection emits invoicePaid, would also fire phantom revenue
|
|
// if it were ever treated as terminal. Only success is sealed.
|
|
func CollectInvoice(ctx context.Context, org *organization.Organization, id string, ev *events.Client) (*InvoiceCollection, *PaymentFault) {
|
|
oc, f := collectInvoice(ctx, org, id, ev)
|
|
if f != nil {
|
|
return nil, f
|
|
}
|
|
if oc.Replayed != nil {
|
|
// A replayed collection: the sealed body IS the answer, verbatim.
|
|
return oc.Replayed, nil
|
|
}
|
|
return &InvoiceCollection{
|
|
Invoice: viewInvoice(oc.Inv),
|
|
Paid: oc.Result.Success,
|
|
CreditUsedCents: oc.Result.CreditUsed,
|
|
BalanceUsedCents: oc.Result.BalanceUsed,
|
|
CardChargedCents: oc.Result.ProviderUsed,
|
|
ProcessorRef: oc.Result.ProviderRef,
|
|
Reason: oc.Result.Error,
|
|
}, nil
|
|
}
|
|
|
|
// collectOutcome is what the collection worker learned, carried by VALUE.
|
|
//
|
|
// It exists because a collection has two shapes of answer — a fresh attempt and a
|
|
// replay of a sealed one — and both endpoints need to tell them apart. Returning it
|
|
// is what keeps that state on the stack: a package-level var would be shared by
|
|
// every concurrent collection in the process, which on this path means one
|
|
// tenant's receipt answering another tenant's request.
|
|
type collectOutcome struct {
|
|
// Inv is the invoice after the attempt.
|
|
Inv *billinginvoice.BillingInvoice
|
|
// Result is the fresh collection outcome. Nil when Replayed is set.
|
|
Result *engine.CollectionResult
|
|
// Replayed is the sealed body of an earlier identical collection. Nil on a
|
|
// fresh attempt.
|
|
Replayed *InvoiceCollection
|
|
}
|
|
|
|
// collectInvoice is the worker. See raiseInvoice.
|
|
func collectInvoice(ctx context.Context, org *organization.Organization, id string, ev *events.Client) (*collectOutcome, *PaymentFault) {
|
|
inv, db, f := loadInvoice(ctx, org, id)
|
|
if f != nil {
|
|
return nil, f
|
|
}
|
|
|
|
rec, replay, gerr := idempotencykey.Begin(db, "billing-pay", "invoice:"+inv.Id())
|
|
if gerr == nil && replay {
|
|
if rec.Status == idempotencykey.StatusCompleted && rec.Response != "" {
|
|
var out InvoiceCollection
|
|
if err := json.Unmarshal([]byte(rec.Response), &out); err == nil {
|
|
return &collectOutcome{Inv: inv, Replayed: &out}, nil
|
|
}
|
|
}
|
|
return nil, fault(409, "invoice payment already in progress", nil)
|
|
}
|
|
|
|
result, err := engine.CollectInvoice(ctx, db, inv, prepaidFor(ctx, org), chargeProviderForOrg(org))
|
|
if err != nil {
|
|
if rec != nil {
|
|
_ = rec.Delete()
|
|
}
|
|
log.Error("Failed to collect invoice: %v", err)
|
|
return nil, fault(500, "failed to collect invoice payment", err)
|
|
}
|
|
if err := inv.Update(); err != nil {
|
|
log.Error("Failed to update invoice after payment: %v", err)
|
|
return nil, fault(500, "failed to update invoice", err)
|
|
}
|
|
|
|
out := &InvoiceCollection{
|
|
Invoice: viewInvoice(inv),
|
|
Paid: result.Success,
|
|
CreditUsedCents: result.CreditUsed,
|
|
BalanceUsedCents: result.BalanceUsed,
|
|
CardChargedCents: result.ProviderUsed,
|
|
ProcessorRef: result.ProviderRef,
|
|
Reason: result.Error,
|
|
}
|
|
if result.Success {
|
|
emitInvoiceCtx(ctx, ev, org.Name, inv, evInvoicePaid)
|
|
if rec != nil {
|
|
if body, mErr := json.Marshal(out); mErr == nil {
|
|
_ = idempotencykey.Complete(rec, string(body))
|
|
}
|
|
}
|
|
} else if rec != nil {
|
|
// Declined / partial: the invoice stays OPEN. RELEASE the guard so a later
|
|
// attempt actually RE-COLLECTS at a higher AttemptCount (fresh gateway key)
|
|
// instead of replaying a sealed failure.
|
|
_ = rec.Delete()
|
|
}
|
|
return &collectOutcome{Inv: inv, Result: result}, nil
|
|
}
|
|
|
|
// The three invoice lifecycle events, named so emitInvoiceCtx can dispatch on a
|
|
// value rather than the caller reaching for a client method directly.
|
|
type invoiceEventKind int
|
|
|
|
const (
|
|
evInvoiceFinalized invoiceEventKind = iota
|
|
evInvoicePaid
|
|
evInvoiceVoid
|
|
)
|
|
|
|
// emitInvoiceCtx is fireEvent for a caller that has a context instead of a
|
|
// request. Best-effort and never on the money path's critical line: a missing
|
|
// collector is a no-op, exactly as it is for the HTTP handlers.
|
|
func emitInvoiceCtx(ctx context.Context, ev *events.Client, orgName string, inv *billinginvoice.BillingInvoice, kind invoiceEventKind) {
|
|
if ev == nil {
|
|
return
|
|
}
|
|
// WithoutCancel (not Background): survive client disconnect but keep trace values.
|
|
detached := context.WithoutCancel(ctx)
|
|
payload := invoiceEvent(orgName, inv)
|
|
go func() {
|
|
switch kind {
|
|
case evInvoiceFinalized:
|
|
ev.EmitInvoiceFinalized(detached, payload)
|
|
case evInvoicePaid:
|
|
ev.EmitInvoicePaid(detached, payload)
|
|
case evInvoiceVoid:
|
|
ev.EmitInvoiceVoid(detached, payload)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// currencyOrEmpty keeps RaiseInvoice from stamping a currency the model would
|
|
// otherwise default itself.
|
|
var _ = currency.USD
|
|
|
|
// eventsOf lifts the request-scoped analytics client a core takes as a value off a
|
|
// *zip.Ctx. It exists so the HTTP endpoint — and only the HTTP endpoint — knows that
|
|
// it lives in locals; the cores take it as a parameter and are therefore callable
|
|
// from a typed op, a cron, or a test with no request at all.
|
|
func eventsOf(c *zip.Ctx) *events.Client {
|
|
if v := c.Locals("events"); v != nil {
|
|
if ev, ok := v.(*events.Client); ok {
|
|
return ev
|
|
}
|
|
}
|
|
return nil
|
|
}
|