Files
zooqueenandHanzo Dev b7bb5f3d32 A subscription reads the seats and money its period's payment charged, a refund or dispute of that payment unpays the period, and a change of seats waits for the renewal that charges it
The subscriptions view gains Seats, ChargedCents and PendingQuantity. Seats and
ChargedCents come from the period's paid invoice (its plan line's quantity, and
its plan fee after discount less what was refunded) or from the seats and price
now recorded with an external period, never from the row's quantity; a period
recorded without them reads the row's seats at the plan's price.

Invoices record refunds by id and a dispute's state. A payment that paid an
invoice is indexed to it (models/paymentorg), so the processor's refund or
dispute marks that invoice instead of drawing on a wallet; a refund recorded
through the refunds API counts the same way and never exceeds what the invoice
still holds. A period refunded in full or disputed, open or lost, reads unpaid;
a dispute won restores it.

PATCH quantity sets the row's pending quantity; the renewal invoices it and the
row holds it from then, and an external plan takes it with the next recorded
period. Members never exceed the seats paid for now.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-01 13:44:35 -07:00

191 lines
5.7 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
sub.PeriodStart = now.AddDate(0, -1, -i) // distinct periods per case
sub.PeriodEnd = now.AddDate(0, 0, -1-i)
if err := sub.Create(); err != nil {
t.Fatalf("create sub: %v", err)
}
inv, _, err := RenewSubscription(context.Background(), db, sub, nil, nil)
if err != nil {
t.Fatalf("renew: %v", err)
}
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)
}
}
// A change of seats waiting for the next period is what that period's invoice
// bills, and the row holds it from then on.
func TestRenewSubscription_BillsThePendingSeats(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := datastore.New(c)
db.SetNamespace("seats-pending")
now := time.Now()
sub := subscription.New(db)
sub.UserId = "seats/pending"
sub.PlanId = "plan_team_pending"
sub.Plan = plan.Plan{
Name: "Team",
Price: currency.Cents(2500),
PerSeat: true,
Currency: currency.USD,
Interval: types.Monthly,
IntervalCount: 1,
}
sub.Quantity, sub.PendingQuantity = 2, 4
sub.Status = subscription.Active
sub.PeriodStart, sub.PeriodEnd = now.AddDate(0, -1, -1), now.AddDate(0, 0, -1)
if err := sub.Create(); err != nil {
t.Fatalf("create sub: %v", err)
}
inv, _, err := RenewSubscription(context.Background(), db, sub, nil, nil)
if err != nil {
t.Fatalf("renew: %v", err)
}
if li := inv.LineItems[0]; li.Quantity != 4 || li.Amount != 10000 {
t.Fatalf("line bills %d seat(s) for %d, want 4 for 10000", li.Quantity, li.Amount)
}
if sub.Quantity != 4 || sub.PendingQuantity != 0 {
t.Fatalf("row holds %d seat(s), %d pending; want 4, 0", sub.Quantity, sub.PendingQuantity)
}
}