Files
commerce/api/billing/record_test.go
zeekay fa4fc53b14 Pay a recorded plan from the prepaid balance
RecordSubscription takes processor "balance": it draws the period from
the subject's prepaid money now, raises and settles that period's
invoice, and opens a regular subscription the engine renews from the
same money. The period must be the one running now and one of the
plan's; a subject holding a plan, or one gone past due, is refused. A
balance that cannot cover the period is refused with nothing written.
The ledger entry and the invoice are the reference.

Prepaid money is read and spent one way. The creditledger seam gains
Debit, all or nothing at the address Credit and Balance already name,
so with a host ledger injected the draw lands where a grant credits and
the AI gate reads, and the tier reads its balance, beside the credit
grants, from there too. Standalone, the draw is an "iam-user"
withdrawal, the kind every deposit writes. The self-serve balance
purchase, the record and every renewal draw through it, credit grants
first, exactly once per ref. The split is recorded under the ref
before the ledger is asked, so a debit whose answer was lost is posted
again as it was and the ledger answers with the posting it holds.

The collector reads the allocation first and charges the card before
any prepaid money moves, so an invoice that cannot be paid in full
takes nothing from credits or balance. What an attempt does collect
accumulates on AmountPaid, and the draw is keyed on the subscription's
period, so two invoices raised for one period draw once between them.

Periods are billed in advance. A subscription holds the period it paid
for; when that period ends the next one is invoiced, fee in advance and
metered usage in arrears, and the row moves onto it only once it is
paid. An unpaid one leaves the row past due on the period it owes;
once that period has gone by unserved it is owed no longer, its
invoice is void, and the row owes the period running now. A row that
was served nothing until it paid, past due or late, is served a whole
period from the payment. An invoice is voided only under the guard its
payment takes, so a payment landing at once is never voided over. A
row whose renewal was missed for whole periods is billed the period
running now, never every period since. A row canceled at the end of
its period ends with the last period it paid for, and an ended row's
unpaid invoices are void.

Invoices are read by their filters rather than under the synckey
ancestor an invoice loses once it is saved again, so a paid one stays
listed and found by its renewal, and a new one is numbered one past
the highest issued. The billing cycle reads
subscriptions the same way, renews only rows of the org's own mode,
and RenewDue is the sweep as a value core a schedule can call.

Claude-Session: https://claude.ai/code/session_016hTDbBTaq6r3xTQ5RAVVtF
2026-09-23 22:09:15 -07:00

255 lines
9.2 KiB
Go

