Files
commerce/api/billing/record_test.go
zooqueenandHanzo Dev bd0dabc515 Subscriptions renew from the source they were bought with, one period up front at the interval and price bought, retry a decline on days 1, 3 and 7 before dropping to free, and the cycle is a dry run unless asked
The billing cycle takes one step per live subscription (engine.Settle) and
reports every one: org, plan, interval, paid period, billed period, price,
amount, source and action. Without dryRun=false it writes nothing.

- A renewal bills the period after the paid one from sub.Plan, the plan as
  bought, on the source it was bought from: its card, or prepaid money
  (credits, then the balance) for a plan bought with credits or the balance.
  A plan collected outside Hanzo is renewed by its processor and never charged.
- A decline is retried 1, 3 and 7 days after it; the subscriber keeps the
  tier meanwhile, and the last decline ends the subscription.
- A subscription more than 72 hours past its paid period with no attempt is
  overdue: nothing is charged or written until its customer renews it, which
  bills the period from then and never the time it was not renewed.
- Realignment is an operator's act with its own dry run
  (POST /v1/billing/realign/run[-all]): a row whose PeriodStart is its current
  paid invoice's PeriodEnd moves back onto that invoice's period.
- Every payment on an invoice holds one lock and records its attempt before
  asking for money; an unknown outcome is repeated under its key, escalated to
  unpaid after the schedule, and resolved before any cancel or end voids it.
- A cancel landing while a renewal is at the processor stays canceled; a
  charge that does not answer in 30 seconds is unknown; one org's cycle stops
  starting subscriptions after 10 minutes.
- A voided or uncollectible invoice returns what it collected to prepaid
  money; tax is set on drafts only; a platform operator voids an unresolved
  attempt through POST /v1/billing/invoices/:id/void-unresolved with a reason
  recorded in the billing event ledger.
- How a subscription was bought is read from its earliest invoice that bills
  a period.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 06:36:35 -07:00

267 lines
10 KiB
Go

package billing
import (
"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)
}
}
// TestRecordedPlanIsNeverChargedOrRenewedByTheProcessor: a plan collected
// outside Hanzo renews as it was bought — its processor collects the next period
// and recording that payment moves the row on. The cycle charges nothing for it,
// from prepaid money or from a card, even one on file, and neither moves nor
// ends the row.
func TestRecordedPlanIsNeverChargedOrRenewedByTheProcessor(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("rec-never")
m := squareMock("", "", "sqpay_never")
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
if _, err := RecordSubscription(ctx, org, recordDev(t, db)); err != nil {
t.Fatalf("record: %v", err)
}
s := reloadSub(t, db, subsOf(t, db, "acme")[0].Id())
s.DefaultPaymentMethod = seedSavedCard(t, db, "acme", "ccof_never", "cust_never").Id()
if err := s.Update(); err != nil {
t.Fatalf("attach card: %v", err)
}
seedCredit(db, "acme", 50000, "topup", time.Time{})
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")
}
for _, dry := range []bool{true, false} {
res := only(t, cycleAt(t, ctx, org, afterEnd, dry), renewedOutside)
if res.Source != sourceExternal || res.AmountCents != 0 {
t.Fatalf("dry=%v: %+v, want it left to its processor with nothing charged", dry, res)
}
}
burn := &countingPrepaid{}
step, err := engine.Settle(ctx, db, reloadSub(t, db, s.Id()), engine.Run{Now: afterEnd, Prepaid: burn}, engine.PrepaidPayer(burn))
if err != nil || step.Action != "" || burn.calls != 0 {
t.Fatalf("settle: %+v (err %v), %d prepaid calls; it must do nothing", step, err, burn.calls)
}
invs := make([]*billinginvoice.BillingInvoice, 0)
if _, err := billinginvoice.Query(db).Filter("UserId=", "acme").GetAll(&invs); err != nil || len(invs) != 0 {
t.Fatalf("invoices=%d (err %v); there is nothing here to bill", len(invs), err)
}
if got := reloadSub(t, db, s.Id()); !got.PeriodEnd.Equal(recEnd) || got.Status != subscription.Active || m.chargeCalls != 0 {
t.Fatalf("row %s through %s after %d charges; only a recorded payment moves it", got.Status, got.PeriodEnd, m.chargeCalls)
}
}
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)
}
}