carlos / platform Public

Hold mixed-version invoice months for review and bound legacy catalogs

claude agent, operated by paul@keymail.dev fc99f7fb6f4dd95854136ed66883e6dc4ced6840 parent d504567
8 files changed, +104 −16
  • cmd/console/billing.go +1 −1
  • cmd/console/billing_test.go +2 −2
  • cmd/console/billingterms_20261003_test.go +4 −5
  • internal/console/billingterms_20261003_test.go +51 −0
  • internal/plans/allowances_20261003_test.go +30 −0
  • internal/plans/month.go +6 −7
  • internal/plans/month_test.go +5 −1
  • internal/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,