mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
RecordSubscription takes processor "balance": it draws the period from the subject's prepaid money now, raises and settles that period's invoice, and opens a regular subscription the engine renews from the same money. The period must be the one running now and one of the plan's; a subject holding a plan, or one gone past due, is refused. A balance that cannot cover the period is refused with nothing written. The ledger entry and the invoice are the reference. Prepaid money is read and spent one way. The creditledger seam gains Debit, all or nothing at the address Credit and Balance already name, so with a host ledger injected the draw lands where a grant credits and the AI gate reads, and the tier reads its balance, beside the credit grants, from there too. Standalone, the draw is an "iam-user" withdrawal, the kind every deposit writes. The self-serve balance purchase, the record and every renewal draw through it, credit grants first, exactly once per ref. The split is recorded under the ref before the ledger is asked, so a debit whose answer was lost is posted again as it was and the ledger answers with the posting it holds. The collector reads the allocation first and charges the card before any prepaid money moves, so an invoice that cannot be paid in full takes nothing from credits or balance. What an attempt does collect accumulates on AmountPaid, and the draw is keyed on the subscription's period, so two invoices raised for one period draw once between them. Periods are billed in advance. A subscription holds the period it paid for; when that period ends the next one is invoiced, fee in advance and metered usage in arrears, and the row moves onto it only once it is paid. An unpaid one leaves the row past due on the period it owes; once that period has gone by unserved it is owed no longer, its invoice is void, and the row owes the period running now. A row that was served nothing until it paid, past due or late, is served a whole period from the payment. An invoice is voided only under the guard its payment takes, so a payment landing at once is never voided over. A row whose renewal was missed for whole periods is billed the period running now, never every period since. A row canceled at the end of its period ends with the last period it paid for, and an ended row's unpaid invoices are void. Invoices are read by their filters rather than under the synckey ancestor an invoice loses once it is saved again, so a paid one stays listed and found by its renewal, and a new one is numbered one past the highest issued. The billing cycle reads subscriptions the same way, renews only rows of the org's own mode, and RenewDue is the sweep as a value core a schedule can call. Claude-Session: https://claude.ai/code/session_016hTDbBTaq6r3xTQ5RAVVtF
235 lines
7.9 KiB
Go
235 lines
7.9 KiB
Go
package billing
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/hanzoai/commerce/billing/engine"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/subscription"
|
|
types "github.com/hanzoai/commerce/types"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// indexBySlug builds a slug → plan map so tests never depend on the positional
|
|
// order of the embedded catalog (which grows as @hanzo/plans adds tiers).
|
|
func indexBySlug(plans []staticPlan) map[string]staticPlan {
|
|
m := make(map[string]staticPlan, len(plans))
|
|
for _, p := range plans {
|
|
m[p.Slug] = p
|
|
}
|
|
return m
|
|
}
|
|
|
|
func TestPlansLoaded(t *testing.T) {
|
|
if len(catalog) == 0 {
|
|
t.Fatal("catalog is empty")
|
|
}
|
|
|
|
bySlug := indexBySlug(catalog)
|
|
|
|
// Core subscription plans that must exist regardless of catalog growth.
|
|
must := []string{"free", "dev", "max-5x", "max-20x", "team", "enterprise"}
|
|
for _, slug := range must {
|
|
p, ok := bySlug[slug]
|
|
if !ok {
|
|
t.Fatalf("required plan %q missing", slug)
|
|
}
|
|
if p.Name == "" {
|
|
t.Errorf("plan %q.Name is empty", slug)
|
|
}
|
|
if p.Currency != "usd" {
|
|
t.Errorf("plan %q.Currency = %q, want usd", slug, p.Currency)
|
|
}
|
|
}
|
|
|
|
// `dev` is the entry paid rung; `enterprise` is priced by conversation, so it
|
|
// is the one row with a null price.
|
|
entry := bySlug["dev"]
|
|
if entry.Price != 2000 {
|
|
t.Errorf("Dev price = %d cents, want 2000", entry.Price)
|
|
}
|
|
if entry.Limits == nil {
|
|
t.Fatal("Dev plan should have limits")
|
|
}
|
|
if entry.Limits.RequestsPerMinute == nil || *entry.Limits.RequestsPerMinute != 500 {
|
|
t.Error("Dev requestsPerMinute should be 500")
|
|
}
|
|
if entry.Limits.TokensPerMinute == nil || *entry.Limits.TokensPerMinute != 1000000 {
|
|
t.Error("Dev tokensPerMinute should be 1000000")
|
|
}
|
|
|
|
// The ladder climbs. Asserted as an ORDERING rather than four numbers, so it
|
|
// keeps holding after a reprice and only fails on a rung that is out of order.
|
|
prev := int64(-1)
|
|
for _, slug := range []string{"free", "dev", "max-5x", "max-20x"} {
|
|
p := bySlug[slug]
|
|
if p.Price <= prev {
|
|
t.Errorf("ladder is not ascending at %q: %d cents follows %d", slug, p.Price, prev)
|
|
}
|
|
prev = p.Price
|
|
}
|
|
|
|
// A year is chosen per rung and stated as a total, and it is a discount on
|
|
// twelve months. Pinned as the RELATION rather than as numbers, so it keeps
|
|
// holding after a reprice. That the per-month tag is this year over twelve is
|
|
// TestAnnualNormalizesToTheAdvertisedPrice, over the whole catalog.
|
|
for _, slug := range []string{"dev", "max-5x", "max-20x", "team"} {
|
|
p := bySlug[slug]
|
|
if p.AnnualTotal <= 0 || p.AnnualTotal >= 12*p.Price {
|
|
t.Errorf("plan %q year = %d cents, monthly = %d; a year must be a discount on twelve months", slug, p.AnnualTotal, p.Price)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDNSPlansLoaded(t *testing.T) {
|
|
if len(dnsPlans) == 0 {
|
|
t.Fatal("dnsPlans is empty")
|
|
}
|
|
|
|
if got := len(dnsPlans); got != 3 {
|
|
t.Fatalf("expected 3 DNS plans, got %d", got)
|
|
}
|
|
|
|
bySlug := indexBySlug(dnsPlans)
|
|
for _, slug := range []string{"dns-free", "dns-pro", "dns-enterprise"} {
|
|
p, ok := bySlug[slug]
|
|
if !ok {
|
|
t.Fatalf("required DNS plan %q missing", slug)
|
|
}
|
|
if p.Category != "dns" {
|
|
t.Errorf("DNS plan %q.Category = %q, want dns", slug, p.Category)
|
|
}
|
|
if p.Currency != "usd" {
|
|
t.Errorf("DNS plan %q.Currency = %q, want usd", slug, p.Currency)
|
|
}
|
|
if len(p.Features) == 0 {
|
|
t.Errorf("DNS plan %q.Features is empty", slug)
|
|
}
|
|
if p.Limits == nil {
|
|
t.Fatalf("DNS plan %q should have limits", slug)
|
|
}
|
|
}
|
|
|
|
if c := bySlug["dns-free"].Price; c != 0 {
|
|
t.Errorf("DNS Free price = %d cents, want 0", c)
|
|
}
|
|
if !bySlug["dns-pro"].Popular {
|
|
t.Error("DNS Pro plan should be popular")
|
|
}
|
|
}
|
|
|
|
func TestLookupPlan(t *testing.T) {
|
|
p := lookupPlan("dev")
|
|
if p == nil {
|
|
t.Fatal("lookupPlan(dev) returned nil")
|
|
}
|
|
if p.Slug != "dev" {
|
|
t.Errorf("lookupPlan(dev).Slug = %q", p.Slug)
|
|
}
|
|
|
|
p = lookupPlan("dns-pro")
|
|
if p == nil {
|
|
t.Fatal("lookupPlan(dns-pro) returned nil")
|
|
}
|
|
if p.Slug != "dns-pro" {
|
|
t.Errorf("lookupPlan(dns-pro).Slug = %q", p.Slug)
|
|
}
|
|
|
|
p = lookupPlan("nonexistent-plan")
|
|
if p != nil {
|
|
t.Errorf("lookupPlan(nonexistent-plan) should return nil, got %v", p.Slug)
|
|
}
|
|
}
|
|
|
|
func TestIncludedMonthlyCents(t *testing.T) {
|
|
// Unknown plan -> 0 (no allotment).
|
|
if c := IncludedMonthlyCents("nonexistent-plan"); c != 0 {
|
|
t.Errorf("IncludedMonthlyCents(unknown) = %d, want 0", c)
|
|
}
|
|
|
|
// Contract MIRRORS the function's real precedence: the monthly allotment is
|
|
// the plan's declared cloud-credit allowance, sourced (in order) from
|
|
// limits.includedCloudCredits (flat), then includedCloudCreditsPerUser (per
|
|
// seat), then the legacy includedCreditUsd alias — dollars*100. Whichever the
|
|
// plan declares > 0, IncludedMonthlyCents returns it; otherwise 0. Asserting
|
|
// the RELATIONSHIP (not a hard-coded number) keeps this stable across
|
|
// @hanzo/plans catalog versions fetched at build time. (The prior test checked
|
|
// only includedCreditUsd, so plans that declare includedCloudCredits — e.g.
|
|
// max=$100, team-max=$100/seat — failed with "10000 want 0".)
|
|
for _, p := range catalog {
|
|
got := IncludedMonthlyCents(p.Slug)
|
|
var want int64
|
|
if p.Limits != nil {
|
|
if usd := firstNonNilPositive(p.Limits.IncludedCloudCredits, p.Limits.IncludedCloudCreditsPerUser, p.Limits.IncludedCreditUsd); usd != nil {
|
|
want = int64(*usd) * 100
|
|
}
|
|
}
|
|
if got != want {
|
|
t.Errorf("IncludedMonthlyCents(%q) = %d, want %d (cloudCredits→cloudCreditsPerUser→creditUsd)", p.Slug, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// firstNonNilPositive returns the first argument that is non-nil AND > 0, or nil.
|
|
// Mirrors IncludedMonthlyCents's field-precedence selection so the test and the
|
|
// implementation cannot silently diverge.
|
|
func firstNonNilPositive(vals ...*int) *int {
|
|
for _, v := range vals {
|
|
if v != nil && *v > 0 {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestCatalogIntervalIsTheConstant: the interval the catalog projects is the one
|
|
// the rest of billing reads, spelled the one way it is declared.
|
|
//
|
|
// Every consumer of Plan.Interval matches on types.Monthly ("month"), and the two
|
|
// that bill reach their monthly answer by a DIFFERENT route when handed anything
|
|
// else: Advance through the default arm of its switch, MonthlyNormalizedCents
|
|
// through the default arm of its own. Both arms exist to be generous about what a
|
|
// stored row might hold, and neither is a place for the catalog's own value to
|
|
// land — an answer reached through a fallback is one nothing asserts, and the next
|
|
// case added above the default takes it silently.
|
|
//
|
|
// PeriodsRemaining is the reading of the same field with no default to catch it:
|
|
// it answers the YEAR difference for anything that is not types.Monthly.
|
|
func TestCatalogIntervalIsTheConstant(t *testing.T) {
|
|
for _, sp := range catalog {
|
|
if types.Interval(sp.Interval) != types.Monthly {
|
|
t.Errorf("catalog plan %q carries interval %q, want %q",
|
|
sp.Slug, sp.Interval, types.Monthly)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCatalogMonthlyPlanGetsAMonth reads the projected interval the way billing
|
|
// does — through the engine that sets the period and the normalizer that reports
|
|
// the revenue — so what is pinned is the period a subscriber actually gets rather
|
|
// than the spelling of a string.
|
|
func TestCatalogMonthlyPlanGetsAMonth(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
for _, slug := range []string{"dev", "max-5x", "max-20x", "team"} {
|
|
// The stored authority row: StartSubscription records the plan's id, so the
|
|
// period under test is the one a real purchase gets.
|
|
row, err := resolveSubscriptionPlan(datastore.New(ctx), slug)
|
|
if err != nil {
|
|
t.Fatalf("resolve %s: %v", slug, err)
|
|
}
|
|
|
|
sub := &subscription.Subscription{}
|
|
engine.StartSubscription(sub, row)
|
|
if want := sub.PeriodStart.AddDate(0, 1, 0); !sub.PeriodEnd.Equal(want) {
|
|
t.Errorf("%s bills every %s..%s, want a month ending %s",
|
|
slug, sub.PeriodStart, sub.PeriodEnd, want)
|
|
}
|
|
|
|
if got := MonthlyNormalizedCents(int64(row.Price), string(row.Interval), row.IntervalCount); got != int64(row.Price) {
|
|
t.Errorf("%s normalizes to %d, want its own monthly price %d", slug, got, row.Price)
|
|
}
|
|
}
|
|
}
|