Per-line tax codes, Task 3: attribution and apportionment, built behind the hold
taxCodesForLines keeps today's answer: one code is carried on the calculation, two hold the month (freeze goes through it). Behind that error: eligibleIntervals (today's paid coverage for a partly covered month, the whole month otherwise, since a fully covered month's Covered is nil by design); usageByCode (eligible days first, as today; each day to the span holding its first instant, 16a's proposal as the one rule body; per (code, meter) sums in date order, one floor to base units each, then the two transfer meters added, the month's own order); apportion (floor, largest remainder, byte-order ties, zero shares omitted, an amount with no weight an error); splitLinesByGrossUsage and lastSpansCodeLines, the two bodies decision 16 would choose between, neither called. checkedStatementDays and billingMeterScale are the existing validation and scales, shared; Task 0's pinned bytes pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+556
−37
internal/console/billingcalculation.go+52 −32internal/console/billinginvoice.go+9 −5internal/console/billingtaxsplit.go+275 −0internal/console/billingtaxsplit_test.go+220 −0
CI passed — run details
diff --git a/internal/console/billingcalculation.go b/internal/console/billingcalculation.go| index b62682b7c..66fa45662 100644 |
| --- a/internal/console/billingcalculation.go |
| +++ b/internal/console/billingcalculation.go |
| @@ -188,14 +188,7 @@ func priceBillingCalculation(statement usage.Statement, history []plans.Assignme |
| } |
| var amounts [5]int64 |
| for i, meter := range monthlyBillingMeters { |
| - scale := int64(1_000_000_000) |
| - if meter == usage.MeterInstanceHours { |
| - scale = 3600 |
| - } |
| - if meter == usage.MeterDurabilityWrites1k { |
| - scale = 1000 |
| - } |
| - amounts[i], err = billingBaseUnits(billed[meter], scale) |
| + amounts[i], err = billingBaseUnits(billed[meter], billingMeterScale(meter)) |
| if err != nil { |
| return nil, err |
| } |
| @@ -244,34 +237,37 @@ var errBillingDays = errors.New("billing_statement_days_unusable") |
| // quiet: the invoice pass logs it by name on every attempt. |
| var errBillingDaysMismatch = errors.New("billing_statement_days_mismatch") |
| -// billingUsageOnPaidDays returns each billed meter summed over the UTC days |
| -// that lie wholly inside one paid span, and those days in order. |
| -// |
| -// A day counts only when all 24 hours are inside a single covered interval. |
| -// A day the plan covered only part of (it started, ended or lapsed inside |
| -// the day) is not counted, because the statement cannot say which of that |
| -// day's usage fell on which side. A plan that starts exactly at midnight |
| -// covers that whole day, and it counts. The rounding is toward the |
| -// customer, by at most two partial days for each continuous paid span, so |
| -// a month with several spans (lapses) can leave out more than two. |
| -// |
| -// Before anything is counted the days must account for the statement: every |
| -// day inside the month, and each billed meter's days summing to its monthly |
| -// total. The days are walked in date order, so the sums, and with them |
| -// whether a statement at the edge of the tolerance is accepted, are the |
| -// same on every run. |
| -func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredInterval) (map[string]float64, []string, error) { |
| - start, err := time.Parse("2006-01", statement.Month) |
| +// billingMeterScale is the base unit of a billed meter: seconds of an |
| +// awake hour, thousandths of a thousand writes, nanos of everything else. |
| +func billingMeterScale(meter string) int64 { |
| + switch meter { |
| + case usage.MeterInstanceHours: |
| + return 3600 |
| + case usage.MeterDurabilityWrites1k: |
| + return 1000 |
| + } |
| + return 1_000_000_000 |
| +} |
| + |
| +// checkedStatementDays validates a statement's daily record as a whole |
| +// and returns the month's bounds: a statement with no days is usable only |
| +// when the month recorded nothing at all; every date lies inside the |
| +// month; every value is finite and not negative; each billed meter's days |
| +// sum to its monthly total within a relative tolerance. The eligibility |
| +// of days (billingUsageOnPaidDays) and the attribution of days to plans |
| +// (billingtaxsplit.go) both read the days only after this. |
| +func checkedStatementDays(statement usage.Statement) (start, end time.Time, err error) { |
| + start, err = time.Parse("2006-01", statement.Month) |
| if err != nil { |
| - return nil, nil, errBillingDaysMismatch |
| + return start, end, errBillingDaysMismatch |
| } |
| - end := start.AddDate(0, 1, 0) |
| + end = start.AddDate(0, 1, 0) |
| if statement.Days == nil { |
| // No day recorded anything. That is a usable statement only when |
| // the month recorded nothing at all. |
| for _, meter := range monthlyBillingMeters { |
| if statement.Meters[meter] != 0 { |
| - return nil, nil, errBillingDays |
| + return start, end, errBillingDays |
| } |
| } |
| } |
| @@ -284,7 +280,7 @@ func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredIn |
| for _, date := range dates { |
| day, perr := time.Parse("2006-01-02", date) |
| if perr != nil || day.Before(start) || !day.Before(end) { |
| - return nil, nil, errBillingDaysMismatch |
| + return start, end, errBillingDaysMismatch |
| } |
| for _, meter := range monthlyBillingMeters { |
| v, ok := statement.Days[date][meter] |
| @@ -292,7 +288,7 @@ func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredIn |
| continue |
| } |
| if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { |
| - return nil, nil, errBillingMeter |
| + return start, end, errBillingMeter |
| } |
| sums[meter] += v |
| } |
| @@ -300,9 +296,33 @@ func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredIn |
| for _, meter := range monthlyBillingMeters { |
| total := statement.Meters[meter] |
| if diff := math.Abs(sums[meter] - total); diff > 1e-9*math.Max(1, math.Abs(total)) { |
| - return nil, nil, errBillingDaysMismatch |
| + return start, end, errBillingDaysMismatch |
| } |
| } |
| + return start, end, nil |
| +} |
| + |
| +// billingUsageOnPaidDays returns each billed meter summed over the UTC days |
| +// that lie wholly inside one paid span, and those days in order. |
| +// |
| +// A day counts only when all 24 hours are inside a single covered interval. |
| +// A day the plan covered only part of (it started, ended or lapsed inside |
| +// the day) is not counted, because the statement cannot say which of that |
| +// day's usage fell on which side. A plan that starts exactly at midnight |
| +// covers that whole day, and it counts. The rounding is toward the |
| +// customer, by at most two partial days for each continuous paid span, so |
| +// a month with several spans (lapses) can leave out more than two. |
| +// |
| +// Before anything is counted the days must account for the statement: every |
| +// day inside the month, and each billed meter's days summing to its monthly |
| +// total. The days are walked in date order, so the sums, and with them |
| +// whether a statement at the edge of the tolerance is accepted, are the |
| +// same on every run. |
| +func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredInterval) (map[string]float64, []string, error) { |
| + start, end, err := checkedStatementDays(statement) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| out := map[string]float64{} |
| var counted []string |
| for day := start; day.Before(end); day = day.AddDate(0, 0, 1) { |
diff --git a/internal/console/billinginvoice.go b/internal/console/billinginvoice.go| index 72efcbbb4..bcfd0b54a 100644 |
| --- a/internal/console/billinginvoice.go |
| +++ b/internal/console/billinginvoice.go |
| @@ -467,16 +467,20 @@ func (r *billingInvoiceRun) freeze(ctx context.Context) (billingMonthRecord, err |
| if identity.LiveMode != r.b.cfg.LiveMode { |
| return billingMonthRecord{}, errBillingModeMismatch |
| } |
| - taxCode := "" |
| + codes := make([]string, 0, len(calc.Allowance.Assignments)) |
| for _, assignment := range calc.Allowance.Assignments { |
| code, ok := r.b.cfg.taxCode(assignment.Plan) |
| if !ok { |
| return billingMonthRecord{}, errBillingUnavailable |
| } |
| - if taxCode != "" && taxCode != code { |
| - return billingMonthRecord{}, errBillingTaxCodeMixed |
| - } |
| - taxCode = code |
| + codes = append(codes, code) |
| + } |
| + // One code, and the calculation carries it as it always has; two, and |
| + // the month holds (taxCodesForLines; decision 16 decides what a |
| + // two-code month's lines carry, and nothing here guesses). |
| + taxCode, err := taxCodesForLines(codes) |
| + if err != nil { |
| + return billingMonthRecord{}, err |
| } |
| calc.TaxCode = taxCode |
| raw, err := json.Marshal(calc) |
diff --git a/internal/console/billingtaxsplit.go b/internal/console/billingtaxsplit.go| new file mode 100644 |
| index 000000000..71300c7a3 |
| --- /dev/null |
| +++ b/internal/console/billingtaxsplit.go |
| @@ -0,0 +1,275 @@ |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "math/big" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// Per-line tax codes for a month in which the plan changed |
| +// (docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md). |
| +// |
| +// A month whose paid spans carry two product tax codes is not invoiced |
| +// today: taxCodesForLines answers errBillingTaxCodeMixed and the month |
| +// holds for a person. Which code each overage line should then carry is |
| +// decision 16 (and 16a to 16d) in DECISIONS-FOR-PAUL.md, the owner's. The |
| +// mechanics either answer needs are here, built and tested behind that |
| +// error: the attribution of eligible days to the plan in force, the |
| +// per-code weights in base units, and the apportionment of each axis's |
| +// unchanged amount. Nothing in this file changes an amount or a one-code |
| +// month's bytes. |
| + |
| +// errBillingTaxApportion means an axis has a positive amount and no |
| +// attributed usage to divide it by: the usage that produced the charge |
| +// is not attributed, and the month holds rather than guess. |
| +var errBillingTaxApportion = errors.New("billing_invoice_tax_code_mismatch") |
| + |
| +// taxCodesForLines decides the tax codes of a calculation's lines from the |
| +// codes of its paid spans: one code, and the calculation carries it as it |
| +// always has; two or more, and the month holds. The bodies that would |
| +// answer a two-code month (splitLinesByGrossUsage, lastSpansCodeLines) |
| +// exist and are tested, and are not called from here until decision 16. |
| +func taxCodesForLines(codes []string) (string, error) { |
| + code := "" |
| + for _, c := range codes { |
| + if code != "" && c != code { |
| + return "", errBillingTaxCodeMixed |
| + } |
| + code = c |
| + } |
| + return code, nil |
| +} |
| + |
| +// eligibleIntervals are the intervals of the month whose whole days are |
| +// billed: the paid coverage of a partly covered month, exactly as |
| +// billingUsageOnPaidDays reads it, and the whole month otherwise (a fully |
| +// covered month's Covered is nil by design, which is not "no days"). |
| +func eligibleIntervals(month string, allowance plans.MonthAllowance) ([]plans.CoveredInterval, error) { |
| + if allowance.Partial { |
| + return allowance.Covered, nil |
| + } |
| + start, err := time.Parse("2006-01", month) |
| + if err != nil { |
| + return nil, errBillingDaysMismatch |
| + } |
| + return []plans.CoveredInterval{{From: start, To: start.AddDate(0, 1, 0)}}, nil |
| +} |
| + |
| +// usageByCode attributes the eligible days' usage to the plan in force and |
| +// returns, per tax code, the four axes in base units: the allocation |
| +// weights of a two-code month. The rule for the day is 16a's proposal, the |
| +// one body there is: a UTC day belongs to the span that contains its first |
| +// instant. Eligibility comes first and is today's (a day counts only when |
| +// the whole of it lies inside the eligible intervals), so a lapse at 15:00 |
| +// excludes that day as it does today, and a continuous change at 15:00 |
| +// keeps it. The arithmetic is the month's own order: the days' values |
| +// are summed per (code, meter) in date order as the statement's floating |
| +// values, each sum is floored to base units once, then the two transfer |
| +// meters are added with the overflow check. These are weights, not the |
| +// month's billed usage, which comes from the whole month's meters as |
| +// before. A statement without Days is usable only when every billed meter |
| +// is zero, as billingUsageOnPaidDays has it; such a month has no weights |
| +// and nothing to divide. |
| +func usageByCode(statement usage.Statement, intervals []plans.CoveredInterval, spans []plans.PlanSpan, codeOf func(plan string) (string, bool)) (map[string]plans.Resources, error) { |
| + start, end, err := checkedStatementDays(statement) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if statement.Days == nil { |
| + // Nothing was used (the check above), so nothing is attributed. |
| + return map[string]plans.Resources{}, nil |
| + } |
| + sums := map[string]map[string]float64{} // code -> meter -> value |
| + for day := start; day.Before(end); day = day.AddDate(0, 0, 1) { |
| + next := day.AddDate(0, 0, 1) |
| + inside := false |
| + for _, iv := range intervals { |
| + if !iv.From.After(day) && !iv.To.Before(next) { |
| + inside = true |
| + break |
| + } |
| + } |
| + if !inside { |
| + continue |
| + } |
| + code, found := "", false |
| + for _, sp := range spans { |
| + if !sp.From.After(day) && day.Before(sp.To) { |
| + c, ok := codeOf(sp.Plan) |
| + if !ok { |
| + return nil, errBillingUnavailable |
| + } |
| + code, found = c, true |
| + break |
| + } |
| + } |
| + if !found { |
| + // An eligible day lies inside the merged coverage, which is |
| + // the union of the spans, so its first instant is in one of |
| + // them; a day that is not is a history this code cannot read. |
| + return nil, errBillingDaysMismatch |
| + } |
| + if sums[code] == nil { |
| + sums[code] = map[string]float64{} |
| + } |
| + for _, meter := range monthlyBillingMeters { |
| + sums[code][meter] += statement.Days[usage.DayOf(day)][meter] |
| + } |
| + } |
| + out := map[string]plans.Resources{} |
| + for code, byMeter := range sums { |
| + var units [5]int64 |
| + for i, meter := range monthlyBillingMeters { |
| + if units[i], err = billingBaseUnits(byMeter[meter], billingMeterScale(meter)); err != nil { |
| + return nil, err |
| + } |
| + } |
| + if units[2] > (1<<63-1)-units[3] { |
| + return nil, plans.ErrOverflow |
| + } |
| + out[code] = plans.Resources{AwakeSeconds: units[0], StorageByteMonths: units[1], TransferBytes: units[2] + units[3], Writes: units[4]} |
| + } |
| + return out, nil |
| +} |
| + |
| +// axisWeight reads the weight of one axis, named as the lines name it. |
| +func axisWeight(r plans.Resources, axis string) int64 { |
| + switch axis { |
| + case "awake_seconds": |
| + return r.AwakeSeconds |
| + case "storage_byte_months": |
| + return r.StorageByteMonths |
| + case "transfer_bytes": |
| + return r.TransferBytes |
| + case "writes": |
| + return r.Writes |
| + } |
| + return 0 |
| +} |
| + |
| +// taxShare is one code's share of one axis's amount. |
| +type taxShare struct { |
| + Code string |
| + Amount int64 |
| +} |
| + |
| +// apportion divides one axis's amount, in minor units, among codes in |
| +// proportion to their weights, without rounding beyond the axis: each |
| +// code's share is floored, and the minor units still owed to reach the |
| +// amount go one at a time to the codes with the largest remainder, ties |
| +// broken by the code's byte order. A share of zero is omitted. An amount |
| +// with no weight to divide it by is an error, not a guess; a zero amount |
| +// has no shares. Two calls with the same inputs give the same shares in |
| +// the same order whatever order the map was built in. |
| +func apportion(amount int64, weights map[string]int64) ([]taxShare, error) { |
| + if amount < 0 { |
| + return nil, errBillingTaxApportion |
| + } |
| + codes := make([]string, 0, len(weights)) |
| + var sum int64 |
| + for code, w := range weights { |
| + if w < 0 { |
| + return nil, errBillingTaxApportion |
| + } |
| + if w > 0 { |
| + codes = append(codes, code) |
| + sum += w |
| + if sum < 0 { |
| + return nil, plans.ErrOverflow |
| + } |
| + } |
| + } |
| + sort.Strings(codes) |
| + if amount == 0 { |
| + return nil, nil |
| + } |
| + if sum == 0 { |
| + return nil, errBillingTaxApportion |
| + } |
| + type part struct { |
| + code string |
| + share int64 |
| + remainder *big.Int |
| + } |
| + parts := make([]part, 0, len(codes)) |
| + var given int64 |
| + bigAmount, bigSum := big.NewInt(amount), big.NewInt(sum) |
| + for _, code := range codes { |
| + product := new(big.Int).Mul(bigAmount, big.NewInt(weights[code])) |
| + var share, remainder big.Int |
| + share.QuoRem(product, bigSum, &remainder) |
| + if !share.IsInt64() { |
| + return nil, plans.ErrOverflow |
| + } |
| + parts = append(parts, part{code, share.Int64(), &remainder}) |
| + given += share.Int64() |
| + } |
| + extra := amount - given |
| + if extra < 0 || extra > int64(len(parts)) { |
| + return nil, errBillingTaxApportion |
| + } |
| + order := make([]int, len(parts)) |
| + for i := range order { |
| + order[i] = i |
| + } |
| + sort.SliceStable(order, func(i, j int) bool { // parts are in code byte order already; a stable sort keeps ties so |
| + return parts[order[i]].remainder.Cmp(parts[order[j]].remainder) > 0 |
| + }) |
| + for k := int64(0); k < extra; k++ { |
| + parts[order[k]].share++ |
| + } |
| + out := make([]taxShare, 0, len(parts)) |
| + for _, p := range parts { |
| + if p.share > 0 { |
| + out = append(out, taxShare{Code: p.code, Amount: p.share}) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// splitLinesByGrossUsage is the body for 16b's proposal: each axis's line, |
| +// with the amount it has today, is divided among the codes by their |
| +// attributed usage of that axis, and the result is one line per (axis, |
| +// code) in axis order then code byte order, each carrying its code. The |
| +// subtotal is unchanged. Not called from freeze until decision 16. |
| +func splitLinesByGrossUsage(lines []plans.OverageLine, weights map[string]plans.Resources) ([]plans.OverageLine, error) { |
| + out := make([]plans.OverageLine, 0, len(lines)) |
| + for _, line := range lines { |
| + if line.TaxCode != "" { |
| + return nil, errBillingTaxApportion |
| + } |
| + w := map[string]int64{} |
| + for code, r := range weights { |
| + w[code] = axisWeight(r, line.Meter) |
| + } |
| + shares, err := apportion(line.AmountCents, w) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, s := range shares { |
| + out = append(out, plans.OverageLine{Meter: line.Meter, AmountCents: s.Amount, TaxCode: s.Code}) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// lastSpansCodeLines is the body for the month-end answer as 16c would |
| +// settle it: every line carries the one code given, nothing is divided. |
| +// Not called from freeze until decision 16. |
| +func lastSpansCodeLines(lines []plans.OverageLine, code string) ([]plans.OverageLine, error) { |
| + if !validTaxCode(code) { |
| + return nil, errBillingTaxApportion |
| + } |
| + out := make([]plans.OverageLine, 0, len(lines)) |
| + for _, line := range lines { |
| + if line.TaxCode != "" { |
| + return nil, errBillingTaxApportion |
| + } |
| + out = append(out, plans.OverageLine{Meter: line.Meter, AmountCents: line.AmountCents, TaxCode: code}) |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/console/billingtaxsplit_test.go b/internal/console/billingtaxsplit_test.go| new file mode 100644 |
| index 000000000..91941f33d |
| --- /dev/null |
| +++ b/internal/console/billingtaxsplit_test.go |
| @@ -0,0 +1,220 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "math" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +func taxSplitCodeOf(plan string) (string, bool) { |
| + code, ok := taxShapeCodes[plan] |
| + return code, ok |
| +} |
| + |
| +func sep(day, hour int) time.Time { return time.Date(2026, 9, day, hour, 0, 0, 0, time.UTC) } |
| + |
| +func span(from, to time.Time, plan string) plans.PlanSpan { |
| + return plans.PlanSpan{From: from, To: to, Plan: plan} |
| +} |
| + |
| +func iv(from, to time.Time) plans.CoveredInterval { return plans.CoveredInterval{From: from, To: to} } |
| + |
| +// A statement with ten awake hours on every day of September. |
| +func dailyStatement() usage.Statement { |
| + st := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 300}, Days: map[string]map[string]float64{}, DaysInMonth: 30, Closed: true} |
| + for d := 1; d <= 30; d++ { |
| + st.Days[fmt.Sprintf("2026-09-%02d", d)] = map[string]float64{usage.MeterInstanceHours: 10} |
| + } |
| + return st |
| +} |
| + |
| +// Two codes still hold; one code is carried as it always was. |
| +func TestTwoTaxCodesStillHoldTheMonth(t *testing.T) { |
| + if code, err := taxCodesForLines([]string{"txcd_10102001", "txcd_10102001"}); err != nil || code != "txcd_10102001" { |
| + t.Fatalf("one code: %q %v", code, err) |
| + } |
| + if _, err := taxCodesForLines([]string{"txcd_10102001", "txcd_10102000"}); !errors.Is(err, errBillingTaxCodeMixed) { |
| + t.Fatalf("two codes: %v", err) |
| + } |
| +} |
| + |
| +// Eligible days first, as today, then each day to the span that holds its |
| +// first instant. |
| +func TestADaysUsageFollowsThePlanInForceAtItsStart(t *testing.T) { |
| + oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| + whole := []plans.CoveredInterval{{From: sep(1, 0), To: oct1}} |
| + hours := func(h int64) int64 { return h * 3600 } |
| + for name, tc := range map[string]struct { |
| + intervals []plans.CoveredInterval |
| + spans []plans.PlanSpan |
| + want map[string]plans.Resources |
| + err error |
| + }{ |
| + "a change at 00:00": {whole, []plans.PlanSpan{span(sep(1, 0), sep(16, 0), plans.PlanPersonal), span(sep(16, 0), oct1, plans.PlanBusiness)}, |
| + map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: hours(150)}, "txcd_10102000": {AwakeSeconds: hours(150)}}, nil}, |
| + "a continuous change at 15:00 (the day eligible, under the earlier plan)": {whole, []plans.PlanSpan{span(sep(1, 0), sep(16, 15), plans.PlanPersonal), span(sep(16, 15), oct1, plans.PlanBusiness)}, |
| + map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: hours(160)}, "txcd_10102000": {AwakeSeconds: hours(140)}}, nil}, |
| + "a lapse at 15:00 (the day excluded, as today)": {[]plans.CoveredInterval{iv(sep(1, 0), sep(16, 15)), iv(sep(17, 0), oct1)}, []plans.PlanSpan{span(sep(1, 0), sep(16, 15), plans.PlanPersonal), span(sep(17, 0), oct1, plans.PlanBusiness)}, |
| + map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: hours(150)}, "txcd_10102000": {AwakeSeconds: hours(140)}}, nil}, |
| + "a change at the month's first instant": {whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, |
| + map[string]plans.Resources{"txcd_10102000": {AwakeSeconds: hours(300)}}, nil}, |
| + "two spans of one code aggregate": {whole, []plans.PlanSpan{span(sep(1, 0), sep(10, 0), plans.PlanPersonal), span(sep(10, 0), sep(20, 0), plans.PlanCommunity), span(sep(20, 0), oct1, plans.PlanBusiness)}, |
| + map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: hours(190)}, "txcd_10102000": {AwakeSeconds: hours(110)}}, nil}, |
| + "an eligible day no span holds": {whole, []plans.PlanSpan{span(sep(2, 0), oct1, plans.PlanBusiness)}, nil, errBillingDaysMismatch}, |
| + "a plan with no code": {whole, []plans.PlanSpan{span(sep(1, 0), oct1, "mystery")}, nil, errBillingUnavailable}, |
| + } { |
| + got, err := usageByCode(dailyStatement(), tc.intervals, tc.spans, taxSplitCodeOf) |
| + if !errors.Is(err, tc.err) || (tc.err == nil && !reflect.DeepEqual(got, tc.want)) { |
| + t.Errorf("%s: %+v %v", name, got, err) |
| + } |
| + } |
| + // A fully covered month's Covered is nil by design: the whole month is |
| + // eligible, not nothing. |
| + if ivs, err := eligibleIntervals("2026-09", plans.MonthAllowance{}); err != nil || !reflect.DeepEqual(ivs, whole) { |
| + t.Fatalf("a whole month: %+v %v", ivs, err) |
| + } |
| + if ivs, _ := eligibleIntervals("2026-09", plans.MonthAllowance{Partial: true, Covered: whole[:1]}); !reflect.DeepEqual(ivs, whole[:1]) { |
| + t.Fatal("a partly covered month keeps its coverage") |
| + } |
| + // No days: nothing to attribute when nothing was used, a hold otherwise. |
| + empty := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{}, DaysInMonth: 30, Closed: true} |
| + if got, err := usageByCode(empty, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); err != nil || len(got) != 0 { |
| + t.Fatalf("no days and no usage: %+v %v", got, err) |
| + } |
| + empty.Meters[usage.MeterInstanceHours] = 5 |
| + if _, err := usageByCode(empty, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); !errors.Is(err, errBillingDays) { |
| + t.Fatalf("no days with usage: %v", err) |
| + } |
| + // Malformed days hold as they do today. |
| + bad := dailyStatement() |
| + bad.Days["2026-10-01"] = map[string]float64{usage.MeterInstanceHours: 0} |
| + if _, err := usageByCode(bad, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); !errors.Is(err, errBillingDaysMismatch) { |
| + t.Fatalf("a date outside the month: %v", err) |
| + } |
| + bad = dailyStatement() |
| + bad.Days["2026-09-03"][usage.MeterInstanceHours] = math.NaN() |
| + if _, err := usageByCode(bad, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); !errors.Is(err, errBillingMeter) { |
| + t.Fatalf("a value that is not a number: %v", err) |
| + } |
| + bad = dailyStatement() |
| + bad.Meters[usage.MeterInstanceHours] = 299 |
| + if _, err := usageByCode(bad, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); !errors.Is(err, errBillingDaysMismatch) { |
| + t.Fatalf("days that do not sum to the month: %v", err) |
| + } |
| +} |
| + |
| +// The two transfer meters are floored to bytes each and then added, as the |
| +// month's own usage is: a fraction in each does not make a byte together. |
| +// Ordinary egress under one plan and store egress under the other land in |
| +// the one transfer axis. |
| +func TestTransferWeightsFloorEachMeterThenAdd(t *testing.T) { |
| + oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| + tiny := math.Ldexp(1, -30) // 2^-30 GB: 0.93 bytes, exact in binary |
| + st := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterEgressGB: tiny + 1, usage.MeterStoreEgressGB: tiny}, DaysInMonth: 30, Closed: true, |
| + Days: map[string]map[string]float64{"2026-09-01": {usage.MeterEgressGB: tiny, usage.MeterStoreEgressGB: tiny}, "2026-09-20": {usage.MeterEgressGB: 1}}} |
| + got, err := usageByCode(st, []plans.CoveredInterval{{From: sep(1, 0), To: oct1}}, []plans.PlanSpan{span(sep(1, 0), sep(16, 0), plans.PlanPersonal), span(sep(16, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got["txcd_10102001"].TransferBytes != 0 || got["txcd_10102000"].TransferBytes != 1_000_000_000 { |
| + t.Fatalf("transfer weights: %+v (two fractions of a byte must not make a byte)", got) |
| + } |
| +} |
| + |
| +// An axis's amount is divided by weight: floors, then the largest |
| +// remainders, ties by the code's byte order; the shares sum to the amount |
| +// whatever order the weights were given in. |
| +func TestAnAxisIsApportionedWithoutRoundingBeyondIt(t *testing.T) { |
| + for name, tc := range map[string]struct { |
| + amount int64 |
| + weights map[string]int64 |
| + want []taxShare |
| + err error |
| + }{ |
| + "two thirds and a third": {1000, map[string]int64{"txcd_10102001": 576000, "txcd_10102000": 504000}, []taxShare{{"txcd_10102000", 467}, {"txcd_10102001", 533}}, nil}, |
| + "equal remainders, byte order": {3, map[string]int64{"txcd_b": 1, "txcd_a": 1}, []taxShare{{"txcd_a", 2}, {"txcd_b", 1}}, nil}, |
| + "all under one code": {7, map[string]int64{"txcd_a": 0, "txcd_b": 9}, []taxShare{{"txcd_b", 7}}, nil}, |
| + "a tiny share rounds to nothing": {1, map[string]int64{"txcd_a": 1, "txcd_b": 1_000_000}, []taxShare{{"txcd_b", 1}}, nil}, |
| + "nothing to divide": {0, map[string]int64{"txcd_a": 1}, nil, nil}, |
| + "an amount with no weight": {5, map[string]int64{"txcd_a": 0}, nil, errBillingTaxApportion}, |
| + "a negative weight": {5, map[string]int64{"txcd_a": -1}, nil, errBillingTaxApportion}, |
| + "weights that overflow": {5, map[string]int64{"txcd_a": math.MaxInt64, "txcd_b": 1}, nil, plans.ErrOverflow}, |
| + } { |
| + got, err := apportion(tc.amount, tc.weights) |
| + if !errors.Is(err, tc.err) || !reflect.DeepEqual(got, tc.want) { |
| + t.Errorf("%s: %+v %v", name, got, err) |
| + } |
| + } |
| + // The same shares whatever the map's insertion order. |
| + a := map[string]int64{} |
| + b := map[string]int64{} |
| + for i := 0; i < 50; i++ { |
| + a[fmt.Sprintf("txcd_%02d", i)] = int64(i + 1) |
| + } |
| + for i := 49; i >= 0; i-- { |
| + b[fmt.Sprintf("txcd_%02d", i)] = int64(i + 1) |
| + } |
| + x, _ := apportion(12345, a) |
| + y, _ := apportion(12345, b) |
| + var sum int64 |
| + for _, s := range x { |
| + sum += s.Amount |
| + } |
| + if !reflect.DeepEqual(x, y) || sum != 12345 { |
| + t.Fatalf("order-dependent or not conserving: %v %v %d", x, y, sum) |
| + } |
| +} |
| + |
| +// The two bodies behind the hold produce the two-code shape with the |
| +// subtotal unchanged: one line per (axis, code) under the split, every |
| +// line under one code for the month-end answer. Neither is called from |
| +// freeze. |
| +func TestTheTwoCodeBodiesKeepTheSubtotalAndTheShape(t *testing.T) { |
| + lines := []plans.OverageLine{{Meter: "awake_seconds", AmountCents: 1000}, {Meter: "writes", AmountCents: 7}} |
| + weights := map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: 576000}, "txcd_10102000": {AwakeSeconds: 504000, Writes: 9}} |
| + split, err := splitLinesByGrossUsage(lines, weights) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []plans.OverageLine{{Meter: "awake_seconds", AmountCents: 467, TaxCode: "txcd_10102000"}, {Meter: "awake_seconds", AmountCents: 533, TaxCode: "txcd_10102001"}, {Meter: "writes", AmountCents: 7, TaxCode: "txcd_10102000"}} |
| + if !reflect.DeepEqual(split, want) { |
| + t.Fatalf("split: %+v", split) |
| + } |
| + last, err := lastSpansCodeLines(lines, "txcd_10102000") |
| + if err != nil || !reflect.DeepEqual(last, []plans.OverageLine{{Meter: "awake_seconds", AmountCents: 1000, TaxCode: "txcd_10102000"}, {Meter: "writes", AmountCents: 7, TaxCode: "txcd_10102000"}}) { |
| + t.Fatalf("last: %+v %v", last, err) |
| + } |
| + for _, body := range [][]plans.OverageLine{split, last} { |
| + var sum int64 |
| + for _, l := range body { |
| + sum += l.AmountCents |
| + } |
| + calc := billingCalculation{V: 2, Lines: body, Total: &plans.Invoice{TotalMinor: 1007}} |
| + if sum != 1007 || !calc.taxShapeOK() { |
| + t.Fatalf("a body: sum %d shape %v", sum, calc.taxShapeOK()) |
| + } |
| + raw, _ := json.Marshal(calc) |
| + var back billingCalculation |
| + if json.Unmarshal(raw, &back) != nil || !reflect.DeepEqual(back.Lines, body) { |
| + t.Fatalf("round trip: %s", raw) |
| + } |
| + } |
| + // An axis with an amount and no weight holds rather than guesses; a |
| + // line that already carries a code is not divided again. |
| + if _, err := splitLinesByGrossUsage([]plans.OverageLine{{Meter: "storage_byte_months", AmountCents: 3}}, weights); !errors.Is(err, errBillingTaxApportion) { |
| + t.Fatalf("no weight: %v", err) |
| + } |
| + if _, err := splitLinesByGrossUsage(want, weights); !errors.Is(err, errBillingTaxApportion) { |
| + t.Fatalf("already tagged: %v", err) |
| + } |
| + if _, err := lastSpansCodeLines(lines, "txcd_"); !errors.Is(err, errBillingTaxApportion) { |
| + t.Fatalf("a malformed code: %v", err) |
| + } |
| +} |