Files
commerce/api/billing/record_test.go

285 lines
10 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)
}
}
// Each subscription says how its period in hand was paid: a plan recorded as
// collected outside commerce names its processor, a plan paid from the balance says
// so, a comp says nobody paid, and a row nobody paid for says nothing.
func TestSubscriptionsSayHowThePeriodWasPaid(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("rec-settled")
withFakeSquare(t, squareMock("cust_1", "ccof_1", "sqpay_1"))
db := datastore.New(org.Namespaced(ctx))
for _, proc := range []string{"square", "wire"} {
in := recordDev(t, db)
in.Subject = "acme-" + proc
in.Processor = proc
if _, err := RecordSubscription(ctx, org, in); err != nil {
t.Fatalf("record %s: %v", proc, err)
}
}
comp := compIn()
comp.Subject = "acme-comp"
if _, err := RecordSubscription(ctx, org, comp); err != nil {
t.Fatalf("record comp: %v", err)
}
for subject, want := range map[string]string{"acme-square": "external:square", "acme-wire": "external:wire", "acme-comp": "comp"} {
rows, err := Subscriptions(ctx, org, subject, "")
if err != nil || len(rows) != 1 || rows[0].Settled != want {
t.Fatalf("%s: %+v, %v; want settled %q", subject, rows, err, want)
}
}
}