Skip to content

Show what tax a user attracts, and why - #381

Merged
v0l merged 1 commit into
masterfrom
feat/admin-user-tax-rate
Aug 19, 2026
Merged

Show what tax a user attracts, and why#381
v0l merged 1 commit into
masterfrom
feat/admin-user-tax-rate

Conversation

@v0l

@v0l v0l commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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_tax resolves it per seller company, because the seller's country is half of the rule — the same customer is domestic to one company and oss_b2c to 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.0 means different things. A cross-border reverse_charge and an out_of_scope non-EU customer are zero-rated for unrelated reasons, and undetermined_default means 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.0 for every country. It now loads rates at startup and refreshes daily, mirroring the public API, and a failure is logged rather than fatal. rates_loaded reports whether the table is populated so an empty one is never rendered as "this customer pays no VAT".

Incidental fixes

  • MockDb::admin_list_companies was 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.
  • The handler pages the company list rather than reading it unbounded.

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.

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 v0l added api User-facing or admin API changes enhancement New feature or request labels Aug 19, 2026
@v0l
v0l merged commit 07d8702 into master Aug 19, 2026
8 of 10 checks passed
@v0l
v0l deleted the feat/admin-user-tax-rate branch August 19, 2026 17:03
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api User-facing or admin API changes enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant