Configure plan-specific PaaS tax codes
Human
Prompt
PaaS sounds perfect
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Use Stripe PaaS personal-use codes for Personal and Community, the PaaS business-use code for Business, and freeze the resolved tax code into overage calculations.
11 files changed,
+359
−31
cmd/console/billing.go+18 −2cmd/console/billing_test.go+40 −2docs/superpowers/specs/2026-09-07-carloku-pricing-plans-design.md+9 −6implementation/carloku-paid-launch/AGENT_HANDOFF.md+6 −7implementation/carloku-paid-launch/requirements-audit.json+2 −2implementation/carloku-paid-launch/stripe-live-readiness.json+29 −2internal/console/billingcalculation.go+1 −0internal/console/billinginvoice.go+34 −6internal/console/billinginvoice_test.go+151 −0internal/console/billingservice.go+33 −3internal/console/billingservice_test.go+36 −1
CI passed — run details
diff --git a/cmd/console/billing.go b/cmd/console/billing.go| index 51ddd5093..de4227d55 100644 |
| --- a/cmd/console/billing.go |
| +++ b/cmd/console/billing.go |
| @@ -23,7 +23,6 @@ func billingConfig(getenv func(string) string) (*console.BillingConfig, error) { |
| LiveMode: mode == "live", |
| SecretKey: getenv("CARLOS_STRIPE_SECRET_KEY"), |
| MerchantAccount: getenv("CONSOLE_STRIPE_ACCOUNT"), |
| - TaxCode: getenv("CONSOLE_STRIPE_TAX_CODE"), |
| PortalConfiguration: getenv("CONSOLE_STRIPE_PORTAL_CONFIGURATION"), |
| HouseCoupon: getenv("CONSOLE_STRIPE_HOUSE_COUPON"), |
| CountryBands: plans.CountryBands(), |
| @@ -34,10 +33,27 @@ func billingConfig(getenv func(string) string) (*console.BillingConfig, error) { |
| } |
| if !strings.HasPrefix(cfg.SecretKey, prefix) || len(cfg.SecretKey) <= len(prefix) || |
| !strings.HasPrefix(cfg.MerchantAccount, "acct_") || len(cfg.MerchantAccount) <= 5 || |
| - !strings.HasPrefix(cfg.TaxCode, "txcd_") || len(cfg.TaxCode) <= 5 || |
| !strings.HasPrefix(cfg.PortalConfiguration, "bpc_") || len(cfg.PortalConfiguration) <= 4 { |
| return nil, errBillingConfiguration |
| } |
| + rawTaxCodes := getenv("CONSOLE_STRIPE_TAX_CODES") |
| + legacyTaxCode := getenv("CONSOLE_STRIPE_TAX_CODE") |
| + if rawTaxCodes == "" { |
| + if !strings.HasPrefix(legacyTaxCode, "txcd_") || len(legacyTaxCode) <= 5 { |
| + return nil, errBillingConfiguration |
| + } |
| + cfg.TaxCode = legacyTaxCode |
| + } else { |
| + if legacyTaxCode != "" || len(rawTaxCodes) > 1024 || json.Unmarshal([]byte(rawTaxCodes), &cfg.TaxCodes) != nil || len(cfg.TaxCodes) != 3 { |
| + return nil, errBillingConfiguration |
| + } |
| + for _, plan := range []string{plans.PlanPersonal, plans.PlanCommunity, plans.PlanBusiness} { |
| + code := cfg.TaxCodes[plan] |
| + if !strings.HasPrefix(code, "txcd_") || len(code) <= 5 { |
| + return nil, errBillingConfiguration |
| + } |
| + } |
| + } |
| primary := strings.TrimSpace(getenv("CARLOS_STRIPE_WEBHOOK_SECRET")) |
| if !strings.HasPrefix(primary, "whsec_") || len(primary) <= 6 { |
| return nil, errBillingConfiguration |
diff --git a/cmd/console/billing_test.go b/cmd/console/billing_test.go| index 3f53366f4..19813df88 100644 |
| --- a/cmd/console/billing_test.go |
| +++ b/cmd/console/billing_test.go |
| @@ -21,11 +21,19 @@ func billingEnvFixture(t *testing.T) map[string]string { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + taxCodes, err := json.Marshal(map[string]string{ |
| + plans.PlanPersonal: "txcd_10102001", |
| + plans.PlanCommunity: "txcd_10102001", |
| + plans.PlanBusiness: "txcd_10102000", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| return map[string]string{ |
| "CONSOLE_BILLING_MODE": "test", |
| "CARLOS_STRIPE_SECRET_KEY": "rk_test_secret_must_not_escape", |
| "CONSOLE_STRIPE_ACCOUNT": "acct_carloku", |
| - "CONSOLE_STRIPE_TAX_CODE": "txcd_approved", |
| + "CONSOLE_STRIPE_TAX_CODES": string(taxCodes), |
| "CONSOLE_STRIPE_PORTAL_CONFIGURATION": "bpc_locked", |
| "CARLOS_STRIPE_WEBHOOK_SECRET": "whsec_current", |
| "CARLOS_STRIPE_PREVIOUS_WEBHOOK_SECRETS": "whsec_previous", |
| @@ -68,8 +76,38 @@ func TestBillingConfigExplicitModeAndCompleteCatalog(t *testing.T) { |
| } |
| } |
| +func TestBillingConfigTaxCodeRolloutShapes(t *testing.T) { |
| + env := billingEnvFixture(t) |
| + delete(env, "CONSOLE_STRIPE_TAX_CODES") |
| + env["CONSOLE_STRIPE_TAX_CODE"] = "txcd_legacy" |
| + cfg, err := billingConfig(func(key string) string { return env[key] }) |
| + if err != nil || cfg.TaxCode != "txcd_legacy" || len(cfg.TaxCodes) != 0 { |
| + t.Fatalf("legacy config: cfg=%+v err=%v", cfg, err) |
| + } |
| + |
| + env = billingEnvFixture(t) |
| + env["CONSOLE_STRIPE_TAX_CODE"] = "txcd_legacy" |
| + if _, err := billingConfig(func(key string) string { return env[key] }); !errors.Is(err, errBillingConfiguration) { |
| + t.Fatal("ambiguous tax-code config accepted") |
| + } |
| + |
| + for name, raw := range map[string]string{ |
| + "missing plan": `{"personal":"txcd_personal","community":"txcd_personal"}`, |
| + "unknown plan": `{"personal":"txcd_personal","community":"txcd_personal","unknown":"txcd_business"}`, |
| + "invalid code": `{"personal":"txcd_personal","community":"txcd_personal","business":"general"}`, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + env := billingEnvFixture(t) |
| + env["CONSOLE_STRIPE_TAX_CODES"] = raw |
| + if _, err := billingConfig(func(key string) string { return env[key] }); !errors.Is(err, errBillingConfiguration) { |
| + t.Fatal("invalid tax-code map accepted") |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestBillingConfigRejectsIncompleteInputWithoutSecretDetails(t *testing.T) { |
| - for _, key := range []string{"CARLOS_STRIPE_SECRET_KEY", "CONSOLE_STRIPE_ACCOUNT", "CONSOLE_STRIPE_TAX_CODE", "CONSOLE_STRIPE_PORTAL_CONFIGURATION", "CARLOS_STRIPE_WEBHOOK_SECRET", "CONSOLE_STRIPE_PRICES"} { |
| + for _, key := range []string{"CARLOS_STRIPE_SECRET_KEY", "CONSOLE_STRIPE_ACCOUNT", "CONSOLE_STRIPE_TAX_CODES", "CONSOLE_STRIPE_PORTAL_CONFIGURATION", "CARLOS_STRIPE_WEBHOOK_SECRET", "CONSOLE_STRIPE_PRICES"} { |
| t.Run(key, func(t *testing.T) { |
| env := billingEnvFixture(t) |
| env[key] = "secret_must_not_escape" |
diff --git a/docs/superpowers/specs/2026-09-07-carloku-pricing-plans-design.md b/docs/superpowers/specs/2026-09-07-carloku-pricing-plans-design.md| index d4b48659f..6c5200ff2 100644 |
| --- a/docs/superpowers/specs/2026-09-07-carloku-pricing-plans-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-carloku-pricing-plans-design.md |
| @@ -583,17 +583,20 @@ a later spec. |
| until a registration shows as Collecting.** This is the single most common |
| Stripe Tax mistake and it fails silently. |
| -State of `acct_1UD7LXI4Nrr6AEsO` as of 2026-09-07: |
| +State of `acct_1UD7LXI4Nrr6AEsO` as of 2026-09-21: |
| - Head office set (64 Dame Street, Dublin), settings status `active`. ✅ |
| -- **Tax registrations: none.** ❌ |
| +- Irish standard VAT and Union OSS registrations are active. ✅ |
| - Default tax code is `txcd_10000000` (General - Electronically Supplied |
| - Services), which the guidance warns is too broad. Hosting is SaaS |
| - (`txcd_10103001` is the likely correct code) — **an accountant's call, not |
| - ours.** |
| + Services), which is too broad. Carloku lets customers deploy, run and |
| + manage applications, so it is Platform as a Service: Personal and |
| + Community use `txcd_10102001` (PaaS, personal use), while Business uses |
| + `txcd_10102000` (PaaS, business use). Paul approved this classification on |
| + 2026-09-21. |
| Required before live: Irish VAT registration with Revenue, EU OSS for |
| -cross-border B2C, both recorded in Stripe, and the product tax code confirmed. |
| +cross-border B2C and the product tax code confirmed. These are now complete; |
| +the live product catalog must carry the plan-specific codes above. |
| ## The published books |
diff --git a/implementation/carloku-paid-launch/AGENT_HANDOFF.md b/implementation/carloku-paid-launch/AGENT_HANDOFF.md| index bb5be5c40..b0f556eb6 100644 |
| --- a/implementation/carloku-paid-launch/AGENT_HANDOFF.md |
| +++ b/implementation/carloku-paid-launch/AGENT_HANDOFF.md |
| @@ -6,7 +6,7 @@ This file is context and instructions for the next coding agent. The branch is a |
| ## Objective and current boundary |
| -Paul asked to get carloku.com paid pricing live by morning and asked whether Stripe Tax can handle tax. Yes for calculation/collection; actual registrations and the approved product tax code still need confirmation. He then explicitly requested this branch handoff so another agent can pick up the work. |
| +Paul asked to get carloku.com paid pricing live by morning and asked whether Stripe Tax can handle tax. Yes for calculation and collection. The VAT/OSS registrations are active, and Paul approved Stripe's PaaS classification and the plan-specific codes below. He then explicitly requested this branch handoff so another agent can pick up the work. |
| Billing remains OFF. Read-only live Stripe requests verified the account on 2026-09-21; no charges, catalog mutations, production infrastructure changes, or deployments were made. The platform changes are being checkpointed on `feat/carloku-paid-launch-20260916` at `https://amadan.net/carlos/platform`. Nothing is being merged to main. |
| @@ -24,10 +24,10 @@ Read `AGENTS.md` and `docs/superpowers/specs/2026-09-07-carloku-pricing-plans-de |
| ## Existing local implementation |
| 1. Plans and calculations: immutable integer-unit EUR plans, effective assignment history, customer-favour rounding and allowance allocation. Rates in the authoritative spec: Personal EUR24/year; Community EUR5/member/year; Business EUR50/member/year; team minimum10; monthly pooled inclusions. Country factor boundary assumptions still need review. Full-month paid history is required: partial first month, free-to-paid transitions, cancellation gaps and some house changes currently HOLD rather than guess. |
| -2. Stripe boundary: restricted-key client, pinned API version, mode/identity validation, sanitized failures, checkout intents with complete commercial snapshots, tax/location checks, safe portal configuration, signed webhook reconciliation and missed-webhook polling. No real account authenticated or tested. |
| +2. Stripe boundary: restricted-key client, pinned API version, mode/identity validation, sanitized failures, checkout intents with complete commercial snapshots, tax/location checks, safe portal configuration, signed webhook reconciliation and missed-webhook polling. The console now accepts one tax code per paid plan, verifies each Stripe Product against its plan's code and freezes the applicable code into overage calculations before any provider write. |
| 3. Entitlements and membership: live-only publication outbox and authority/revision checks; Personal member limit; team quantity event history. Runtime Free-tier enforcement and the quantity/proration worker are not implemented. |
| 4. Quantity history: append-only events with exact time/generation/owner/terms, including transient add/remove. SQL guards reject REPLACE, preprocessed inserts, invalid processing timestamps and missing outcome/reference. Processing audit cannot be rewritten. Clock rollback retains events and holds history. Reconciliation refreshes summaries; pending additions/history gaps block retirement and first monthly freeze. Bound authoritative checkout expiry and proven never-created checkout abandonment atomically audit their own events. Positive proration collection timing is still an explicit product/implementation decision; no worker silently collects or waives it. |
| -5. Invoice engine: frozen month, durable ownership lease and per-operation request journal; idempotent recovery before/after provider requests; bounded replay window; exact draft/lines/finalize/pay verification; auto-advance disabled; terminal payment failure and review holds. Every POST rechecks ownership, age, frozen identity and open reviews. It reloads after acquiring ownership to avoid acting on a stale pre-lease draft. SQLite writes reserve the writer before transaction reads where needed. These mocks do not prove a real verified all-meter purchase path. |
| +5. Invoice engine: frozen month, tax code, durable ownership lease and per-operation request journal; idempotent recovery before/after provider requests; bounded replay window; exact draft/lines/finalize/pay verification; auto-advance disabled; terminal payment failure and review holds. A month spanning plans with different tax codes holds before any provider write until line allocation is defined. Unfinished calculations frozen by an older binary without a tax code also hold, while terminal rows stay terminal. Every POST rechecks ownership, age, frozen identity and open reviews. It reloads after acquiring ownership to avoid acting on a stale pre-lease draft. SQLite writes reserve the writer before transaction reads where needed. These mocks do not prove a real verified all-meter purchase path. |
| 6. Metering: durable edge spool and evidence closure; ownership and lifecycle proofs; separate CPU interval attribution; cost model no longer prices ordinary edge HTTP requests as S3 writes and pools edge/store transfer once. Object access-log parser exists, including unknown classification, but the collector is absent. |
| 7. Replication ledger: real SDK per-attempt hooks, bounded recording, durable SQLite ledger, immutable owner/purpose binding, publication lineage, incomplete/late/crash evidence, Abandon cleanup and read-connection snapshots. Vendored Litestream passes client options through its real S3 clients; close-order fixes preserve final flush. The new vendor test fixture drains multipart request bodies to avoid TCP reset. |
| 8. NEWEST / least reviewed: host integration in `internal/host/durability.go`, `agent.go`, `movespass.go`, `internal/activator/backend_exec.go`, and `internal/lease/requestmeter_api.go`. Opens ledger before tenant clients; binds account/app/host/purpose with bounded waits; records acquire, replicate, restore, staged copy/delete, move mirror and checkpoint requests; independently publishes lifecycle evidence and closes after child shutdown. Host attestation stays FALSE. No automatic handoff-token replay. Unknown owners never default to ops. External Litestream processes and continuity across arbitrary old-binary restarts are still unproved. Do not enable the meter as complete merely because embedded hooks exist. |
| @@ -63,13 +63,12 @@ make test |
| - The key is a full `sk_live` provisioning key. Production console configuration deliberately requires an `rk_live` restricted key, which does not exist in the inspected item. Never put the full key into console runtime configuration. |
| - The live account has zero active products, recurring prices, portal configurations and webhook endpoints. |
| - Stripe legal entity is `Team Tito Limited`, business profile `Carloku`. Paul confirmed Team Tito is VAT and OSS registered. On 2026-09-21 the live account gained and read back two active registrations: Irish `standard` with `place_of_supply_scheme=standard` (`taxreg_1UI9NEI4Nrr6AEsOPpkwK80n`) and Irish `oss_union` (`taxreg_1UI9NQI4Nrr6AEsOrBwcY1rE`). Stable idempotency keys are recorded in `stripe-live-readiness.json`. |
| -- The account default remains generic tax code `txcd_10000000`; that is not approval to use it for Carloku. |
| +- The account default remains generic tax code `txcd_10000000`; do not use it for Carloku. Paul approved Platform as a Service on 2026-09-21. Configure Personal and Community as `txcd_10102001` (PaaS, personal use) and Business as `txcd_10102000` (PaaS, business use). Stripe says the personal-use category fits products available to serious hobbyists; the business category is for products restricted to commercial enterprise use. |
| - `stripe-live-readiness.json` records the exact required 12-price EUR catalog and the remaining mutation gates. The two Tax Registration objects are the only live Stripe mutations so far; no product, price, webhook, portal, customer, subscription or charge was created or changed. |
| -- Raw read-only API responses were kept only in task-owned scratch with mode 0600. The 1Password session token must be removed when credential work finishes. |
| +- Raw API responses were kept only in task-owned scratch with mode 0600. The temporary 1Password session token and raw response files were removed after the registration work. |
| ## Pending user/external inputs — do not repeatedly ask |
| -- Accountant-approved product tax code. Registration is confirmed and represented in Stripe, but VAT/OSS registration does not choose the product code. |
| - Pricing copy review: http://docs-t1.tail5fd3ba.ts.net:37461 |
| - Billing copy review: http://docs-t1.tail5fd3ba.ts.net:34185 |
| - Postal/location copy review: http://docs-t1.tail5fd3ba.ts.net:46235 |
| @@ -91,4 +90,4 @@ A scratch SQLite experiment was also found replacing local root main.go. It was |
| 2. Review host wiring: `bindingFailed` is set on unmetered fallbacks but currently only the permanently false attestation prevents billing. Finish per-route move/checkpoint tests and client inventory, source completeness folding, scoped external-client exclusion, crash/late-callback/restart proof, bounded ledger retention and real-disk latency measurement. Keep unattested usage unbillable until proved. |
| 3. Implement P5 collector with durable deduplication, immutable historical bucket ownership and a settled transfer contract. Prepare exact scoped logging activation before seeking production approval. |
| 4. Complete partial-month billing, quantity/proration collection, operator review/house doors, Free enrollment/caps/idle/flush-only, dormancy warnings/archive and revenue-pool accounting required by the spec. Use the requirements audit rather than treating these as optional follow-ups. |
| -5. After external prerequisites and copy approvals, provision/verify Stripe catalog, run the real sandbox purchase/webhook/portal flow, obtain independent reviews, pass Amadan CI and release gates, then land/deploy through the required reviewed path and verify live behaviour. No main merge or deployment is part of this handoff. |
| +5. After copy approvals, provision and verify the Stripe catalog with the approved per-plan PaaS codes, run the real sandbox purchase/webhook/portal flow, obtain independent reviews, pass Amadan CI and release gates, then land/deploy through the required reviewed path and verify live behaviour. No main merge or deployment is part of this handoff. |
diff --git a/implementation/carloku-paid-launch/requirements-audit.json b/implementation/carloku-paid-launch/requirements-audit.json| index bf9ddb8eb..bc8d83487 100644 |
| --- a/implementation/carloku-paid-launch/requirements-audit.json |
| +++ b/implementation/carloku-paid-launch/requirements-audit.json |
| @@ -63,7 +63,7 @@ |
| { |
| "step": 9, |
| "requirement": "Stripe catalog", |
| - "observed_status": "live account authenticated; Irish standard VAT and Union OSS registrations active; no products or recurring prices; exact 12-price plan recorded in stripe-live-readiness.json; provisioning blocked on approved product tax code and approved display names", |
| + "observed_status": "live account authenticated; Irish standard VAT and Union OSS registrations active; PaaS classification approved with personal/community txcd_10102001 and business txcd_10102000; console per-plan mapping implemented locally; no products or recurring prices; exact 12-price plan recorded in stripe-live-readiness.json; provisioning blocked on approved display names and remaining release gates", |
| "inspection_anchor": "external Stripe account", |
| "complete": false |
| }, |
| @@ -140,6 +140,6 @@ |
| "scoped new-infrastructure approval", |
| "deployment version verification", |
| "actual Stripe purchase/entitlement/portal proof", |
| - "approved public copy and tax configuration" |
| + "approved public copy and deployed tax configuration" |
| ] |
| } |
diff --git a/implementation/carloku-paid-launch/stripe-live-readiness.json b/implementation/carloku-paid-launch/stripe-live-readiness.json| index a223434b5..6a9ac00ea 100644 |
| --- a/implementation/carloku-paid-launch/stripe-live-readiness.json |
| +++ b/implementation/carloku-paid-launch/stripe-live-readiness.json |
| @@ -84,7 +84,7 @@ |
| ] |
| }, |
| "remaining_before_live_mutation": [ |
| - "Obtain the accountant-approved Stripe product tax code; do not use the current generic default by assumption.", |
| + "Land and deploy the approved plan-specific PaaS tax-code mapping before enabling billing.", |
| "Approve customer-facing product names and billing copy.", |
| "Create and verify a restricted live runtime key with only the API permissions the console needs.", |
| "Choose the deployed webhook URL and create the endpoint and signing secret.", |
| @@ -105,5 +105,32 @@ |
| "id": "taxreg_1UI9NQI4Nrr6AEsOrBwcY1rE", |
| "idempotency_key": "carloku-ie-oss-union-registration-20260921" |
| } |
| - ] |
| + ], |
| + "recommended_tax_codes": { |
| + "status": "approved by Paul on 2026-09-21; implemented locally; not yet applied to live products", |
| + "basis": "Carloku is a platform where customers deploy, run and manage applications, matching Stripe Platform as a Service rather than raw infrastructure or website-only hosting.", |
| + "products": { |
| + "personal": { |
| + "tax_code": "txcd_10102001", |
| + "name": "Platform as a Service (PaaS) - personal use", |
| + "reason": "The plan is for one person managing their own apps and is not restricted to a commercial enterprise." |
| + }, |
| + "community": { |
| + "tax_code": "txcd_10102001", |
| + "name": "Platform as a Service (PaaS) - personal use", |
| + "reason": "The plan is not restricted to commercial enterprises; Stripe advises the personal-use category when serious hobbyist or personal use is allowed." |
| + }, |
| + "business": { |
| + "tax_code": "txcd_10102000", |
| + "name": "Platform as a Service (PaaS) - business use", |
| + "reason": "The plan is explicitly intended for commercial or organizational use." |
| + } |
| + }, |
| + "single_code_fallback": "txcd_10102001", |
| + "single_code_fallback_reason": "If one code must cover the current mixed audience, Stripe guidance points to personal-use when the product is available for personal use; plan-specific codes are more accurate.", |
| + "sources": [ |
| + "https://docs.stripe.com/payments/managed-payments/eligibility", |
| + "https://support.stripe.com/questions/selecting-the-right-product-tax-code-for-managed-payments?locale=en-GB" |
| + ] |
| + } |
| } |
diff --git a/internal/console/billingcalculation.go b/internal/console/billingcalculation.go| index 04658bdcd..2056dd9a5 100644 |
| --- a/internal/console/billingcalculation.go |
| +++ b/internal/console/billingcalculation.go |
| @@ -37,6 +37,7 @@ type billingCalculation struct { |
| Overage plans.OverageResult `json:"overage"` |
| Invoice plans.InvoiceResult `json:"invoice"` |
| Lines []plans.OverageLine `json:"lines"` |
| + TaxCode string `json:"tax_code"` |
| } |
| func prepareBillingCalculation(ctx context.Context, store release.Store, statement usage.Statement, history []plans.Assignment, now time.Time) (*billingCalculation, error) { |
diff --git a/internal/console/billinginvoice.go b/internal/console/billinginvoice.go| index 35a8952e4..b35f755fc 100644 |
| --- a/internal/console/billinginvoice.go |
| +++ b/internal/console/billinginvoice.go |
| @@ -10,6 +10,7 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| + "strings" |
| "sync" |
| "time" |
| @@ -310,14 +311,20 @@ func (s *Server) invoiceBillingMonth(ctx context.Context, acct *Account, month s |
| if errors.Is(err, errBillingIdentityMixed) { |
| return r.review("billing_invoice_identity_mixed") |
| } |
| + if errors.Is(err, errBillingTaxCodeMixed) { |
| + return r.review("billing_invoice_tax_code_mixed") |
| + } |
| if err != nil { |
| return err |
| } |
| r.rec = rec |
| + if billingMonthTerminal(r.rec.State) { |
| + return nil |
| + } |
| if err := json.Unmarshal(rec.Calculation, &r.calc); err != nil { |
| return err |
| } |
| - if r.calc.Statement.Account != acct.Sqid || r.calc.Statement.Month != month || r.calc.Currency != "eur" { |
| + if r.calc.Statement.Account != acct.Sqid || r.calc.Statement.Month != month || r.calc.Currency != "eur" || !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 { |
| return r.review("billing_invoice_record_mismatch") |
| } |
| if err := r.reload(); err != nil { |
| @@ -452,6 +459,18 @@ func (r *billingInvoiceRun) freeze(ctx context.Context) (billingMonthRecord, err |
| if identity.LiveMode != r.b.cfg.LiveMode { |
| return billingMonthRecord{}, errBillingModeMismatch |
| } |
| + taxCode := "" |
| + 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 |
| + } |
| + calc.TaxCode = taxCode |
| raw, err := json.Marshal(calc) |
| if err != nil { |
| return billingMonthRecord{}, err |
| @@ -459,7 +478,10 @@ func (r *billingInvoiceRun) freeze(ctx context.Context) (billingMonthRecord, err |
| return r.s.store.freezeBillingMonth(r.acct.ID, r.month, raw) |
| } |
| -var errBillingIdentityMixed = errors.New("billing_invoice_identity_mixed") |
| +var ( |
| + errBillingIdentityMixed = errors.New("billing_invoice_identity_mixed") |
| + errBillingTaxCodeMixed = errors.New("billing_invoice_tax_code_mixed") |
| +) |
| // invoiceIdentityFromHistory resolves the customer and subscription the |
| // month's usage was metered under: the recorded rows behind exactly the |
| @@ -876,10 +898,13 @@ func (r *billingInvoiceRun) stepCreating(ctx context.Context) error { |
| }) |
| } |
| -func (r *billingInvoiceRun) lineParams(line plans.OverageLine) billingstripe.InvoiceItemParams { |
| - return billingstripe.InvoiceItemParams{Customer: r.rec.Customer, Invoice: r.rec.InvoiceID, Meter: line.Meter, TaxCode: r.b.cfg.TaxCode, |
| +func (r *billingInvoiceRun) lineParams(line plans.OverageLine) (billingstripe.InvoiceItemParams, error) { |
| + if !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 { |
| + return billingstripe.InvoiceItemParams{}, errBillingInvoiceMismatch |
| + } |
| + return billingstripe.InvoiceItemParams{Customer: r.rec.Customer, Invoice: r.rec.InvoiceID, Meter: line.Meter, TaxCode: r.calc.TaxCode, |
| Description: billingInvoiceLineDescription(line.Meter, r.month), CalculationDigest: r.rec.Digest, |
| - AmountCents: line.AmountCents, PeriodStart: r.start.Unix(), PeriodEnd: r.end.Unix()} |
| + AmountCents: line.AmountCents, PeriodStart: r.start.Unix(), PeriodEnd: r.end.Unix()}, nil |
| } |
| // billingInvoiceLineDescription is what the customer reads on the invoice |
| @@ -907,7 +932,10 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error { |
| if line.AmountCents <= 0 { |
| return r.review("billing_invoice_record_mismatch") |
| } |
| - params := r.lineParams(line) |
| + params, err := r.lineParams(line) |
| + if err != nil { |
| + return r.review("billing_invoice_tax_code_mismatch") |
| + } |
| kind := "line-" + line.Meter |
| if err := r.perform(ctx, providerOp{ |
| kind: kind, hash: billingRequestHash(kind, params), |
diff --git a/internal/console/billinginvoice_test.go b/internal/console/billinginvoice_test.go| index 41ebe170c..21153f4c6 100644 |
| --- a/internal/console/billinginvoice_test.go |
| +++ b/internal/console/billinginvoice_test.go |
| @@ -367,6 +367,108 @@ func (ix *invoiceFixture) run(t *testing.T) error { |
| return ix.srv.invoiceBillingMonth(context.Background(), ix.acct, invoiceFixtureMonth, ix.now) |
| } |
| +func TestInvoiceLineUsesFrozenTaxCode(t *testing.T) { |
| + r := billingInvoiceRun{ |
| + b: &billingRuntime{cfg: BillingConfig{TaxCodes: map[string]string{plans.PlanPersonal: "txcd_new"}}}, |
| + month: invoiceFixtureMonth, |
| + start: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), |
| + end: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), |
| + rec: billingMonthRecord{Customer: "cus_hist", InvoiceID: "in_usage", Digest: strings.Repeat("a", 64)}, |
| + calc: billingCalculation{TaxCode: "txcd_frozen"}, |
| + } |
| + params, err := r.lineParams(plans.OverageLine{Meter: usage.MeterInstanceHours, AmountCents: 100}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if params.TaxCode != "txcd_frozen" { |
| + t.Fatalf("TaxCode = %q", params.TaxCode) |
| + } |
| +} |
| + |
| +func TestInvoiceEngineRetryUsesFrozenTaxCode(t *testing.T) { |
| + 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", |
| + } |
| + calc := ix.expectedCalc(t) |
| + lost := "line-" + calc.Lines[0].Meter |
| + ix.fake.loseResponse[lost] = true |
| + if err := ix.run(t); !errors.Is(err, errBillingPending) { |
| + t.Fatalf("lost line response: %v", err) |
| + } |
| + var frozen billingCalculation |
| + if rec := ix.month(t); json.Unmarshal(rec.Calculation, &frozen) != nil || frozen.TaxCode != "txcd_personal" { |
| + t.Fatalf("frozen calculation: %+v", frozen) |
| + } |
| + |
| + ix.srv.billing.cfg.TaxCodes[plans.PlanPersonal] = "txcd_changed" |
| + ix.now = ix.now.Add(time.Minute) |
| + if err := ix.run(t); err != nil { |
| + t.Fatalf("retry after config change: %v", err) |
| + } |
| + if rec := ix.month(t); rec.State != "complete" { |
| + t.Fatalf("month: %+v", rec) |
| + } |
| + if arrivals, mutations, _ := ix.fake.counts(); arrivals[lost] != 2 || mutations[lost] != 1 { |
| + t.Fatalf("line replay: arrivals=%v mutations=%v", arrivals, mutations) |
| + } |
| +} |
| + |
| +func removeFrozenTaxCode(t *testing.T, ix *invoiceFixture) { |
| + t.Helper() |
| + rec := ix.month(t) |
| + var calc map[string]any |
| + if err := json.Unmarshal(rec.Calculation, &calc); err != nil { |
| + t.Fatal(err) |
| + } |
| + delete(calc, "tax_code") |
| + raw, err := json.Marshal(calc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ix.st.db.Exec(`UPDATE billing_months SET calculation=? WHERE account_id=? AND month=?`, raw, ix.acct.ID, invoiceFixtureMonth); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestInvoiceEngineLegacyCalculationsFailClosedWithoutReopeningTerminalMonths(t *testing.T) { |
| + t.Run("unfinished", func(t *testing.T) { |
| + ix := newInvoiceFixture(t) |
| + ix.srv.billing.invoice.hooks = &billingInvoiceHooks{beforePost: func(string) error { return errFakeCrash }} |
| + if err := ix.run(t); !errors.Is(err, errFakeCrash) { |
| + t.Fatal(err) |
| + } |
| + removeFrozenTaxCode(t, ix) |
| + ix.srv.billing.invoice.hooks = nil |
| + if err := ix.run(t); !errors.Is(err, errBillingInvoiceHeld) { |
| + t.Fatalf("legacy unfinished month: %v", err) |
| + } |
| + if reviews := ix.reviews(t); len(reviews) != 1 || reviews[0] != "billing_invoice_record_mismatch:"+invoiceFixtureMonth { |
| + t.Fatalf("reviews: %v", reviews) |
| + } |
| + if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 { |
| + t.Fatalf("legacy month reached provider: %v", mutations) |
| + } |
| + }) |
| + |
| + t.Run("terminal", func(t *testing.T) { |
| + ix := newInvoiceFixture(t) |
| + if err := ix.run(t); err != nil { |
| + t.Fatal(err) |
| + } |
| + removeFrozenTaxCode(t, ix) |
| + if err := ix.run(t); err != nil { |
| + t.Fatalf("legacy terminal month: %v", err) |
| + } |
| + if reviews := ix.reviews(t); len(reviews) != 0 { |
| + t.Fatalf("terminal month opened reviews: %v", reviews) |
| + } |
| + }) |
| +} |
| + |
| func (ix *invoiceFixture) month(t *testing.T) billingMonthRecord { |
| t.Helper() |
| rec, err := ix.st.billingMonth(ix.acct.ID, invoiceFixtureMonth) |
| @@ -1164,6 +1266,55 @@ func TestInvoiceEngineMixedIdentityMonthIsHeld(t *testing.T) { |
| } |
| } |
| +func TestInvoiceEngineMixedTaxCodeMonthIsHeld(t *testing.T) { |
| + 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", |
| + } |
| + mid := time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) |
| + next := billingState{AccountID: ix.acct.ID, Customer: "cus_hist", Subscription: "sub_hist", Status: "active", Plan: plans.PlanBusiness, Terms: plans.CurrentTermsVersion, Band: plans.BandA, Quantity: 10, PeriodStart: mid.Unix(), PeriodEnd: mid.AddDate(1, 0, 0).Unix()} |
| + if err := ix.st.applyBillingState(next, 1, "evt_business", mid); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ix.run(t); !errors.Is(err, errBillingInvoiceHeld) { |
| + t.Fatalf("mixed tax code not held: %v", err) |
| + } |
| + if reviews := ix.reviews(t); len(reviews) != 1 || reviews[0] != "billing_invoice_tax_code_mixed:"+invoiceFixtureMonth { |
| + t.Fatalf("reviews: %v", reviews) |
| + } |
| + if arrivals, _, _ := ix.fake.counts(); len(arrivals) != 0 { |
| + t.Fatalf("mixed-code month reached provider: %v", arrivals) |
| + } |
| +} |
| + |
| +func TestInvoiceEngineOppositeModeMixedTaxCodesDoNotOpenReview(t *testing.T) { |
| + ix := newInvoiceFixture(t) |
| + ix.srv.billing.cfg.LiveMode = true |
| + 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", |
| + } |
| + mid := time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) |
| + next := billingState{AccountID: ix.acct.ID, Customer: "cus_hist", Subscription: "sub_hist", Status: "active", Plan: plans.PlanBusiness, Terms: plans.CurrentTermsVersion, Band: plans.BandA, Quantity: 10, PeriodStart: mid.Unix(), PeriodEnd: mid.AddDate(1, 0, 0).Unix()} |
| + if err := ix.st.applyBillingState(next, 1, "evt_business", mid); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ix.run(t); !errors.Is(err, errBillingModeMismatch) { |
| + t.Fatalf("opposite-mode month: %v", err) |
| + } |
| + if reviews := ix.reviews(t); len(reviews) != 0 { |
| + t.Fatalf("opposite-mode month opened reviews: %v", reviews) |
| + } |
| + if arrivals, _, _ := ix.fake.counts(); len(arrivals) != 0 { |
| + t.Fatalf("opposite-mode month reached provider: %v", arrivals) |
| + } |
| +} |
| + |
| func TestInvoiceEngineExternallyPaidInvoiceCompletesWithoutPaymentRequest(t *testing.T) { |
| ix := newInvoiceFixture(t) |
| ix.srv.billing.invoice.hooks = &billingInvoiceHooks{beforePost: func(kind string) error { |
diff --git a/internal/console/billingservice.go b/internal/console/billingservice.go| index 4de439b44..86c6cf54b 100644 |
| --- a/internal/console/billingservice.go |
| +++ b/internal/console/billingservice.go |
| @@ -16,8 +16,10 @@ import ( |
| ) |
| type BillingConfig struct { |
| - MerchantAccount string |
| + MerchantAccount string |
| + // TaxCode is the deprecated single-code configuration retained during rollout. |
| TaxCode string |
| + TaxCodes map[string]string |
| SecretKey string |
| WebhookSecrets []string |
| LiveMode bool |
| @@ -50,7 +52,7 @@ func newBillingRuntime(cfg *BillingConfig) (*billingRuntime, error) { |
| if cfg.LiveMode { |
| prefix = "rk_live_" |
| } |
| - if !strings.HasPrefix(cfg.MerchantAccount, "acct_") || !strings.HasPrefix(cfg.TaxCode, "txcd_") || cfg.PortalConfiguration == "" || !strings.HasPrefix(cfg.SecretKey, prefix) || len(cfg.WebhookSecrets) == 0 || len(cfg.CountryBands) == 0 || len(cfg.Prices) == 0 { |
| + if !strings.HasPrefix(cfg.MerchantAccount, "acct_") || cfg.PortalConfiguration == "" || !strings.HasPrefix(cfg.SecretKey, prefix) || len(cfg.WebhookSecrets) == 0 || len(cfg.CountryBands) == 0 || len(cfg.Prices) == 0 || !validBillingTaxCodes(*cfg) { |
| return nil, errBillingUnavailable |
| } |
| for _, secret := range cfg.WebhookSecrets { |
| @@ -75,6 +77,10 @@ func newBillingRuntime(cfg *BillingConfig) (*billingRuntime, error) { |
| for k, v := range cfg.Prices { |
| copyCfg.Prices[k] = v |
| } |
| + copyCfg.TaxCodes = make(map[string]string, len(cfg.TaxCodes)) |
| + for k, v := range cfg.TaxCodes { |
| + copyCfg.TaxCodes[k] = v |
| + } |
| copyCfg.CountryBands = make(map[string]string, len(cfg.CountryBands)) |
| for k, v := range cfg.CountryBands { |
| copyCfg.CountryBands[k] = v |
| @@ -83,6 +89,29 @@ func newBillingRuntime(cfg *BillingConfig) (*billingRuntime, error) { |
| return &billingRuntime{cfg: copyCfg, client: c, locks: newStoreWalkLocks()}, nil |
| } |
| +func validBillingTaxCodes(cfg BillingConfig) bool { |
| + if len(cfg.TaxCodes) == 0 { |
| + return strings.HasPrefix(cfg.TaxCode, "txcd_") && len(cfg.TaxCode) > 5 |
| + } |
| + if cfg.TaxCode != "" || len(cfg.TaxCodes) != 3 { |
| + return false |
| + } |
| + for _, plan := range []string{plans.PlanPersonal, plans.PlanCommunity, plans.PlanBusiness} { |
| + if code := cfg.TaxCodes[plan]; !strings.HasPrefix(code, "txcd_") || len(code) <= 5 { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func (cfg BillingConfig) taxCode(plan string) (string, bool) { |
| + if len(cfg.TaxCodes) == 0 { |
| + return cfg.TaxCode, strings.HasPrefix(cfg.TaxCode, "txcd_") && len(cfg.TaxCode) > 5 |
| + } |
| + code, ok := cfg.TaxCodes[plan] |
| + return code, ok && strings.HasPrefix(code, "txcd_") && len(code) > 5 |
| +} |
| + |
| func billingPriceKey(terms, plan, band string) string { return terms + "/" + plan + "/" + band } |
| func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan, country, postal string, now time.Time) (string, error) { |
| @@ -143,7 +172,8 @@ func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan, |
| if err != nil { |
| return "", err |
| } |
| - if !product.Active || product.LiveMode != b.cfg.LiveMode || product.TaxCode != b.cfg.TaxCode { |
| + taxCode, ok := b.cfg.taxCode(plan) |
| + if !ok || !product.Active || product.LiveMode != b.cfg.LiveMode || product.TaxCode != taxCode { |
| return "", errBillingUnavailable |
| } |
| registrations, err := b.client.ActiveTaxRegistrations(ctx) |
diff --git a/internal/console/billingservice_test.go b/internal/console/billingservice_test.go| index 1d85907b5..9c5860a67 100644 |
| --- a/internal/console/billingservice_test.go |
| +++ b/internal/console/billingservice_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "net/http" |
| "net/http/httptest" |
| @@ -36,6 +37,7 @@ type fakeBillingStripe struct { |
| expireCalls int |
| postals []string |
| coupon map[string]any |
| + productTaxCode string |
| } |
| func newBillingFixture(t *testing.T) (*consoleFixture, *fakeBillingStripe, *Account) { |
| @@ -81,7 +83,11 @@ func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) { |
| case r.URL.Path == "/v1/account": |
| out = map[string]string{"id": "acct_carloku"} |
| case r.URL.Path == "/v1/products/prod_personal": |
| - out = map[string]any{"id": "prod_personal", "active": true, "livemode": false, "tax_code": "txcd_test"} |
| + taxCode := f.productTaxCode |
| + if taxCode == "" { |
| + taxCode = "txcd_test" |
| + } |
| + out = map[string]any{"id": "prod_personal", "active": true, "livemode": false, "tax_code": taxCode} |
| case r.URL.Path == "/v1/billing_portal/configurations/bpc_safe": |
| out = map[string]any{"id": "bpc_safe", "active": true, "livemode": false, "features": map[string]any{"customer_update": map[string]any{"enabled": false}, "subscription_update": map[string]any{"enabled": false}, "subscription_cancel": map[string]any{"enabled": true, "mode": "at_period_end"}, "payment_method_update": map[string]bool{"enabled": true}, "invoice_history": map[string]bool{"enabled": true}}} |
| case r.URL.Path == "/v1/customers" || r.URL.Path == "/v1/customers/cus_paid": |
| @@ -165,6 +171,35 @@ func TestBillingCheckoutRetryDoesNotDuplicateSubscription(t *testing.T) { |
| } |
| } |
| +func TestBillingCheckoutRequiresConfiguredPlanTaxCode(t *testing.T) { |
| + fx, f, acct := newBillingFixture(t) |
| + fx.srv.billing.cfg.TaxCode = "" |
| + fx.srv.billing.cfg.TaxCodes = map[string]string{ |
| + plans.PlanPersonal: "txcd_personal", |
| + plans.PlanCommunity: "txcd_personal", |
| + plans.PlanBusiness: "txcd_business", |
| + } |
| + f.productTaxCode = "txcd_personal" |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", f.now); err != nil { |
| + t.Fatalf("matching personal code: %v", err) |
| + } |
| + |
| + fx, f, acct = newBillingFixture(t) |
| + fx.srv.billing.cfg.TaxCode = "" |
| + fx.srv.billing.cfg.TaxCodes = map[string]string{ |
| + plans.PlanPersonal: "txcd_personal", |
| + plans.PlanCommunity: "txcd_personal", |
| + plans.PlanBusiness: "txcd_business", |
| + } |
| + f.productTaxCode = "txcd_business" |
| + if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "IE", "", f.now); !errors.Is(err, errBillingUnavailable) { |
| + t.Fatalf("cross-plan product code: %v", err) |
| + } |
| + if f.checkoutCalls != 0 { |
| + t.Fatal("mismatched product reached checkout creation") |
| + } |
| +} |
| + |
| func TestBillingCheckoutAmbiguousResponseUsesSameKey(t *testing.T) { |
| fx, f, acct := newBillingFixture(t) |
| f.failCreate = true |