fix(customers): re-issue the country backfill skipping migration-reset source companies (#2249)

* fix(customers): re-issue the country backfill skipping migration-reset source companies

Migration 20260903170000 (PR #2241) failed on prod at its first UPDATE:
"Archived migration reset source records are immutable" (SQLSTATE P0001).
Rows of a company listed in company_migration_resets are frozen by
trigger, and the backfill touched them, so the whole file rolled back and
prod has neither normalize_country_code() nor country_raw.

The same SQL ships again as 20260903173000 with every UPDATE excluding
those companies (their legacy text keeps being read through
normalizeCountryCode() at runtime). The old file is removed rather than
edited: prod never recorded it, staging was re-tracked under the new
version by hand. References in code, tests and DECISIONS.md follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE

* docs(decisions): cite the shipped backfill version 20260903173000

The country-ISO entry still named 20260903170000, the version that never
landed on prod; only the follow-up entry keeps that number, as history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE

* docs(decisions): drop the duplicate country-ISO entry the merge carried in

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE

* docs(decisions): keep a single country-ISO entry

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE

* docs(decisions): rebuild the tail from main so the merge leaves no duplicated entries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-03 19:39:56 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent a48508e5b0
commit 3159920d7c
5 changed files with 22 additions and 13 deletions
+2 -1
View File
@@ -1529,7 +1529,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client.
[2026-09-03] Enable Banking ASPSP_ERROR ladder (#2202): the widest history window a bank has answered is stored per account as accounts_data[].accepted_history_days (no migration; same write-back as dedup_scope and the balance), not as an absolute date, because a bank's limit is a width from today and an absolute known-good date would age into a genuine width rejection. On a rejected window: no wider than accepted = bank unavailable after ONE call; wider = one retry at the accepted width, then unavailable (was up to five calls). AspspUnavailableError maps to 503 BANK_UNAVAILABLE on the web sync route with copy that says the connection does not need renewing; the agent path keeps the contract code BANK_SYNC_FAILED (adding a code touches core contract + structured-errors + v1 docs, left for a follow-up) but no longer persists renewal advice. The envelope's `detail` field is NOT used as a signal: one sample, "Unknown error", identical to a width rejection. A narrowed sync now returns history_from so the UI can say from which date it is complete.
[2026-09-03] KPI monthly breakdown counts reversed originals (#2201): the monthly section of get_kpi_report_aggregates (new migration 20260903160000) and lib/reports/monthly-breakdown.ts now use tb_ex_year_end's entry set verbatim (posted + reversed, minus the undone year-end chain) instead of posted-only. A same-year storno then cancels inside the months as it does in the year total, so sum(months) = Nettoresultat; the reversal shows as negative revenue in its own month, which is the honest month view. The pg-real pin "in tb, not in monthly" was flipped, not worked around. Unblocks the per-month sum on Nyckeltal (#2196).
[2026-09-03] customers.country and suppliers.country are ISO 3166-1 alpha-2 at every writer (form select, internal + v1 REST, MCP, imports, provider migration), normalised through one helper (lib/vat/country-codes.ts) that also accepts the Swedish/English names the form used to write; unknown text is a 400 on write and left as-is by the backfill (migration 20260903170000 keeps the original in country_raw for a one-UPDATE rollback, and derives the country from the VAT prefix for eu_business rows whose country was null or only the old writer default SE: on prod that is one validated row plus sixteen without a country, and without it they would flip from reverse charge to 25% on their next invoice). No CHECK constraint on the column: unmapped legacy rows would violate it, and the periodisk report already warns on those. The country-vs-type rule (swedish_business = SE, eu_business = not SE and either in the EU VAT area with a matching prefix or holding an EU-trade VAT registration such as a Swiss company with a DE number or Northern Ireland XI, non_eu_business = outside the EU) is enforced on customers only, and on update only when type, country or VAT number is part of the change so a contradictory legacy row can still change its email; individuals are free (a foreign private person is still a Swedish-VAT customer) and suppliers get normalisation without the rule, since #2025/#2028 are about sales VAT. An omitted country on create is SE for Swedish types, derived from the VAT prefix for eu_business, and a 400 for non_eu_business: guessing a non-EU country is not possible, and Sweden-by-default was the bug. vat-rules.ts takes the country as a third optional argument and refuses reverse charge only for SE (a VIES-validated number outweighs a non-EU address), and not for an unknown/unmapped country: charging Swedish VAT to a genuine German customer whose row says Deutschland (Bayern) would be the worse error.
[2026-09-03] customers.country and suppliers.country are ISO 3166-1 alpha-2 at every writer (form select, internal + v1 REST, MCP, imports, provider migration), normalised through one helper (lib/vat/country-codes.ts) that also accepts the Swedish/English names the form used to write; unknown text is a 400 on write and left as-is by the backfill (migration 20260903173000 keeps the original in country_raw for a one-UPDATE rollback, and derives the country from the VAT prefix for eu_business rows whose country was null or only the old writer default SE: on prod that is one validated row plus sixteen without a country, and without it they would flip from reverse charge to 25% on their next invoice). No CHECK constraint on the column: unmapped legacy rows would violate it, and the periodisk report already warns on those. The country-vs-type rule (swedish_business = SE, eu_business = not SE and either in the EU VAT area with a matching prefix or holding an EU-trade VAT registration such as a Swiss company with a DE number or Northern Ireland XI, non_eu_business = outside the EU) is enforced on customers only, and on update only when type, country or VAT number is part of the change so a contradictory legacy row can still change its email; individuals are free (a foreign private person is still a Swedish-VAT customer) and suppliers get normalisation without the rule, since #2025/#2028 are about sales VAT. An omitted country on create is SE for Swedish types, derived from the VAT prefix for eu_business, and a 400 for non_eu_business: guessing a non-EU country is not possible, and Sweden-by-default was the bug. vat-rules.ts takes the country as a third optional argument and refuses reverse charge only for SE (a VIES-validated number outweighs a non-EU address), and not for an unknown/unmapped country: charging Swedish VAT to a genuine German customer whose row says Deutschland (Bayern) would be the worse error.
[2026-09-03] AGI kvittens cron: dropped the apigw_config bucket (#963) and its warn-once suppression for ACCESS_DENIED (#2226). The bucket existed because the APIGW client was known to lack the AGI hantera subscription in Utvecklarportalen; with that subscription expected in place, a gateway refusal is a regression and belongs in the ordinary error path (error level, generic 'error' status) rather than a status that hides it as a known gap. Same pass: the connector-mode gateway-refusal message names the connector operator by host instead of "kontakta supporten", because hosted is itself a Connect installation for the canary companies and "support" no longer says whose. Merging before the portal subscription is active means the 15-minute cron logs at error level per pending declaration until it is.
[2026-09-03] Payment terms 0 days (#2070): customer and supplier forms plus the three API schemas accept whole days 0-365 (0 = betalning direkt), and every `|| 30` fallback became `?? 30` so a stored 0 does not reopen as 30. Upper bound 365 is a sanity cap, not law; the invoice schema's own cap (90) is unchanged.
[2026-09-03] getErrorMessage passthrough (#2086): kept the keyword list as the first gate and added looksLikeUserFacingSwedish as a second (å/ä/ö, a strong Swedish word, or two weak function words, and no technical-leak pattern) instead of flipping to "show unless technical": several routes still return English free text ("Failed to disconnect") that must keep falling through to the status/context fallback, which existing tests pin. A registry-wide test asserts every message_sv passes.
@@ -1540,3 +1540,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-03] Tenant brand logos render with next/image `unoptimized` (#2203): the optimizer's remotePatterns allowlist is fixed at build time and the generic Docker image bakes a sentinel for NEXT_PUBLIC_SUPABASE_URL, so /_next/image rejected the runtime Supabase host with 400 and the sidebar mark broke on self-hosted while the favicon (same URL) worked. Chosen over a same-origin proxy route (more code, a second fetch hop for a 26px image) and over a runtime-configurable allowlist (Next.js has none). The remotePatterns block stays for builds that know the URL.
[2026-09-03] Danger zone (#2214): both company actions (Radera företag, Starta om migrering) and the account deletion already open a dialog that requires the company name / e-mail typed in, so nothing destructive can fire from a tap or swipe. The report came from a user who did not dare press the link to find that out, so the fix is copy on the row ("Inget händer direkt: du bekräftar i nästa steg ...") rather than a second dialog or a different control.
[2026-09-03] Decision lines for PRs #2242 (#2237), #2246 (#2203) and #2245 (#2214) are carried in this PR's commit rather than their own: DECISIONS.md is append-only, so every squash-merge flips every other open PR to CONFLICTING and costs a full CI round each; consolidating the lines into the last PR of the batch turns four rounds into one.
[2026-09-03] The country backfill ships as 20260903173000, not 20260903170000: the first version failed on prod at the customers UPDATE because rows of a migration-reset source company are immutable by trigger (block_migration_reset_source_mutation), and the whole file rolled back. The new file skips those companies in every UPDATE (their legacy text is still normalised at read time) and the old file is removed rather than edited, since prod never recorded it; staging was re-tracked by hand under the new version.
@@ -1,5 +1,5 @@
/**
* The backfill in migration 20260903170000 maps country names with a SQL
* The backfill in migration 20260903173000 maps country names with a SQL
* function that carries its own copy of the name table. This test holds the
* two copies to each other: every name the SQL knows must map to the same
* code in TypeScript, and every name TypeScript knows must be in the SQL.
@@ -11,7 +11,7 @@ import { listKnownCountryNames, normalizeCountryCode } from '../country-codes'
const MIGRATION = join(
process.cwd(),
'supabase/migrations/20260903170000_customer_supplier_country_iso.sql',
'supabase/migrations/20260903173000_customer_supplier_country_iso.sql',
)
function sqlNameTable(): Map<string, string> {
+3 -3
View File
@@ -132,7 +132,7 @@ const ALPHA2_RE = /^[A-Z]{2}$/
/**
* Every folded name the TypeScript table knows, with its code. Exists so a
* test can hold the SQL twin in migration 20260903170000 to the same table.
* test can hold the SQL twin in migration 20260903173000 to the same table.
*/
export function listKnownCountryNames(): Array<[name: string, code: string]> {
return [...NAME_TO_CODE.entries()]
@@ -148,7 +148,7 @@ export function listKnownCountryNames(): Array<[name: string, code: string]> {
* "unknown, keep the raw text" (reports, backfill) or a 400 (API writes).
*
* The SQL twin `public.normalize_country_code(text)` (migration
* 20260903170000) carries the same table for the one-off backfill; keep them
* 20260903173000) carries the same table for the one-off backfill; keep them
* in step when adding names here.
*/
export function normalizeCountryCode(input: string | null | undefined): string | null {
@@ -362,7 +362,7 @@ export function checkCountryConsistency(input: {
* either: refusing reverse charge on a genuine German customer whose row
* still says "Deutschland" would put Swedish VAT on a correct invoice, which
* is the worse error. The consistency check above stops new contradictory
* rows from being saved, and migration 20260903170000 repairs the old ones
* rows from being saved, and migration 20260903173000 repairs the old ones
* whose country was only ever the writer default.
*/
export function countryPermitsReverseCharge(country: string | null | undefined): boolean {
@@ -14,6 +14,11 @@
-- were (and listed in country_raw); the periodisk report already warns
-- on those and the customer form asks for a pick before it saves.
--
-- Rows of a company that is a migration-reset source (company_migration_resets)
-- are immutable by trigger and are skipped by every UPDATE below; the first
-- attempt (as 20260903170000) failed on prod because it touched them. Their
-- legacy text is still read through normalizeCountryCode() at runtime.
--
-- 4. for eu_business rows whose country is missing or only the old writer
-- default (SE) while the VAT number names another EU member, takes the
-- country from the VAT prefix. The pre-2026-09 VAT rules granted reverse
@@ -197,17 +202,19 @@ comment on column public.suppliers.country_raw is
-- Backfill: every row that is not already an uppercase alpha-2 code. An
-- empty string is "no country" and becomes null (the periodisk report
-- already treats both the same); a null stays null, nothing is guessed.
update public.customers
update public.customers c
set country_raw = country,
country = case when btrim(country) = '' then null else coalesce(public.normalize_country_code(country), country) end
where country is not null
and country !~ '^[A-Z]{2}$';
and country !~ '^[A-Z]{2}$'
and not exists (select 1 from public.company_migration_resets r where r.source_company_id = c.company_id);
update public.suppliers
update public.suppliers s
set country_raw = country,
country = case when btrim(country) = '' then null else coalesce(public.normalize_country_code(country), country) end
where country is not null
and country !~ '^[A-Z]{2}$';
and country !~ '^[A-Z]{2}$'
and not exists (select 1 from public.company_migration_resets r where r.source_company_id = s.company_id);
-- Step 4: EU-business rows with a missing or defaulted (SE) country and a
-- VAT number registered in another EU member. Only a prefix that names a
@@ -228,6 +235,7 @@ update public.customers c
where d.id = c.id
and d.code is not null
and d.code <> 'SE'
and d.code in ('AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES');
and d.code in ('AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES')
and not exists (select 1 from public.company_migration_resets r where r.source_company_id = c.company_id);
NOTIFY pgrst, 'reload schema';
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* pg-real test for migration 20260903170000: customers.country and
* pg-real test for migration 20260903173000: customers.country and
* suppliers.country are ISO 3166-1 alpha-2 (#2025, #2028).
*
* The backfill ran once when the migration was applied, so what can be
@@ -51,7 +51,7 @@ describe('normalize_country_code()', () => {
})
})
describe('country columns after migration 20260903170000', () => {
describe('country columns after migration 20260903173000', () => {
it('keeps the pre-backfill text in country_raw on both tables', async () => {
const { rows } = await getPool().query<{ table_name: string; column_name: string }>(
`select table_name, column_name