[PM-40694] fix: Derive Stripe tax ID type server-side - #8122
[PM-40694] fix: Derive Stripe tax ID type server-side#8122amorask-bitwarden wants to merge 2 commits into
Conversation
The web client resolves a tax ID's Stripe type from the country alone, returning the first entry flagged as impacting tax calculation. GB lists the Northern Ireland eu_vat entry ahead of gb_vat, so every UK VAT ID reached Stripe typed eu_vat. Stripe stores the value but will not apply reverse charge, leaving UK business customers paying VAT they do not owe. Four write sites forwarded the client-supplied type verbatim. Each now derives it from (country, value) via TaxService.GetStripeTaxCode, which already distinguishes GB from XI and was previously only used by organization signup - hence creating an org stored the correct type while editing the billing address afterwards overwrote it. Where derivation returns null the client-supplied type is kept and a warning is logged. TaxService covers fewer countries than the client offers a tax ID field for, so failing hard would break tax ID saves that work today. Also types a bare Canadian BN correctly, which the client sends as ca_gst_hst and Stripe rejects (PM-27010).
A UK VAT registration number is nine digits; the GB prefix is optional in practice and routinely omitted. Without it neither GB pattern matched, so derivation returned null and the fallback re-applied the client's eu_vat - the original defect, unchanged, for exactly the customers the ticket is about. Verified against Stripe: gb_vat with an unprefixed value is accepted and verifies against HMRC, so the type can be corrected without rewriting the value we store. Bare nine digits cannot distinguish mainland UK from Northern Ireland, so they resolve to gb_vat. An NI customer who omits the XI prefix moves from eu_vat to gb_vat. NI postcodes all start with BT if that needs disambiguating later.
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE This bugfix routes four Stripe tax-ID write sites ( Code Review DetailsNo findings at or above the reporting threshold. Verification notes from this review:
The dependency ordering in the PR description is worth preserving through merge: the Stripe data remediation for existing GB-prefixed |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8122 +/- ##
===========================================
+ Coverage 14.99% 62.89% +47.90%
===========================================
Files 1409 2301 +892
Lines 61097 100256 +39159
Branches 4864 9027 +4163
===========================================
+ Hits 9161 63061 +53900
+ Misses 51780 35013 -16767
- Partials 156 2182 +2026 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-40694
📔 Objective
UK business customers were being charged 20% VAT they didn't owe.
The web client resolves a tax ID's Stripe type from the billing country alone, returning the first candidate flagged as impacting tax calculation. GB has two, and the Northern Ireland
eu_vatentry is listed first — so every UK VAT ID reached Stripe typedeu_vat. Stripe stores it without complaint but will not apply reverse charge.Four server write sites forwarded the client-supplied type verbatim. Each now derives it from
(country, value)viaITaxService.GetStripeTaxCode:UpdateBillingAddressCommandUpgradePremiumToOrganizationCommandProviderBillingService.SetupCustomerPreviewOrganizationTaxCommandThat method already existed and already distinguishes GB from XI. Before this PR its only non-test caller was
OrganizationBillingServiceon the org-signup path — which is why creating an organization stored the correct type while editing the billing address afterwards overwrote it.The second commit adds an unprefixed
^[0-9]{9}$→gb_vatrule for GB. A UK VAT number is nine digits and theGBprefix is routinely omitted; without this, the fix misses exactly the customers the ticket is about.Design decisions
Fallback rather than throw. When derivation returns
nullthe client-supplied type is kept and a warning logged.TaxServicecovers 83 countries; the client renders a tax ID field for 112. Throwing would break tax ID saves that work today.OrganizationBillingServicedoes throw here — reconciling the two is deliberate follow-up, not part of this PR.No feature flag. The fallback makes the null path byte-identical to previous behaviour, so there is no scenario where flag-off is safer than flag-on. Revert is four files with no schema or data dependency.
Derivation sits after the
OneOfmatch inPreviewOrganizationTaxCommand, so it also corrects previews for customers whose stored type is already wrong, ahead of data remediation.Verified against Stripe (test account)
gb_vatwith an unprefixed value is accepted and verifies against HMRC, so the type is corrected without rewriting the stored value.eu_vatwith an unprefixed value is rejected (tax_id_invalid). Customers in that state were never receiving reverse charge, so deriving a country-specific type for them is not a regression.ca_gst_hstwith a full…RT0001number zeroes the tax;ca_bndoes not. A bare Business Number correctly grants no relief, andca_gst_hstwith a bare value is rejected by Stripe.Known caveat
Bare nine digits cannot distinguish mainland UK from Northern Ireland. An NI customer who omits
XInow resolves togb_vatinstead ofeu_vat. NI postcodes all start withBT, which is the disambiguator if this needs addressing.Follow-up, deliberately out of scope
TaxServiceis 36 tax ID types behind Stripe's current table, and Nigeria's and Argentina's documented formats have drifted from ours. Adding missing types is low risk; changing the two live formats needs confirmation of which forms are actually in circulation.GetStripeTaxCodeis still declared non-nullable. All are best done alongside the fallback-policy reconciliation, which touches all five sites anyway.eu_vatrecords with GB-prefixed values need delete-and-recreate asgb_vat(Stripe has no update endpoint). Must run after this ships, or the customer's next address save re-corrupts the record.Testing
298 unit tests passing across the five affected suites. The GB regression test sends
eu_vatwith aGB-prefixed value and asserts Stripe receivesgb_vat, so it fails onmain.QA note: read the
customer.tax_id.createdevent payload, not the Dashboard Details panel — Stripe renders a GB-prefixedeu_vatas "GB VAT", which masks the defect entirely.