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>
4 files changed,
+91
−0
internal/console/billingcalculation.go+5 −0internal/plans/lines.go+6 −0internal/plans/month.go+18 −0internal/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) |
| + } |
| +} |