package billing
import (
"context"
"testing"
"time"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/test/ae"
)
// A plan paid outside checkout: an invoice raised by hand in the processor's
// dashboard. These pin what recording it does, and what it must never do.
var (
recStart = time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC)
recEnd = time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)
)
func recordDev(t *testing.T, db *datastore.Datastore) RecordIn {
t.Helper()
p, err := resolveSubscriptionPlan(db, "dev")
if err != nil || p.Price <= 0 {
t.Fatalf("the catalog must sell a paid dev plan: %v (price %d)", err, p.Price)
}
return RecordIn{
Subject: "acme",
PlanID: "dev",
PriceCents: int64(p.Price),
PeriodStart: recStart,
PeriodEnd: recEnd,
Processor: "square",
Reference: map[string]string{"invoice": "inv:0-test", "payment": "pay_test"},
Terms: "refundable against referred business",
}
}
func subsOf(t *testing.T, db *datastore.Datastore, subject string) []*subscription.Subscription {
t.Helper()
out := make([]*subscription.Subscription, 0)
if _, err := subscription.Query(db).Filter("UserId=", subject).GetAll(&out); err != nil {
t.Fatalf("query subscriptions: %v", err)
}
return out
}
func TestRecordSubscription_OpensTheExternalPlanAndMovesNoMoney(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("rec-open")
m := squareMock("cust_1", "ccof_1", "sqpay_1")
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
in := recordDev(t, db)
before, err := bucketedSplit(org.Namespaced(ctx), in.Subject, currency.USD, org.TestMode())
if err != nil {
t.Fatalf("balance before: %v", err)
}
got, err := RecordSubscription(ctx, org, in)
if err != nil {
t.Fatalf("record: %v", err)
}
if got.Outcome != RecordCreated {
t.Fatalf("outcome=%q, want %q", got.Outcome, RecordCreated)
}
subs := subsOf(t, db, in.Subject)
if len(subs) != 1 {
t.Fatalf("subscriptions=%d, want exactly the one recorded", len(subs))
}
s := subs[0]
if s.Status != subscription.Active || s.Type != subscription.External {
t.Fatalf("status=%q type=%q, want active external", s.Status, s.Type)
}
if s.ProviderType != "square" || s.ProviderId != "" || s.DefaultPaymentMethod != "" {
t.Fatalf("provider=%q id=%q method=%q: an external row names its processor and nothing a charge or a webhook could key on",
s.ProviderType, s.ProviderId, s.DefaultPaymentMethod)
}
if !s.PeriodStart.Equal(recStart) || !s.PeriodEnd.Equal(recEnd) || !s.TrialEnd.IsZero() {
t.Fatalf("period %s..%s trialEnd %s, want the paid period and no trial", s.PeriodStart, s.PeriodEnd, s.TrialEnd)
}
if int64(s.Plan.Price) != in.PriceCents || s.Plan.Slug != "dev" {
t.Fatalf("plan %q at %d, want dev at %d", s.Plan.Slug, s.Plan.Price, in.PriceCents)
}
ref, _ := s.Metadata["reference"].(map[string]interface{})
if ref["invoice"] != "inv:0-test" || s.Metadata["terms"] != in.Terms || s.Metadata["collection"] != "external" {
t.Fatalf("metadata did not survive the store: %#v", s.Metadata)
}
if tier, _ := deriveTier(db, in.Subject, org.TestMode()); tier != "pro" {
t.Fatalf("tier=%q, want the paid tier the plan confers", tier)
}
if held := billingSubscription(db, in.Subject, org.TestMode()); held == nil || held.Id() != s.Id() {
t.Fatal("the recorded plan must count as the subject's one paid subscription")
}
if m.chargeCalls != 0 {
t.Fatalf("processor charged %d time(s); recording a payment collected elsewhere charges nothing", m.chargeCalls)
}
invs := make([]*billinginvoice.BillingInvoice, 0)
if _, err := billinginvoice.Query(db).Filter("UserId=", in.Subject).GetAll(&invs); err != nil || len(invs) != 0 {
t.Fatalf("invoices=%d (err %v); there is nothing here to bill", len(invs), err)
}
after, err := bucketedSplit(org.Namespaced(ctx), in.Subject, currency.USD, org.TestMode())
if err != nil {
t.Fatalf("balance after: %v", err)
}
if after.Available != before.Available || after.CreditsRemaining != before.CreditsRemaining {
t.Fatalf("wallet moved %+v -> %+v; recording a plan grants no credit", before, after)
}
}
func TestRecordedPlanIsNeverChargedOrRenewedByTheProcessor(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("rec-never")
db := datastore.New(org.Namespaced(ctx))
if _, err := RecordSubscription(ctx, org, recordDev(t, db)); err != nil {
t.Fatalf("record: %v", err)
}
s := subsOf(t, db, "acme")[0]
afterEnd := recEnd.Add(24 * time.Hour)
if engine.IsDue(s, afterEnd) {
t.Fatal("an externally collected plan answered due; the cycle would invoice and collect it")
}
charged := 0
burn := &countingPrepaid{}
charge := func(context.Context, *datastore.Datastore, *billinginvoice.BillingInvoice, int64) (string, error) {
charged++
return "ref", nil
}
inv, _, err := engine.RenewSubscription(ctx, db, s, burn, charge)
burned := burn.calls
if err != nil {
t.Fatalf("renew: %v", err)
}
if inv != nil || burned != 0 || charged != 0 {
t.Fatalf("renewal built invoice=%v, burned credits %d time(s), charged %d time(s); it must do none", inv != nil, burned, charged)
}
if !s.PeriodEnd.Equal(recEnd) || s.Status != subscription.Active {
t.Fatalf("renewal moved the row to %s %s; only a recorded payment moves it", s.Status, s.PeriodEnd)
}
}
func TestRecordSubscription_RetryIsUnchangedAndTheNextPaymentExtends(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("rec-next")
db := datastore.New(org.Namespaced(ctx))
in := recordDev(t, db)
in.Processor = "wire" // a processor with no card rail still holds the one paid row
if _, err := RecordSubscription(ctx, org, in); err != nil {
t.Fatalf("record: %v", err)
}
again, err := RecordSubscription(ctx, org, in)
if err != nil || again.Outcome != RecordUnchanged {
t.Fatalf("retry: outcome=%v err=%v, want unchanged", again, err)
}
next := in
next.PeriodStart, next.PeriodEnd = recEnd, recEnd.AddDate(0, 1, 0)
next.Reference = map[string]string{"invoice": "inv:0-next"}
got, err := RecordSubscription(ctx, org, next)
if err != nil || got.Outcome != RecordExtended {
t.Fatalf("next period: outcome=%v err=%v, want extended", got, err)
}
subs := subsOf(t, db, in.Subject)
if len(subs) != 1 {
t.Fatalf("subscriptions=%d; the next payment extends the one row", len(subs))
}
s := subs[0]
if !s.PeriodStart.Equal(next.PeriodStart) || !s.PeriodEnd.Equal(next.PeriodEnd) {
t.Fatalf("period %s..%s, want the newly paid one", s.PeriodStart, s.PeriodEnd)
}
if periods, _ := s.Metadata["periods"].([]interface{}); len(periods) != 2 {
t.Fatalf("periods recorded=%d, want both payments on the row", len(periods))
}
past := in
past.PeriodStart, past.PeriodEnd = recStart.AddDate(0, -1, 0), recStart
if _, err := RecordSubscription(ctx, org, past); !IsSaleRefused(err) {
t.Fatalf("an earlier period: err=%v, want refused", err)
}
}
func TestRecordSubscription_Refusals(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("rec-price")
db := datastore.New(org.Namespaced(ctx))
wrong := recordDev(t, db)
wrong.PriceCents++
if _, err := RecordSubscription(ctx, org, wrong); !IsSaleRefused(err) {
t.Fatalf("a payment that is not the plan's price: err=%v, want refused", err)
}
if n := len(subsOf(t, db, wrong.Subject)); n != 0 {
t.Fatalf("a refused record wrote %d row(s)", n)
}
unknown := recordDev(t, db)
unknown.PlanID = "no-such-plan"
if _, err := RecordSubscription(ctx, org, unknown); !IsSaleNotFound(err) {
t.Fatalf("an unknown plan: err=%v, want not found", err)
}
noRef := recordDev(t, db)
noRef.Reference = map[string]string{"invoice": " "}
if _, err := RecordSubscription(ctx, org, noRef); !IsSaleRefused(err) {
t.Fatalf("no reference: err=%v, want refused", err)
}
paying := moneyOrg("rec-paying")
pdb := datastore.New(paying.Namespaced(ctx))
liveSub(t, pdb, "acme", "dev", "square")
if _, err := RecordSubscription(ctx, paying, recordDev(t, pdb)); !IsSaleConflict(err) {
t.Fatalf("a subject already paying through checkout: err=%v, want conflict", err)
}
}
// TestListSubscriptions_ReadsTheRowsTheStoreHolds: the list the billing page and
// the revenue board read is every subscription in the org's namespace, filtered
// only by what the caller asked.
func TestListSubscriptions_ReadsTheRowsTheStoreHolds(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("rec-list")
db := datastore.New(org.Namespaced(ctx))
if _, err := RecordSubscription(ctx, org, recordDev(t, db)); err != nil {
t.Fatalf("record: %v", err)
}
liveSub(t, db, "other", "dev", "square")
for _, tc := range []struct {
user, status string
want int
}{
{"", "", 2}, {"acme", "", 1}, {"acme", "active", 1}, {"acme", "canceled", 0},
} {
rows, err := ListSubscriptions(ctx, org, tc.user, tc.status)
if err != nil || len(rows) != tc.want {
t.Errorf("user=%q status=%q: rows=%d err=%v, want %d", tc.user, tc.status, len(rows), err, tc.want)
}
}
views, err := Subscriptions(ctx, org, "acme", "")
if err != nil || len(views) != 1 || views[0].MRRCents <= 0 || views[0].Status != "active" {
t.Fatalf("the billing page's view: %+v err=%v", views, err)
}
}