mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
160 lines
4.8 KiB
Go
160 lines
4.8 KiB
Go
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)
|
||
}
|
||
}
|