Hold mixed-version invoice months for review and bound legacy catalogs
8 files changed,
+104
−16
cmd/console/billing.go+1 −1cmd/console/billing_test.go+2 −2cmd/console/billingterms_20261003_test.go+4 −5internal/console/billingterms_20261003_test.go+51 −0internal/plans/allowances_20261003_test.go+30 −0internal/plans/month.go+6 −7internal/plans/month_test.go+5 −1internal/plans/plans.go+5 −0
CI passed — run details
diff --git a/cmd/console/billing.go b/cmd/console/billing.go| index f4ff5ce31..81213747f 100644 |
| --- a/cmd/console/billing.go |
| +++ b/cmd/console/billing.go |
| @@ -178,7 +178,7 @@ func billingConfig(getenv func(string) string) (*console.BillingConfig, error) { |
| return nil, errBillingConfiguration |
| } |
| } |
| - for _, version := range plans.TermsVersions() { |
| + for _, version := range plans.LegacyTermsVersions() { |
| if !catalog(plans.LegacyPriceBook, version, false) { |
| return nil, errBillingConfiguration |
| } |
diff --git a/cmd/console/billing_test.go b/cmd/console/billing_test.go| index e7eda319e..43482f968 100644 |
| --- a/cmd/console/billing_test.go |
| +++ b/cmd/console/billing_test.go |
| @@ -47,7 +47,7 @@ func billingPricesFixture(legacy bool) map[string]string { |
| prices[plans.CurrentPriceBook+"/"+plan+"/"+band+"/"+currency] = "price_" + plan + band + currency |
| } |
| if legacy { |
| - prices[plans.CurrentTermsVersion+"/"+plan+"/"+band] = "price_" + plan + band |
| + prices["2026-09-07-v1/"+plan+"/"+band] = "price_" + plan + band |
| } |
| } |
| } |
| @@ -249,7 +249,7 @@ func TestBillingConfigPriceBookAndCatalog(t *testing.T) { |
| for _, plan := range []string{"personal", "community", "business"} { |
| for _, band := range []string{"A", "B", "C", "D"} { |
| prices["/"+plan+"/"+band+"/eur"] = "price_empty_" + plan + band |
| - prices[plans.CurrentTermsVersion+"/"+plan+"/"+band] = "price_" + plan + band |
| + prices["2026-09-07-v1/"+plan+"/"+band] = "price_" + plan + band |
| } |
| } |
| }, |
diff --git a/cmd/console/billingterms_20261003_test.go b/cmd/console/billingterms_20261003_test.go| index 5b4363ac3..fbcc6389b 100644 |
| --- a/cmd/console/billingterms_20261003_test.go |
| +++ b/cmd/console/billingterms_20261003_test.go |
| @@ -11,7 +11,7 @@ import ( |
| func TestBillingConfigHistoricalLegacyCatalogs(t *testing.T) { |
| const historical = "2026-09-07-v1" |
| for _, book := range []string{"2026-10-01-draft1", plans.CurrentPriceBook} { |
| - for _, kind := range []string{"absent", "historical", "both", "partial historical", "partial current", "unknown terms", "shared price", "bad historical price"} { |
| + for _, kind := range []string{"absent", "historical", "unsupported current", "partial historical", "partial current", "unknown terms", "shared price", "bad historical price"} { |
| t.Run(book+"/"+kind, func(t *testing.T) { |
| env := billingEnvFixture(t) |
| env["CONSOLE_BILLING_PRICE_BOOK"] = book |
| @@ -29,9 +29,9 @@ func TestBillingConfigHistoricalLegacyCatalogs(t *testing.T) { |
| if kind != "absent" { |
| add(historical) |
| } |
| - valid := kind == "absent" || kind == "historical" || kind == "both" |
| + valid := kind == "absent" || kind == "historical" |
| switch kind { |
| - case "both": |
| + case "unsupported current": |
| add(plans.CurrentTermsVersion) |
| case "partial historical": |
| delete(prices, historical+"/personal/A") |
| @@ -40,8 +40,7 @@ func TestBillingConfigHistoricalLegacyCatalogs(t *testing.T) { |
| case "unknown terms": |
| add("2030-01-01-v1") |
| case "shared price": |
| - add(plans.CurrentTermsVersion) |
| - prices[plans.CurrentTermsVersion+"/personal/A"] = prices[historical+"/personal/A"] |
| + prices[historical+"/personal/A"] = prices[book+"/personal/A/eur"] |
| case "bad historical price": |
| prices[historical+"/personal/A"] = "wrong_id" |
| } |
diff --git a/internal/console/billingterms_20261003_test.go b/internal/console/billingterms_20261003_test.go| index 45fc1174e..6969fb93c 100644 |
| --- a/internal/console/billingterms_20261003_test.go |
| +++ b/internal/console/billingterms_20261003_test.go |
| @@ -1,10 +1,13 @@ |
| package console |
| import ( |
| + "context" |
| + "database/sql" |
| "errors" |
| "net/http" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/plans" |
| ) |
| @@ -82,3 +85,51 @@ func TestAllowanceTermsStartupRejectsUnknownBook(t *testing.T) { |
| t.Fatalf("unknown configured book: %v", err) |
| } |
| } |
| + |
| +func TestInvoiceEngineReviewsMixedAllowanceTerms(t *testing.T) { |
| + for _, oldBook := range []string{plans.LegacyPriceBook, "2026-10-01-draft1"} { |
| + t.Run(oldBook, func(t *testing.T) { |
| + ix := newInvoiceFixtureWithTerms(t, "2026-09-07-v1") |
| + if oldBook != plans.LegacyPriceBook { |
| + inCurrency(t, ix, "eur") |
| + for _, table := range []string{"billing_accounts", "billing_book_assignments"} { |
| + if _, err := ix.st.db.Exec(`UPDATE `+table+` SET price_book=? WHERE account_id=?`, oldBook, ix.acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + state, err := ix.st.billingAccount(ix.acct.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ended := state |
| + ended.Status = "canceled" |
| + if err := ix.st.applyBillingState(ended, state.Revision, "evt_old_end", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)); err != nil { |
| + t.Fatal(err) |
| + } |
| + start := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC) |
| + next := billingState{AccountID: ix.acct.ID, Customer: state.Customer, Subscription: "sub_new_terms", Status: "active", Plan: plans.PlanPersonal, Terms: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: "eur", Band: plans.BandA, Quantity: 1, PeriodStart: start.Unix(), PeriodEnd: start.AddDate(1, 0, 0).Unix()} |
| + if err := ix.st.applyBillingState(next, state.Revision+1, "evt_new_terms", start); err != nil { |
| + t.Fatal(err) |
| + } |
| + const month = "2026-10" |
| + ix.now = time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) |
| + ix.state.Month, ix.state.DaysInMonth, ix.state.UpdatedAt = month, 31, ix.now.AddDate(0, 0, -1) |
| + ix.putStatement(t) |
| + for i := 0; i < 2; i++ { |
| + if err := ix.srv.invoiceBillingMonth(context.Background(), ix.acct, month, ix.now); !errors.Is(err, errBillingInvoiceHeld) { |
| + t.Fatalf("run %d: %v", i, err) |
| + } |
| + } |
| + if reviews := ix.reviews(t); len(reviews) != 1 || reviews[0] != "billing_invoice_price_terms:"+month { |
| + t.Fatalf("reviews: %v", reviews) |
| + } |
| + if _, err := ix.st.billingMonth(ix.acct.ID, month); !errors.Is(err, sql.ErrNoRows) { |
| + t.Fatalf("month unexpectedly frozen: %v", err) |
| + } |
| + if arrivals, mutations, requests := ix.fake.counts(); len(arrivals) != 0 || len(mutations) != 0 || requests != 0 { |
| + t.Fatalf("provider contacted: arrivals=%v mutations=%v requests=%d", arrivals, mutations, requests) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/plans/allowances_20261003_test.go b/internal/plans/allowances_20261003_test.go| index c6f2ca604..60c4f5051 100644 |
| --- a/internal/plans/allowances_20261003_test.go |
| +++ b/internal/plans/allowances_20261003_test.go |
| @@ -197,3 +197,33 @@ func TestApprovedAllowanceTermsVersionsRetained(t *testing.T) { |
| t.Fatal("caller changed embedded terms list") |
| } |
| } |
| + |
| +func TestAllowanceTermsMixedMonthNeedsPriceReview(t *testing.T) { |
| + for _, oldBook := range []string{LegacyPriceBook, "2026-10-01-draft1"} { |
| + t.Run(oldBook, func(t *testing.T) { |
| + start := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| + first := Assignment{EffectiveAt: start, Revision: 1, TermsVersion: v1, Plan: PlanPersonal, Band: BandA, Quantity: 1, Status: "active", PaidThrough: start.AddDate(1, 0, 0), PriceBook: oldBook, Currency: EUR} |
| + second := first |
| + second.EffectiveAt, second.Revision = start.AddDate(0, 0, 12), 2 |
| + second.TermsVersion, second.PriceBook = CurrentTermsVersion, CurrentPriceBook |
| + if _, err := AllowanceForMonth("2026-10", []Assignment{first, second}); !errors.Is(err, ErrPriceTerms) || errors.Is(err, ErrAssignmentHistory) { |
| + t.Fatalf("mixed terms: %v", err) |
| + } |
| + second.TermsVersion, second.PriceBook, second.House = first.TermsVersion, first.PriceBook, true |
| + if _, err := AllowanceForMonth("2026-10", []Assignment{first, second}); !errors.Is(err, ErrAssignmentHistory) || errors.Is(err, ErrPriceTerms) { |
| + t.Fatalf("mixed house marker: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLegacyTermsVersionsOnlyHistorical(t *testing.T) { |
| + got := LegacyTermsVersions() |
| + if !reflect.DeepEqual(got, []string{v1}) { |
| + t.Fatalf("legacy versions: %v", got) |
| + } |
| + got[0] = CurrentTermsVersion |
| + if !reflect.DeepEqual(LegacyTermsVersions(), []string{v1}) { |
| + t.Fatal("caller changed supported legacy versions") |
| + } |
| +} |
diff --git a/internal/plans/month.go b/internal/plans/month.go| index bcd58be1e..69682dcb5 100644 |
| --- a/internal/plans/month.go |
| +++ b/internal/plans/month.go |
| @@ -10,7 +10,7 @@ import ( |
| var ErrAssignmentHistory = errors.New("plans: assignment_history_incomplete") |
| // ErrPriceTerms: the month's paid spans do not share one usable set of price |
| -// terms. They were bought in different currencies or from different price |
| +// terms. They were bought under different terms versions, currencies or price |
| // books, or one names a book or a currency this build does not have. The |
| // month cannot be priced on one basis and is never priced on two. Unlike an |
| // incomplete history, this does not mend itself with time: a person has to |
| @@ -106,11 +106,10 @@ func (m MonthAllowance) PriceTerms() PriceTerms { |
| // ends in, a lapse, a span not paid through. What is refused |
| // (ErrAssignmentHistory) is a history that cannot be read at all (empty, |
| // unordered, zero times or revisions), a month with no paid span, and paid |
| -// spans that disagree on the terms version or the house marker, whose |
| -// usage could not be priced on one basis. |
| +// spans that disagree on the house marker. |
| // |
| -// Paid spans that disagree on the price book or the currency, and a paid |
| -// span whose price terms this build does not know (a book it does not |
| +// Paid spans that disagree on the terms version, price book or currency, |
| +// and a paid span whose price terms this build does not know (a book it does not |
| // have, a currency the book does not sell in), are refused with |
| // ErrPriceTerms. A month paid for partly in one currency and partly in |
| // another is never priced in either, and nothing is converted. |
| @@ -190,9 +189,9 @@ func AllowanceForMonth(month string, history []Assignment) (*MonthAllowance, err |
| if !paid { |
| out.TermsVersion, out.House, paid = a.TermsVersion, a.House, true |
| out.PriceBook, out.Currency = a.PriceBook, currency |
| - } else if a.TermsVersion != out.TermsVersion || a.House != out.House { |
| + } else if a.House != out.House { |
| return nil, ErrAssignmentHistory |
| - } else if a.PriceBook != out.PriceBook || currency != out.Currency { |
| + } else if a.TermsVersion != out.TermsVersion || a.PriceBook != out.PriceBook || currency != out.Currency { |
| return nil, ErrPriceTerms |
| } |
| if err := a.PriceTerms().Validate(); err != nil { |
diff --git a/internal/plans/month_test.go b/internal/plans/month_test.go| index 0230d69c6..3316967ba 100644 |
| --- a/internal/plans/month_test.go |
| +++ b/internal/plans/month_test.go |
| @@ -108,7 +108,11 @@ func TestMonthAllowanceRefusesUnpriceableHistory(t *testing.T) { |
| case "starts_after_the_month": |
| history = []Assignment{monthAssignment("2026-10-02T00:00:00Z", 1, 10)} |
| } |
| - if _, err := AllowanceForMonth("2026-09", history); !errors.Is(err, ErrAssignmentHistory) { |
| + want := ErrAssignmentHistory |
| + if kind == "mixed_terms" { |
| + want = ErrPriceTerms |
| + } |
| + if _, err := AllowanceForMonth("2026-09", history); !errors.Is(err, want) { |
| t.Fatalf("unpriceable history: %v", err) |
| } |
| }) |
diff --git a/internal/plans/plans.go b/internal/plans/plans.go| index 806cc90d9..c786fb1df 100644 |
| --- a/internal/plans/plans.go |
| +++ b/internal/plans/plans.go |
| @@ -165,6 +165,11 @@ func TermsVersions() []string { |
| return out |
| } |
| +// LegacyTermsVersions lists terms sold before price books were introduced. |
| +func LegacyTermsVersions() []string { |
| + return []string{"2026-09-07-v1"} |
| +} |
| + |
| // decodeTerms strictly decodes and validates a terms document: unknown |
| // fields and trailing content after the JSON value are rejected outright |
| // (a misspelled key must fail loudly, never silently decode to a zero rate, |