Files
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

160 lines
4.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package engine
import (
"context"
"testing"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/types"
"github.com/hanzoai/commerce/util/test/ae"
)
// TestBuildPeriodInvoice_Seats proves the per-seat money invariant: a per-seat
// plan invoices Price × quantity (floored at 1 seat), while a flat plan's
// invoice is UNCHANGED by quantity.
func TestBuildPeriodInvoice_Seats(t *testing.T) {
cases := []struct {
name string
perSeat bool
price int64
quantity int
want int64
}{
{"per-seat team ×4 seats", true, 2500, 4, 10000},
{"per-seat floors at 1 seat", true, 2500, 0, 2500},
{"flat plan ignores quantity", false, 2000, 4, 2000},
{"flat plan quantity 1", false, 2000, 1, 2000},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := datastore.New(c)
db.SetNamespace("seats-invoice")
now := time.Now()
sub := subscription.New(db)
sub.UserId = "seats/user"
sub.PlanId = "plan_seats_" + tc.name
sub.Plan = plan.Plan{
Name: "Seats",
Price: currency.Cents(tc.price),
PerSeat: tc.perSeat,
Currency: currency.USD,
Interval: types.Monthly,
IntervalCount: 1,
}
sub.Quantity = tc.quantity
sub.Status = subscription.Active
// Paid through a few hours ago (distinct per case), inside the grace window.
sub.PeriodEnd = now.Add(-time.Duration(i+1) * time.Hour)
sub.PeriodStart = sub.PeriodEnd.AddDate(0, -1, 0)
if err := sub.Create(); err != nil {
t.Fatalf("create sub: %v", err)
}
cc := &card{}
step, err := Settle(context.Background(), db, sub, Run{Now: now}, CardPayer(cc.charger()))
if err != nil {
t.Fatalf("settle: %v", err)
}
inv := step.Invoice
if inv == nil {
t.Fatalf("settle = %+v, want a renewal invoice", step)
}
if len(cc.amounts) != 1 || cc.amounts[0] != tc.want {
t.Fatalf("charged %v, want one charge of %d", cc.amounts, tc.want)
}
if len(inv.LineItems) == 0 {
t.Fatal("invoice has no line items")
}
li := inv.LineItems[0]
if li.Amount != tc.want {
t.Fatalf("line amount = %d, want %d (price %d × quantity %d, perSeat=%v)",
li.Amount, tc.want, tc.price, tc.quantity, tc.perSeat)
}
if li.UnitPrice != tc.price {
t.Fatalf("line unitPrice = %d, want %d", li.UnitPrice, tc.price)
}
if inv.Subtotal != tc.want || inv.AmountDue != tc.want {
t.Fatalf("subtotal=%d amountDue=%d, want %d", inv.Subtotal, inv.AmountDue, tc.want)
}
})
}
}
// TestChangePlan_ProrationSeats proves proration respects quantity: on a
// per-seat plan the credit/charge scale by the subscription's seat count. The
// proration fraction is time.Now-based, so the proof is relational — the
// 3-seat net equals 3 × the 1-seat net within a 3-cent flooring bound.
func TestChangePlan_ProrationSeats(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := datastore.New(c)
db.SetNamespace("seats-proration")
mkSub := func(qty int) *subscription.Subscription {
now := time.Now()
sub := subscription.New(db)
sub.Plan = plan.Plan{
Name: "Team",
Price: currency.Cents(2500),
PerSeat: true,
Currency: currency.USD,
Interval: types.Monthly,
}
sub.Quantity = qty
sub.PeriodStart = now.AddDate(0, 0, -15)
sub.PeriodEnd = now.AddDate(0, 0, 15)
return sub
}
newPlan := plan.New(db)
newPlan.Name = "Team Max"
newPlan.Price = currency.Cents(22500)
newPlan.PerSeat = true
newPlan.Currency = currency.USD
newPlan.Interval = types.Monthly
item1, err := ChangePlan(mkSub(1), newPlan, true)
if err != nil {
t.Fatalf("proration qty=1: %v", err)
}
item3, err := ChangePlan(mkSub(3), newPlan, true)
if err != nil {
t.Fatalf("proration qty=3: %v", err)
}
if item1 == nil || item3 == nil {
t.Fatal("expected proration line items")
}
if item1.Amount <= 0 {
t.Fatalf("upgrade proration must be a positive net charge, got %d", item1.Amount)
}
want := 3 * item1.Amount
if diff := item3.Amount - want; diff < -3 || diff > 3 {
t.Fatalf("3-seat proration = %d, want ~3 × 1-seat (%d) within 3c", item3.Amount, want)
}
// Flat plans are UNCHANGED by quantity: same prices, PerSeat off.
flatOld := mkSub(3)
flatOld.Plan.PerSeat = false
flatNew := plan.New(db)
flatNew.Name = newPlan.Name
flatNew.Price = newPlan.Price
flatNew.Currency = newPlan.Currency
flatNew.Interval = newPlan.Interval
itemFlat, err := ChangePlan(flatOld, flatNew, true)
if err != nil {
t.Fatalf("flat proration: %v", err)
}
if diff := itemFlat.Amount - item1.Amount; diff < -3 || diff > 3 {
t.Fatalf("flat-plan proration at qty=3 (%d) must match the 1-seat net (%d)", itemFlat.Amount, item1.Amount)
}
}