carlos / platform Public

Per-line tax codes, Task 1: a line may carry its own tax code, and the allowance names its spans

plans.OverageLine.TaxCode (omitempty) and plans.MonthAllowance.Spans
(omitempty): the paid intervals the allowance counted, one per assignment,
half-open and unjoined, with the plan in force. priceBillingCalculation
drops the spans, so every calculation with one tax code encodes as it did
(Task 0's pinned bytes pass); the path that will need them, a month whose
plans carry two codes, asks the allowance for them again. Tests: the spans
of one plan, a quantity change, a plan change at 15:00, a change at the
month's first instant, a lapse; an empty allowance and a code-less line
encode as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b04d84624e7902c639398b7d539db1b435d44434 parent 6d0d371
4 files changed, +91 −0
  • internal/console/billingcalculation.go +5 −0
  • internal/plans/lines.go +6 −0
  • internal/plans/month.go +18 −0
  • internal/plans/month_test.go +62 −0

CI passed — run details

diff --git a/internal/console/billingcalculation.go b/internal/console/billingcalculation.go
index a8efdde8f..12f2cde36 100644
--- a/internal/console/billingcalculation.go
+++ b/internal/console/billingcalculation.go
@@ -133,6 +133,11 @@ func priceBillingCalculation(statement usage.Statement, history []plans.Assignme
if err != nil {
return nil, err
}
+ // The spans the allowance counted are not part of a calculation with
+ // one tax code (its bytes are what they were before spans existed);
+ // the one path that needs them, a month whose plans carry two codes,
+ // asks the allowance for them again.
+ allowance.Spans = nil
if len(allowance.Assignments) == 0 {
return nil, plans.ErrAssignmentHistory
}
diff --git a/internal/plans/lines.go b/internal/plans/lines.go
index 825d5407f..f53fad414 100644
--- a/internal/plans/lines.go
+++ b/internal/plans/lines.go
@@ -22,6 +22,12 @@ var meterOrder = [4]string{"awake_seconds", "storage_byte_months", "transfer_byt
type OverageLine struct {
Meter string
AmountCents int64
+ // TaxCode is the product tax code of this line alone, set only in a
+ // calculation whose month's paid spans carry more than one code (the
+ // calculation's own tax code is then empty); absent, and omitted from
+ // the encoding, in every calculation with one code, whose bytes are
+ // exactly what they were before the field existed.
+ TaxCode string `json:"tax_code,omitempty"`
}
// InvoiceOverageLines prices a standalone overage invoice exactly as
diff --git a/internal/plans/month.go b/internal/plans/month.go
index ee5b516c9..bcd58be1e 100644
--- a/internal/plans/month.go
+++ b/internal/plans/month.go
@@ -47,6 +47,16 @@ type CoveredInterval struct {
To time.Time `json:"to"`
}
+// PlanSpan is one paid assignment's interval of a month, half-open
+// [From, To), with the plan in force over it. Unlike Covered, adjacent
+// spans are not joined: a quantity change gives two spans of one plan, a
+// plan change two spans of two.
+type PlanSpan struct {
+ From time.Time `json:"from"`
+ To time.Time `json:"to"`
+ Plan string `json:"plan"`
+}
+
type MonthAllowance struct {
Month string `json:"month"`
TermsVersion string `json:"terms_version"`
@@ -68,6 +78,13 @@ type MonthAllowance struct {
// one book. Empty for a month of legacy assignments.
PriceBook string `json:"price_book,omitempty"`
Currency Currency `json:"currency,omitempty"`
+ // Spans are the paid intervals the allowance counted, one per
+ // assignment, unjoined and in order, for whoever has to know which
+ // plan was in force when (the tax code of an overage line, when the
+ // month's plans carry more than one). AllowanceForMonth fills them;
+ // a calculation keeps them only when it needs them, and a month with
+ // one tax code drops them, so its bytes are what they always were.
+ Spans []PlanSpan `json:"spans,omitempty"`
}
// PriceTerms is the commercial identity the month's usage is priced under.
@@ -194,6 +211,7 @@ func AllowanceForMonth(month string, history []Assignment) (*MonthAllowance, err
totals[n].Add(&totals[n], weighted)
}
out.Assignments = append(out.Assignments, a)
+ out.Spans = append(out.Spans, PlanSpan{From: lo, To: paidHi, Plan: a.Plan})
// Adjacent paid spans join, so a quantity change mid-month does not
// split a day between two intervals.
if n := len(out.Covered); n > 0 && out.Covered[n-1].To.Equal(lo) {
diff --git a/internal/plans/month_test.go b/internal/plans/month_test.go
index 47aa86129..0230d69c6 100644
--- a/internal/plans/month_test.go
+++ b/internal/plans/month_test.go
@@ -1,8 +1,10 @@
package plans
import (
+ "encoding/json"
"errors"
"reflect"
+ "strings"
"testing"
"time"
)
@@ -200,3 +202,63 @@ func TestMonthAllowanceRoundsFractionalBaseUnitsForCustomer(t *testing.T) {
t.Fatalf("house history: %+v %v", got, err)
}
}
+
+// The allowance names the paid intervals it counted, one per assignment,
+// half-open and unjoined, with the plan in force: a quantity change gives
+// two spans of one plan, a plan change two spans of two, a change at the
+// month's first instant one span, and a lapse a gap between spans. The
+// joined Covered list is as it was.
+func TestMonthAllowanceNamesItsSpans(t *testing.T) {
+ sep := func(day, hour int) time.Time { return time.Date(2026, 9, day, hour, 0, 0, 0, time.UTC) }
+ oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ first := monthAssignment("2026-08-01T00:00:00Z", 1, 10)
+ second := monthAssignment("2026-09-16T15:00:00Z", 2, 20)
+ withPlan := func(a Assignment, plan string) Assignment {
+ a.Plan = plan
+ if plan == PlanPersonal {
+ a.Quantity = 1
+ }
+ return a
+ }
+ cases := map[string]struct {
+ history []Assignment
+ spans []PlanSpan
+ covered []CoveredInterval
+ }{
+ "one plan all month": {[]Assignment{first}, []PlanSpan{{sep(1, 0), oct1, first.Plan}}, nil},
+ "a quantity change at 15:00": {[]Assignment{first, second},
+ []PlanSpan{{sep(1, 0), sep(16, 15), first.Plan}, {sep(16, 15), oct1, second.Plan}}, nil},
+ "a plan change at 15:00": {[]Assignment{withPlan(first, PlanPersonal), withPlan(second, PlanBusiness)},
+ []PlanSpan{{sep(1, 0), sep(16, 15), PlanPersonal}, {sep(16, 15), oct1, PlanBusiness}}, nil},
+ "a change at the month's first instant": {[]Assignment{withPlan(first, PlanPersonal), withPlan(monthAssignment("2026-09-01T00:00:00Z", 2, 20), PlanBusiness)},
+ []PlanSpan{{sep(1, 0), oct1, PlanBusiness}}, nil},
+ }
+ lapsed := first
+ lapsed.PaidThrough = sep(10, 0)
+ cases["a lapse, then a new plan"] = struct {
+ history []Assignment
+ spans []PlanSpan
+ covered []CoveredInterval
+ }{[]Assignment{lapsed, withPlan(second, PlanBusiness)}, []PlanSpan{{sep(1, 0), sep(10, 0), lapsed.Plan}, {sep(16, 15), oct1, PlanBusiness}}, []CoveredInterval{{sep(1, 0), sep(10, 0)}, {sep(16, 15), oct1}}}
+ for name, tc := range cases {
+ got, err := AllowanceForMonth("2026-09", tc.history)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ if !reflect.DeepEqual(got.Spans, tc.spans) {
+ t.Errorf("%s: spans %+v, want %+v", name, got.Spans, tc.spans)
+ }
+ if !reflect.DeepEqual(got.Covered, tc.covered) {
+ t.Errorf("%s: covered %+v, want %+v", name, got.Covered, tc.covered)
+ }
+ }
+ // A span is not in the encoding of an allowance that has none, and a
+ // line without a code encodes as it always did.
+ raw, _ := json.Marshal(MonthAllowance{})
+ if strings.Contains(string(raw), "spans") {
+ t.Fatalf("an empty allowance encodes spans: %s", raw)
+ }
+ if raw, _ := json.Marshal(OverageLine{Meter: "writes", AmountCents: 5}); string(raw) != `{"Meter":"writes","AmountCents":5}` {
+ t.Fatalf("a line without a code: %s", raw)
+ }
+}