Show what tax a user attracts, and why - #381
Merged
Merged
Conversation
A rate is not a property of a user. It is determined per seller company — the seller's country is half of the rule — so the new endpoint returns one determination per company rather than a single number that would be wrong for every company but one. It reports the treatment alongside the rate because the treatment is what explains the number. A 0.0 from a cross-border reverse charge and a 0.0 from a non-EU customer are unrelated situations, and the case a bare country percentage gets exactly backwards is the one most likely to be asked about: a customer who supplied a VAT number and sees no VAT on their invoice. The evidence that decided it — declared country, IP country, VAT number — comes back too. Computed live rather than read from the last payment: the question is what we would charge now, which is what a customer disputing VAT or adding a VAT number is asking. What they were charged is on the payment. The admin API had no VAT rate table at all, so any rate it computed would have been 0% for everybody. It now loads and refreshes rates daily as the public API does, and the response carries `rates_loaded` so an empty table is reported as unavailable rather than as "this customer pays no VAT" — the failure mode being avoided is a page that quietly tells an admin the wrong thing. MockDb::admin_list_companies was a stub returning an empty list, so tests written against it asserted nothing. It is now implemented, and the endpoint pages the company list rather than asking for it unbounded. E2E coverage runs the determination against real rows, since the mock cannot catch a decode or a join.
v0l
added a commit
to LNVPS/admin
that referenced
this pull request
Aug 19, 2026
Reads GET /users/{id}/tax (LNVPS/api#381), one row per seller company.
A rate is not a property of a user — the seller's country is half of the
rule — so a single number would be wrong for every company but one.
The treatment sits next to every rate rather than instead of it. Two
determinations can both read 0% for unrelated reasons: a cross-border
reverse charge and a non-EU customer are not the same answer, and the
evidence line says which signal decided it. Undetermined is amber
because a fallback rate means nothing identified the customer, which is
a data problem rather than a tax outcome.
When the rate table has not loaded the card says so. Every rate reads 0%
in that state, and an admin answering a VAT question needs to know the
difference between "no VAT is due" and "we do not know yet".
The card sits beside Billing because it is decided by the country and
VAT number in that card, and a failure here is inline: the rest of the
user page is still worth reading when only this lookup fails.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
GET /api/admin/v1/users/{id}/tax(users::view) for the admin user detail page.Why it is a list, not a number
A rate is not a property of a user.
determine_taxresolves it per seller company, because the seller's country is half of the rule — the same customer isdomesticto one company andoss_b2cto another. So the response carries one determination per company.{ "rates_loaded": true, "determinations": [ { "company_id": 1, "company_name": "LNVPS IE", "seller_country": "IRL", "rate": 0.0, "treatment": "reverse_charge", "place_of_supply": "DEU", "vat_number": "DE123456789", "declared_country": "DEU", "geo_country": "DEU" } ] }Why the treatment is returned, not just the rate
A
0.0means different things. A cross-borderreverse_chargeand anout_of_scopenon-EU customer are zero-rated for unrelated reasons, andundetermined_defaultmeans no customer country was known and the seller's own rate was used as a fallback. The case a bare country percentage gets exactly backwards is also the one most likely to be asked about: a customer who supplied a VAT number and sees no VAT on their invoice. The evidence that decided it comes back too, so the page can show why.Computed live rather than read back from the last payment: the question is what we would charge now, which is what a customer disputing VAT — or one who just added a VAT number — is asking. What they were charged is already on the payment.
The admin API had no VAT rates
It never loaded the rate table, so any rate it computed would have been
0.0for every country. It now loads rates at startup and refreshes daily, mirroring the public API, and a failure is logged rather than fatal.rates_loadedreports whether the table is populated so an empty one is never rendered as "this customer pays no VAT".Incidental fixes
MockDb::admin_list_companieswas a stub returning[]. Tests written against it asserted nothing — my first pass at these unit tests passed for that reason. Now implemented, ordered to match the SQL.Testing
6 unit tests (domestic, OSS B2C, reverse charge, unloaded rate table, unknown user, permission) and 2 e2e tests against a real database, since the mock returns Rust values and cannot catch a decode or a join.
./scripts/run-e2e.sh --filter user_tax: 2 passed. Full unit suite and clippy clean; every function added is covered.Admin UI to follow in
LNVPS/admin.