| new file mode 100644 |
| index 000000000..ce3be802b |
| --- /dev/null |
| +++ b/internal/console/billingtaxlifecycle_test.go |
| @@ -0,0 +1,203 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// storeTwoCodeMonth freezes, by hand, the calculation a two-code month |
| +// would carry once decision 16 is taken: the fixture's own month, priced |
| +// as today, with its one awake line divided between two codes and the |
| +// calculation's own code empty. It is the seam the lifecycle tests use; |
| +// freeze itself still holds such a month. |
| +func storeTwoCodeMonth(t *testing.T, ix *invoiceFixture, personalShare func(total int64) int64) *billingCalculation { |
| + t.Helper() |
| + calc := ix.expectedCalc(t) |
| + if len(calc.Lines) != 1 || calc.Lines[0].Meter != "awake_seconds" { |
| + t.Fatalf("the fixture's lines: %+v", calc.Lines) |
| + } |
| + total := calc.Lines[0].AmountCents |
| + personal := personalShare(total) |
| + calc.TaxCode = "" |
| + calc.Lines = []plans.OverageLine{ |
| + {Meter: "awake_seconds", AmountCents: total - personal, TaxCode: "txcd_business"}, |
| + {Meter: "awake_seconds", AmountCents: personal, TaxCode: "txcd_personal"}, |
| + } |
| + if !calc.taxShapeOK() { |
| + t.Fatal("the hand-made record is not in the two-code shape") |
| + } |
| + raw, err := json.Marshal(calc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ix.st.freezeBillingMonth(ix.acct.ID, invoiceFixtureMonth, raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + return calc |
| +} |
| + |
| +func taxLifecycleFixture(t *testing.T) *invoiceFixture { |
| + t.Helper() |
| + ix := newInvoiceFixture(t) |
| + ix.srv.billing.cfg.TaxCode = "" |
| + ix.srv.billing.cfg.TaxCodes = map[string]string{plans.PlanPersonal: "txcd_personal", plans.PlanCommunity: "txcd_personal", plans.PlanBusiness: "txcd_business"} |
| + return ix |
| +} |
| + |
| +// Two lines of one meter are two operations, two items, each carrying its |
| +// own code in tax_code and in metadata, and the draft validates against |
| +// both; with equal and with unequal amounts. |
| +func TestATwoCodeMonthInvoicesOneLinePerCode(t *testing.T) { |
| + for name, share := range map[string]func(int64) int64{ |
| + "unequal amounts": func(total int64) int64 { return total * 6 / 10 }, |
| + "equal amounts": func(total int64) int64 { return total / 2 }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + ix := taxLifecycleFixture(t) |
| + calc := storeTwoCodeMonth(t, ix, share) |
| + if err := ix.run(t); err != nil { |
| + t.Fatalf("run: %v", err) |
| + } |
| + if rec := ix.month(t); rec.State != "complete" { |
| + t.Fatalf("month: %+v", rec) |
| + } |
| + ops := ix.ops(t) |
| + for _, line := range calc.Lines { |
| + op, ok := ops[lineKind(line)] |
| + if !ok || op.State != billingOpAcked { |
| + t.Fatalf("operation %s: %+v", lineKind(line), op) |
| + } |
| + } |
| + if _, dup := ops["line-awake_seconds"]; dup { |
| + t.Fatal("an operation by meter alone, beside the two by code") |
| + } |
| + ix.assertMutations(t, map[string]int{"draft": 1, "line-awake_seconds-txcd_business": 1, "line-awake_seconds-txcd_personal": 1, "finalize": 1, "pay": 1}) |
| + inv := ix.fake.invoices[ix.fake.order[0]] |
| + if len(inv.lines) != 2 { |
| + t.Fatalf("lines at the provider: %+v", inv.lines) |
| + } |
| + for _, l := range inv.lines { |
| + if l.taxCode != l.metaTax || (l.metaTax != "txcd_business" && l.metaTax != "txcd_personal") { |
| + t.Fatalf("a line's codes: %+v", l) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A lost answer to one of the two lines is recovered by the listing, the |
| +// line told from its sibling by its metadata code, through a paginated |
| +// listing too; nothing is sent twice. |
| +func TestATwoCodeLinesLostAnswerIsRecoveredByItsCode(t *testing.T) { |
| + for name, paged := range map[string]bool{"one page": false, "paged listing": true} { |
| + t.Run(name, func(t *testing.T) { |
| + ix := taxLifecycleFixture(t) |
| + storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 }) |
| + ix.fake.hasMoreLines = paged |
| + ix.fake.loseResponse["line-awake_seconds-txcd_personal"] = true |
| + if err := ix.run(t); !errors.Is(err, errBillingPending) { |
| + t.Fatalf("lost line answer: %v", err) |
| + } |
| + ix.now = ix.now.Add(2 * time.Hour) |
| + if err := ix.run(t); err != nil { |
| + t.Fatalf("recovery: %v", err) |
| + } |
| + if rec := ix.month(t); rec.State != "complete" { |
| + t.Fatalf("month: %+v", rec) |
| + } |
| + ix.assertMutations(t, map[string]int{"draft": 1, "line-awake_seconds-txcd_business": 1, "line-awake_seconds-txcd_personal": 1, "finalize": 1, "pay": 1}) |
| + }) |
| + } |
| +} |
| + |
| +// A provider that drops the metadata code is caught at the answer to the |
| +// POST and again at the draft, equal amounts included: the line cannot be |
| +// told from its sibling, so the month holds for a person and nothing is |
| +// finalized. |
| +func TestAMissingMetadataCodeHoldsTheMonth(t *testing.T) { |
| + for name, setup := range map[string]func(*fakeInvoiceStripe){ |
| + "on the answer to the POST": func(f *fakeInvoiceStripe) { f.stripItemMetaTax = true }, |
| + "on the listed lines": func(f *fakeInvoiceStripe) { f.stripLineMetaTax = true }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + ix := taxLifecycleFixture(t) |
| + storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 }) |
| + setup(ix.fake) |
| + err := ix.run(t) |
| + if !errors.Is(err, errBillingInvoiceHeld) { |
| + t.Fatalf("run: %v", err) |
| + } |
| + reviews := ix.reviews(t) |
| + if len(reviews) != 1 || !strings.HasSuffix(reviews[0], ":"+invoiceFixtureMonth) || !strings.Contains(reviews[0], "mismatch") { |
| + t.Fatalf("reviews: %v", reviews) |
| + } |
| + _, mutations, _ := ix.fake.counts() |
| + if mutations["finalize"] != 0 || mutations["pay"] != 0 { |
| + t.Fatalf("finalized or paid under a hold: %v", mutations) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// An older binary's entry guard, copied as it was before lines carried |
| +// codes (29ea6676: wellFormed and a top-level txcd_ check), refuses a |
| +// two-code record and files the record-mismatch review; that review |
| +// blocks the new reader until it is resolved, and an unrelated review |
| +// keeps the month held after that. The record's bytes are untouched and |
| +// nothing reaches the provider meanwhile. |
| +func TestAnOlderReaderRefusesATwoCodeRecordAndHoldsTheAccount(t *testing.T) { |
| + olderGuardAccepts := func(c *billingCalculation) bool { |
| + return c.wellFormed() && strings.HasPrefix(c.TaxCode, "txcd_") && len(c.TaxCode) > 5 |
| + } |
| + ix := taxLifecycleFixture(t) |
| + oneCode := ix.expectedCalc(t) |
| + oneCode.TaxCode = "txcd_personal" |
| + if !olderGuardAccepts(oneCode) { |
| + t.Fatal("the older guard refuses a one-code record") |
| + } |
| + storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 }) |
| + before := ix.month(t) |
| + var frozen billingCalculation |
| + if err := json.Unmarshal(before.Calculation, &frozen); err != nil || olderGuardAccepts(&frozen) { |
| + t.Fatalf("the older guard accepts a two-code record: %v", err) |
| + } |
| + if err := ix.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // What the older binary then does: the review, by the run's own filing. |
| + if err := ix.st.fileBillingInvoiceReview(ix.acct.ID, "sub_hist", "billing_invoice_record_mismatch:"+invoiceFixtureMonth, ix.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ix.st.fileBillingInvoiceReview(ix.acct.ID, "sub_hist", "billing_invoice_tax_code_mixed:2026-08", ix.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ix.run(t); !errors.Is(err, errBillingReview) { |
| + t.Fatalf("the new reader under the older one's review: %v", err) |
| + } |
| + if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 { |
| + t.Fatalf("the provider was written under a hold: %v", mutations) |
| + } |
| + if after := ix.month(t); string(after.Calculation) != string(before.Calculation) || after.Digest != before.Digest { |
| + t.Fatal("the frozen record changed") |
| + } |
| + if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: "billing_invoice_record_mismatch:" + invoiceFixtureMonth, Occurrence: 1, Actor: "paul@keymail.dev", Note: "older_build"}, ix.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ix.run(t); !errors.Is(err, errBillingReview) { |
| + t.Fatalf("with an unrelated review still open: %v", err) |
| + } |
| + if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: "billing_invoice_tax_code_mixed:2026-08", Occurrence: 1, Actor: "paul@keymail.dev", Note: "decided"}, ix.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ix.run(t); err != nil { |
| + t.Fatalf("after both reviews: %v", err) |
| + } |
| + if rec := ix.month(t); rec.State != "complete" { |
| + t.Fatalf("month: %+v", rec) |
| + } |
| +} |