Per-line tax codes, Task 2: the tax shape, a line's own request and operation identity, the three metadata checks
taxShapeOK: one code on the calculation and none on any line, or none on the calculation and one on every line; a code is txcd_ and more; a calculation that is not waived has lines. The invoice run's entry guard keeps its account, month and money checks and asks the shape instead of the top-level predicate. lineParams takes the line's code (sent as the item's tax_code and metadata[tax_code]) or the calculation's, never both, never neither; lineKind is line-<meter> as before, line-<meter>-<code> for a line with its own. The POST answer, the recovery match and the draft validation require metadata[tax_code] to be the frozen line's when the line carries one. InvoiceItemParams.TaxCodeMetadata is omitempty, so an old request encodes and hashes as before (Task 0's pinned hashes pass), and the client refuses a metadata code that is not the item's. The fake provider stores and reports a line's tax code and metadata.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+199
−12
internal/billingstripe/invoices.go+12 −0internal/billingstripe/invoices_test.go+26 −0internal/console/billingcalculation.go+33 −0internal/console/billinginvoice.go+31 −9internal/console/billinginvoice_test.go+20 −3internal/console/billingtaxshape_test.go+77 −0
CI cancelled — superseded by a newer push — run details
diff --git a/internal/billingstripe/invoices.go b/internal/billingstripe/invoices.go| index b00b61069..65f75ea43 100644 |
| --- a/internal/billingstripe/invoices.go |
| +++ b/internal/billingstripe/invoices.go |
| @@ -266,6 +266,12 @@ func (x *InvoiceItem) UnmarshalJSON(data []byte) error { |
| type InvoiceItemParams struct { |
| Customer, Invoice, Meter, TaxCode, Description, CalculationDigest string |
| AmountCents, PeriodStart, PeriodEnd int64 |
| + // TaxCodeMetadata, when set, is sent as metadata[tax_code] and must |
| + // equal TaxCode: a line that carries its own code (one of two for a |
| + // meter) names it in metadata so it can be told from its sibling when |
| + // read back. Omitted from the encoding when empty, so a request |
| + // without it hashes exactly as before the field existed. |
| + TaxCodeMetadata string `json:",omitempty"` |
| // Currency is the currency of the item: the invoice's and the |
| // calculation's own. Required, lower case; omitted from the encoding |
| // when empty for the same reason as DraftInvoiceParams.Currency. |
| @@ -277,12 +283,18 @@ func (c *Client) CreateInvoiceItem(ctx context.Context, p InvoiceItemParams, key |
| !validID(p.Meter) || !validDigest(p.CalculationDigest) || p.AmountCents <= 0 || p.PeriodStart <= 0 || p.PeriodEnd <= p.PeriodStart || len(p.Description) > 500 || !validCurrency(p.Currency) { |
| return nil, &Error{Code: CodeInvalidParams} |
| } |
| + if p.TaxCodeMetadata != "" && p.TaxCodeMetadata != p.TaxCode { |
| + return nil, &Error{Code: CodeInvalidParams} |
| + } |
| form := url.Values{ |
| "customer": {p.Customer}, "invoice": {p.Invoice}, "amount": {strconv.FormatInt(p.AmountCents, 10)}, "currency": {p.Currency}, |
| "discountable": {"false"}, "tax_behavior": {"exclusive"}, "tax_code": {p.TaxCode}, |
| "description": {p.Description}, "period[start]": {strconv.FormatInt(p.PeriodStart, 10)}, "period[end]": {strconv.FormatInt(p.PeriodEnd, 10)}, |
| "metadata[meter]": {p.Meter}, "metadata[calculation_digest]": {p.CalculationDigest}, |
| } |
| + if p.TaxCodeMetadata != "" { |
| + form.Set("metadata[tax_code]", p.TaxCodeMetadata) |
| + } |
| var out InvoiceItem |
| if err := c.do(ctx, http.MethodPost, "/v1/invoiceitems", form, key, &out); err != nil { |
| return nil, err |
diff --git a/internal/billingstripe/invoices_test.go b/internal/billingstripe/invoices_test.go| index 637597890..7a20c6650 100644 |
| --- a/internal/billingstripe/invoices_test.go |
| +++ b/internal/billingstripe/invoices_test.go |
| @@ -163,6 +163,32 @@ func TestInvoiceItemTargetsOnlyItsDraftWithNoDiscount(t *testing.T) { |
| } |
| } |
| +// A line that carries its own tax code names it in metadata too; one that |
| +// does not sends no metadata[tax_code] at all; a metadata code that is not |
| +// the item's tax code is refused before any request. |
| +func TestInvoiceItemNamesItsOwnTaxCodeInMetadata(t *testing.T) { |
| + var form url.Values |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + r.ParseForm() |
| + form = r.PostForm |
| + w.Write([]byte(`{"id":"ii_usage","invoice":"in_usage","customer":"cus_paid","livemode":false,"currency":"eur","amount":800,"discountable":false}`)) |
| + })) |
| + defer srv.Close() |
| + p := invoiceItemFixture() |
| + if _, err := testClient(t, srv.URL).CreateInvoiceItem(context.Background(), p, "k1"); err != nil || form.Has("metadata[tax_code]") { |
| + t.Fatalf("a line without its own code: %v %v", form, err) |
| + } |
| + p.TaxCodeMetadata = p.TaxCode |
| + if _, err := testClient(t, srv.URL).CreateInvoiceItem(context.Background(), p, "k2"); err != nil || form.Get("metadata[tax_code]") != p.TaxCode || form.Get("tax_code") != p.TaxCode { |
| + t.Fatalf("a line with its own code: %v %v", form, err) |
| + } |
| + form = nil |
| + p.TaxCodeMetadata = "txcd_other" |
| + if _, err := testClient(t, srv.URL).CreateInvoiceItem(context.Background(), p, "k3"); err == nil || form != nil { |
| + t.Fatalf("a metadata code that is not the tax code: %v %v", form, err) |
| + } |
| +} |
| + |
| func TestFinalizeInvoiceLeavesCollectionExplicit(t *testing.T) { |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path != "/v1/invoices/in_usage/finalize" || r.Method != "POST" || r.Header.Get("Idempotency-Key") != "stable-finalize" { |
diff --git a/internal/console/billingcalculation.go b/internal/console/billingcalculation.go| index 12f2cde36..b62682b7c 100644 |
| --- a/internal/console/billingcalculation.go |
| +++ b/internal/console/billingcalculation.go |
| @@ -9,6 +9,7 @@ import ( |
| "math" |
| "math/big" |
| "sort" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/plans" |
| @@ -77,6 +78,38 @@ func (c *billingCalculation) wellFormed() bool { |
| return false |
| } |
| +// validTaxCode is the shape of a product tax code: "txcd_" and at least |
| +// one more character. Which codes exist is the provider's; the console |
| +// checks the shape alone. |
| +func validTaxCode(code string) bool { return strings.HasPrefix(code, "txcd_") && len(code) > 5 } |
| + |
| +// taxShapeOK reports whether the calculation carries its tax codes in one |
| +// of the two shapes it may: one code on the calculation and none on any |
| +// line (every calculation frozen before lines carried codes, and every |
| +// month whose paid spans map to one code), or no code on the calculation |
| +// and one on every line (a month whose paid spans carry two codes). Either |
| +// way a calculation that is not waived has lines, and every code present |
| +// has the shape of one. A calculation in neither shape is not invoiced and |
| +// not priced again: it is a record to look at. |
| +func (c *billingCalculation) taxShapeOK() bool { |
| + tagged := 0 |
| + for _, l := range c.Lines { |
| + if l.TaxCode != "" { |
| + if !validTaxCode(l.TaxCode) { |
| + return false |
| + } |
| + tagged++ |
| + } |
| + } |
| + if len(c.Lines) == 0 && !c.waived() { |
| + return false |
| + } |
| + if c.TaxCode != "" { |
| + return validTaxCode(c.TaxCode) && tagged == 0 |
| + } |
| + return tagged == len(c.Lines) |
| +} |
| + |
| // waived and totalMinor read the invoice total of either version. They are |
| // meaningful only for a well-formed calculation. |
| func (c *billingCalculation) waived() bool { |
diff --git a/internal/console/billinginvoice.go b/internal/console/billinginvoice.go| index 07f78a6b1..72efcbbb4 100644 |
| --- a/internal/console/billinginvoice.go |
| +++ b/internal/console/billinginvoice.go |
| @@ -10,7 +10,6 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| - "strings" |
| "sync" |
| "time" |
| @@ -333,7 +332,7 @@ func (s *Server) invoiceBillingMonth(ctx context.Context, acct *Account, month s |
| // The stored calculation must be whole for its version and name one |
| // currency throughout. One that is not is never invoiced, and is never |
| // priced again in some other currency: it is a record to look at. |
| - if r.calc.Statement.Account != acct.Sqid || r.calc.Statement.Month != month || !r.calc.wellFormed() || !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 { |
| + if r.calc.Statement.Account != acct.Sqid || r.calc.Statement.Month != month || !r.calc.wellFormed() || !r.calc.taxShapeOK() { |
| return r.review("billing_invoice_record_mismatch") |
| } |
| if err := r.reload(); err != nil { |
| @@ -955,15 +954,38 @@ func (r *billingInvoiceRun) stepCreating(ctx context.Context) error { |
| }) |
| } |
| +// lineParams is the provider request for one line: the line's own tax |
| +// code when it carries one (a two-code calculation; the code is then also |
| +// sent as the item's metadata, which is how the line is told apart from |
| +// its sibling of the same meter afterwards), else the calculation's. A |
| +// line with a code at both levels, or at neither, is not sent. |
| func (r *billingInvoiceRun) lineParams(line plans.OverageLine) (billingstripe.InvoiceItemParams, error) { |
| - if !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 { |
| + code, meta := r.calc.TaxCode, "" |
| + if line.TaxCode != "" { |
| + if code != "" { |
| + return billingstripe.InvoiceItemParams{}, errBillingInvoiceMismatch |
| + } |
| + code, meta = line.TaxCode, line.TaxCode |
| + } |
| + if !validTaxCode(code) { |
| return billingstripe.InvoiceItemParams{}, errBillingInvoiceMismatch |
| } |
| - return billingstripe.InvoiceItemParams{Customer: r.rec.Customer, Invoice: r.rec.InvoiceID, Meter: line.Meter, TaxCode: r.calc.TaxCode, |
| + return billingstripe.InvoiceItemParams{Customer: r.rec.Customer, Invoice: r.rec.InvoiceID, Meter: line.Meter, TaxCode: code, TaxCodeMetadata: meta, |
| Description: billingInvoiceLineDescription(line.Meter, r.month), CalculationDigest: r.rec.Digest, |
| AmountCents: line.AmountCents, PeriodStart: r.start.Unix(), PeriodEnd: r.end.Unix(), Currency: r.calc.Currency}, nil |
| } |
| +// lineKind is the journal's name for a line's operation: by meter, as it |
| +// always was, for a line without its own code; by meter and code for a |
| +// line with one, so two lines of one meter in a two-code calculation are |
| +// two operations. The two cannot collide: a code is never empty. |
| +func lineKind(line plans.OverageLine) string { |
| + if line.TaxCode == "" { |
| + return "line-" + line.Meter |
| + } |
| + return "line-" + line.Meter + "-" + line.TaxCode |
| +} |
| + |
| // billingInvoiceLineDescription is what the customer reads on the invoice |
| // line. There is no approved invoice copy yet (the billing-launch copy |
| // review has no result), so the text stays input-bound: the meter's own |
| @@ -993,7 +1015,7 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error { |
| if err != nil { |
| return r.review("billing_invoice_tax_code_mismatch") |
| } |
| - kind := "line-" + line.Meter |
| + kind := lineKind(line) |
| if err := r.perform(ctx, providerOp{ |
| kind: kind, hash: r.requestHash(kind, params), |
| post: func(ctx context.Context, key string) (string, string, error) { |
| @@ -1001,7 +1023,7 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error { |
| if err != nil { |
| return "", "", err |
| } |
| - if item.Invoice != r.rec.InvoiceID || item.Customer != r.rec.Customer || item.LiveMode != r.b.cfg.LiveMode || item.Amount != line.AmountCents || item.Currency != r.calc.Currency || item.Discountable || item.Metadata["meter"] != line.Meter || item.Metadata["calculation_digest"] != r.rec.Digest { |
| + if item.Invoice != r.rec.InvoiceID || item.Customer != r.rec.Customer || item.LiveMode != r.b.cfg.LiveMode || item.Amount != line.AmountCents || item.Currency != r.calc.Currency || item.Discountable || item.Metadata["meter"] != line.Meter || item.Metadata["calculation_digest"] != r.rec.Digest || (line.TaxCode != "" && item.Metadata["tax_code"] != line.TaxCode) { |
| return item.ID, "attached", errBillingInvoiceMismatch |
| } |
| return item.ID, "attached", nil |
| @@ -1013,7 +1035,7 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error { |
| } |
| var matches []billingstripe.InvoiceLine |
| for _, l := range lines { |
| - if l.Metadata["meter"] == line.Meter && l.Metadata["calculation_digest"] == r.rec.Digest && l.InvoiceItem != "" { |
| + if l.Metadata["meter"] == line.Meter && l.Metadata["calculation_digest"] == r.rec.Digest && l.InvoiceItem != "" && (line.TaxCode == "" || l.Metadata["tax_code"] == line.TaxCode) { |
| matches = append(matches, l) |
| } |
| } |
| @@ -1103,7 +1125,7 @@ func (r *billingInvoiceRun) validateDraftLines(ctx context.Context) error { |
| } |
| expected := map[string]plans.OverageLine{} |
| for _, line := range r.calc.Lines { |
| - op, ok := r.ops["line-"+line.Meter] |
| + op, ok := r.ops[lineKind(line)] |
| if !ok || op.State != billingOpAcked || op.ObjectID == "" { |
| return errBillingConflict |
| } |
| @@ -1113,7 +1135,7 @@ func (r *billingInvoiceRun) validateDraftLines(ctx context.Context) error { |
| var sum int64 |
| for _, l := range lines { |
| want, ok := expected[l.InvoiceItem] |
| - if !ok || seen[l.InvoiceItem] || l.Amount != want.AmountCents || l.Currency != r.calc.Currency || l.Discountable || l.Metadata["meter"] != want.Meter || l.Metadata["calculation_digest"] != r.rec.Digest { |
| + if !ok || seen[l.InvoiceItem] || l.Amount != want.AmountCents || l.Currency != r.calc.Currency || l.Discountable || l.Metadata["meter"] != want.Meter || l.Metadata["calculation_digest"] != r.rec.Digest || (want.TaxCode != "" && l.Metadata["tax_code"] != want.TaxCode) { |
| return r.review("billing_invoice_lines_mismatch") |
| } |
| seen[l.InvoiceItem] = true |
diff --git a/internal/console/billinginvoice_test.go b/internal/console/billinginvoice_test.go| index 2581f52db..afe7455fd 100644 |
| --- a/internal/console/billinginvoice_test.go |
| +++ b/internal/console/billinginvoice_test.go |
| @@ -79,6 +79,19 @@ type fakeLine struct { |
| currency string |
| id, item, meter, digest string |
| amount int64 |
| + // taxCode is the item's tax_code as posted; metaTax its |
| + // metadata[tax_code], set only by a line that carries its own code. |
| + taxCode, metaTax string |
| +} |
| + |
| +// lineMetadata is what the provider reports as a line's metadata: the |
| +// meter and the digest always, the tax code when the request carried it. |
| +func (l fakeLine) lineMetadata() map[string]string { |
| + m := map[string]string{"meter": l.meter, "calculation_digest": l.digest} |
| + if l.metaTax != "" { |
| + m["tax_code"] = l.metaTax |
| + } |
| + return m |
| } |
| func newFakeInvoiceStripe(sqid string, now func() time.Time) *fakeInvoiceStripe { |
| @@ -121,7 +134,7 @@ func (f *fakeInvoiceStripe) invoiceJSON(inv *fakeInvoice, allLines bool) map[str |
| continue |
| } |
| lines = append(lines, map[string]any{"id": l.id, "invoice_item": l.item, "type": "invoiceitem", "amount": l.amount, "currency": either(f.reportLineCurrency, l.currency), "discountable": false, |
| - "metadata": map[string]string{"meter": l.meter, "calculation_digest": l.digest}, "period": map[string]int64{"start": 1, "end": 2}}) |
| + "metadata": l.lineMetadata(), "period": map[string]int64{"start": 1, "end": 2}}) |
| } |
| if f.extraLine && inv.status == "draft" { |
| lines = append(lines, map[string]any{"id": "il_extra", "invoice_item": "ii_extra", "type": "invoiceitem", "amount": 100, "currency": inv.currency, "discountable": false, "metadata": map[string]string{}}) |
| @@ -190,6 +203,9 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) { |
| switch { |
| case path == "/v1/invoiceitems": |
| kind = "line-" + r.PostForm.Get("metadata[meter]") |
| + if tc := r.PostForm.Get("metadata[tax_code]"); tc != "" { |
| + kind += "-" + tc |
| + } |
| case strings.HasSuffix(path, "/finalize"): |
| kind = "finalize" |
| case strings.HasSuffix(path, "/pay"): |
| @@ -228,9 +244,10 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) { |
| f.fault(kind, w, func() any { |
| f.seq++ |
| amount, _ := strconv.ParseInt(r.PostForm.Get("amount"), 10, 64) |
| - line := fakeLine{currency: r.PostForm.Get("currency"), id: "il_" + strconv.Itoa(f.seq), item: "ii_" + strconv.Itoa(f.seq), meter: r.PostForm.Get("metadata[meter]"), digest: r.PostForm.Get("metadata[calculation_digest]"), amount: amount} |
| + line := fakeLine{currency: r.PostForm.Get("currency"), id: "il_" + strconv.Itoa(f.seq), item: "ii_" + strconv.Itoa(f.seq), meter: r.PostForm.Get("metadata[meter]"), digest: r.PostForm.Get("metadata[calculation_digest]"), amount: amount, |
| + taxCode: r.PostForm.Get("tax_code"), metaTax: r.PostForm.Get("metadata[tax_code]")} |
| inv.lines = append(inv.lines, line) |
| - return map[string]any{"id": line.item, "invoice": inv.id, "customer": inv.customer, "livemode": false, "amount": amount, "currency": either(f.reportItemCurrency, line.currency), "discountable": r.PostForm.Get("discountable") != "false", "metadata": map[string]string{"meter": line.meter, "calculation_digest": line.digest}} |
| + return map[string]any{"id": line.item, "invoice": inv.id, "customer": inv.customer, "livemode": false, "amount": amount, "currency": either(f.reportItemCurrency, line.currency), "discountable": r.PostForm.Get("discountable") != "false", "metadata": line.lineMetadata()} |
| }, key) |
| case strings.HasSuffix(path, "/finalize"): |
| inv := f.invoices[strings.TrimSuffix(strings.TrimPrefix(path, "/v1/invoices/"), "/finalize")] |
diff --git a/internal/console/billingtaxshape_test.go b/internal/console/billingtaxshape_test.go| new file mode 100644 |
| index 000000000..df0263ea2 |
| --- /dev/null |
| +++ b/internal/console/billingtaxshape_test.go |
| @@ -0,0 +1,77 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/billingstripe" |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// A calculation carries its tax codes in one of two shapes: one code on |
| +// the calculation and none on any line, or none on the calculation and one |
| +// on every line. Anything else is a record to look at, not to invoice. |
| +func TestACalculationCarriesItsTaxCodesInOneOfTwoShapes(t *testing.T) { |
| + waived := func(c *billingCalculation) *billingCalculation { c.Total = &plans.Invoice{Waived: true}; return c } |
| + v2 := func(top string, lines ...plans.OverageLine) *billingCalculation { |
| + return &billingCalculation{V: 2, TaxCode: top, Lines: lines, Total: &plans.Invoice{TotalMinor: 1}} |
| + } |
| + for name, tc := range map[string]struct { |
| + calc *billingCalculation |
| + ok bool |
| + }{ |
| + "one code, untagged lines": {v2("txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5}), true}, |
| + "one code, waived, no lines": {waived(v2("txcd_10102001")), true}, |
| + "two codes, every line tagged": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102001"}, plans.OverageLine{Meter: "writes", AmountCents: 3, TaxCode: "txcd_10102000"}), true}, |
| + "two-code shape with one code emitted": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102000"}), true}, |
| + "two-code shape, waived, no lines": {waived(v2("")), true}, |
| + "a code at both levels": {v2("txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102001"}), false}, |
| + "a code at neither level": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5}), false}, |
| + "two codes with one line lacking its code": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102001"}, plans.OverageLine{Meter: "awake_seconds", AmountCents: 3}), false}, |
| + "a malformed code on the calculation": {v2("txcd_", plans.OverageLine{Meter: "writes", AmountCents: 5}), false}, |
| + "a malformed code on a line": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "10102001"}), false}, |
| + "not waived and no lines, one code": {v2("txcd_10102001"), false}, |
| + "not waived and no lines, no code": {v2(""), false}, |
| + } { |
| + if got := tc.calc.taxShapeOK(); got != tc.ok { |
| + t.Errorf("%s: taxShapeOK %v, want %v", name, got, tc.ok) |
| + } |
| + } |
| +} |
| + |
| +// The request for a line takes the line's own code when it carries one, |
| +// and then names it in metadata too; else the calculation's; never both, |
| +// never neither. A request without the metadata field encodes exactly as |
| +// before the field existed, so an old journal's hashes still match. |
| +func TestALinesRequestCarriesItsOwnCodeOrTheCalculationsNotBoth(t *testing.T) { |
| + run := func(top string) *billingInvoiceRun { |
| + return &billingInvoiceRun{month: "2026-09", rec: billingMonthRecord{Customer: "cus_x", InvoiceID: "in_x", Digest: "d"}, calc: billingCalculation{TaxCode: top, Currency: "usd"}} |
| + } |
| + for name, tc := range map[string]struct { |
| + top string |
| + line plans.OverageLine |
| + code string |
| + meta string |
| + err bool |
| + }{ |
| + "the calculation's code": {"txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5}, "txcd_10102001", "", false}, |
| + "the line's own code": {"", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102000"}, "txcd_10102000", "txcd_10102000", false}, |
| + "a code at both levels": {"txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102000"}, "", "", true}, |
| + "a code at neither": {"", plans.OverageLine{Meter: "writes", AmountCents: 5}, "", "", true}, |
| + "a malformed line code": {"", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_"}, "", "", true}, |
| + "a malformed top-level code": {"txcd", plans.OverageLine{Meter: "writes", AmountCents: 5}, "", "", true}, |
| + } { |
| + p, err := run(tc.top).lineParams(tc.line) |
| + if (err != nil) != tc.err || p.TaxCode != tc.code || p.TaxCodeMetadata != tc.meta { |
| + t.Errorf("%s: %+v %v", name, p, err) |
| + } |
| + } |
| + raw, _ := json.Marshal(billingstripe.InvoiceItemParams{Customer: "cus_x", TaxCode: "txcd_10102001"}) |
| + if strings.Contains(string(raw), "TaxCodeMetadata") { |
| + t.Fatalf("a request without the metadata code encodes it: %s", raw) |
| + } |
| + if lineKind(plans.OverageLine{Meter: "writes"}) != "line-writes" || lineKind(plans.OverageLine{Meter: "writes", TaxCode: "txcd_10102000"}) != "line-writes-txcd_10102000" { |
| + t.Fatal("the operation kinds") |
| + } |
| +} |