ADR-041: Remove `tax_on_failure=fallback_manual` (block-only)
Status: Accepted
Date: 2026-05-30
Supersedes: the tax_on_failure=fallback_manual setting introduced
by migration 0039 (internal/platform/migrate/sql/0039_tax_block_retry.up.sql).
Summary
Section titled “Summary”If tax calculation fails, the invoice stays a draft until the tax retry job (ADR-017) or the operator resolves it; Hardcount never substitutes another rate. The tax_on_failure=fallback_manual option was removed, because it silently charged zero tax wherever no manual rate was set. Migration 0103 moved every tenant to block, the only value its CHECK constraint allows. Choosing tax_provider=stripe_tax without a Stripe client is an explicit error, and an operator who wants manual tax sets tax_provider=manual. Status: active.
Context
Section titled “Context”Migration 0039 introduced tenant_settings.tax_on_failure with two
values:
block— when Stripe Tax fails to compute tax (API outage, missing customer country, missing Stripe credentials, etc.), return the error to the engine. The engine defers the invoice totax_status=pending; the TaxRetrier reconciler picks it up on the next scheduler tick.fallback_manual— when Stripe Tax fails, transparently substitute the result of the internalManualProvider(which computes tax from the tenant’s manual rate config for the customer’s jurisdiction).
The default for new tenants was block. Migration 0039 line 46 set
every EXISTING tenant to fallback_manual to “preserve behavior at
migration time.”
The 2026-05-30 design-debt audit identified this as residual debt:
- No operator has consciously chosen
fallback_manual. Every existing tenant was opted in by migration 0039, not by user action. - The fallback’s edge-case behavior is wrong.
ManualProviderreturns ZERO when no manual rate is configured for the customer’s jurisdiction. Operators who pickfallback_manualintending “fall back to my manual rate” silently get “fall back to zero tax” in every jurisdiction they didn’t pre-configure rates for. The fallback produces a different answer than the operator intended, with no surface-level signal beyond aslog.Warnline. - The TaxRetrier already handles transient Stripe failures. The
reconciler exists specifically to retry
tax_status=pendinginvoices when Stripe Tax becomes reachable again. The “block” path uses it correctly;fallback_manualcompetes with the retrier by shipping the invoice with an approximate (often zero) tax NOW, forcing the operator to issue credit notes later to reconcile the difference. That’s strictly worse operational hygiene than letting the retrier resolve transient outages. - Per
feedback_no_silent_fallbacks: engine paths that can’t produce a correct answer must fail loudly, not substitute a default. Logging atWarndoesn’t make a fallback not-silent — the customer still gets a wrong-tax invoice + no actionable signal at calc time. - Per
feedback_no_belt_and_suspenders: the fallback’s failure modes (manual rate missing for jurisdiction → returns zero) are not independent of the failure mode it’s meant to rescue from (Stripe API down). The retrier IS the recovery path;fallback_manualis a parallel mechanism with worse outcomes.
Decision
Section titled “Decision”Cut fallback_manual. Block-only post-migration.
- Migration 0103 sets every tenant currently at
fallback_manualtoblockand tightens the CHECK constraint to allow onlyblock. internal/tax/stripe.gohandleFailurealways returns the error; thep.fallback *ManualProviderfield and the constructor’sfallbackparameter are removed.internal/tax/resolver.gono longer constructs a manual fallback whentax_provider=stripe_taxis selected without a wired Stripe client — returns an explicit error: “tax_provider=stripe_tax but no Stripe client wired — set tax_provider=manual or configure Stripe”. Operators who want manual billing must explicitly selecttax_provider=manualat the tenant level.internal/tax/provider.goremoves theOnFailureFallbackManualconstant.OnFailureBlockis the only validOnFailurevalue.Request.OnFailureis retained for forward-compat (future shapes likedefer_with_delaycan reuse the field).internal/tenant/settings.govalidation accepts onlyblock(wasblockorfallback_manual).internal/domain/audit.goTenantSettings.TaxOnFailurecomment block is updated to reflect the post-ADR-041 contract.internal/tax/providers_test.gostripe_tax_without_clients_falls_back_to_manualtest case rewritten to assert the new error-loud behavior.- Prometheus metric
hardcount_tax_outcome_totalno longer emits theoutcome="fallback"label value (onlydeferredremains for the failure-mode signal).
Consequences
Section titled “Consequences”- Operators get correct tax or no invoice. Stripe Tax outage →
invoice goes to
tax_status=pending+ Attention banner + TaxRetrier takes over. No silent shipped-wrong-tax invoices. - Operators who genuinely want manual billing must say so explicitly.
tax_provider=manualis the path. Mixed Stripe+manual (“Stripe preferred, manual fallback”) is no longer expressible — the use case was never named by any DP and the failure mode was wrong (silent zero, not the configured manual rate). - One less branch in the failure path.
handleFailurecollapses to a single defer + metric increment. - No code in production was using
fallback_manualconsciously. Hardcount is pre-launch (zero customers). Migration 0039 had set every existing tenant including the test tenant; no operator chose this. - Schema retained. The
tax_on_failurecolumn stays (forward-compat for future failure-handling shapes). Only the CHECK constraint and the runtime branch are removed.
Revisit trigger
Section titled “Revisit trigger”Resurrect fallback_manual (or a runtime equivalent) when:
- A signed design partner names a use case where they specifically
want “Stripe preferred, MY configured manual rate as fallback for
jurisdictional gaps” (NOT zero-fallback — the silent-zero edge case
is the actual bug; if rebuilt, the fallback must hard-error when no
manual rate matches). The rebuild would add a new policy value
(e.g.
fallback_to_configured_manual_strict) with the no-rate-match → block guard baked in. - AI-native peer convergence shifts in a way that makes mixed-provider tax composition the industry-standard pattern.
Until then, block-only is the contract.
Industry references
Section titled “Industry references”- Stripe Billing: when Stripe Tax fails, the invoice goes to draft state with a tax error; Stripe doesn’t substitute a default.
- Lago: defers to operator-configured tax (no auto-fallback).
- Chargebee + Avalara: blocks the invoice on tax failure.
- The “fallback to approximate, reconcile later” pattern doesn’t appear in the AI-native or traditional SaaS billing peer set.
Related cuts
Section titled “Related cuts”- ADR-040: outbox env flags removed. Same shape — boot-time flag / setting whose “off” branch silently weakens correctness without an operator-visible signal.
- ADR-039: coupons removed. Same shape — feature kept on parity reasoning without a named DP use case.