diff --git a/DECISIONS.md b/DECISIONS.md index e2aa57c4..806fe9da 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1556,3 +1556,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [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. +[2026-09-03] Per-invoice payee migrations re-issued as 20260904010000 and 20260904011000 (were 20260903150000 / 20260903193000, merged in #2233 but never applied): the backfill's INSERT into invoice_payee_defaults fired the mirror into company_settings for a company that is a migration-reset source, whose rows are immutable by trigger, so the whole migration rolled back on prod and every later migration queued behind it. Same pattern as #2249: skip company_migration_resets sources in the backfill and re-issue under a fresh version rather than edit the failed file in place, so any environment that did apply the old version (staging, by hand) is reconciled by renaming its schema_migrations row instead of diverging silently. diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 359db240..bd53000f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -254,7 +254,7 @@ export const PUT = withRouteContext( } // Payment instructions live on cash_accounts since migration - // 20260903150000; the bank columns below are a mirror of the default + // 20260904010000; the bank columns below are a mirror of the default // payee account per currency. Write the change through to the account // FIRST: if that fails nothing has been written and the caller gets an // error, instead of a settings row that the next mirror would undo. diff --git a/app/api/v1/companies/[companyId]/settings/route.ts b/app/api/v1/companies/[companyId]/settings/route.ts index e3b7b5f2..cf58fbcb 100644 --- a/app/api/v1/companies/[companyId]/settings/route.ts +++ b/app/api/v1/companies/[companyId]/settings/route.ts @@ -287,7 +287,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string }> }>( // are `undefined` here and are dropped by supabase-js JSON serialization, // so only supplied fields are written; explicit null still clears. // The bank columns mirror the default SEK payee account (migration - // 20260903150000): write the change through to it FIRST so a failure + // 20260904010000): write the change through to it FIRST so a failure // leaves nothing half-written, and the PDF prints what this call set. try { await propagateLegacyPayeeWrite(ctx.supabase, ctx.companyId!, changes) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 24d276ae..67111c0e 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -635,7 +635,7 @@ const CreateInvoiceBaseSchema = z.object({ .optional(), received_date: optionalIsoDate, // Which of the company's bank accounts the invoice asks the customer to pay - // to (migration 20260903193000). Omitted/null = the per-currency default. + // to (migration 20260904011000). Omitted/null = the per-currency default. // The route checks the account belongs to the company, is flagged as a // payee and is usable for the invoice currency. payment_cash_account_id: z @@ -2221,7 +2221,7 @@ const InvoicePaymentAccountSchema = z.object({ /** * PATCH /api/cash-accounts/[id]: the verifikationsserie override plus the - * payee fields (migration 20260903150000). Payee keys share the field rules + * payee fields (migration 20260904010000). Payee keys share the field rules * of InvoicePaymentAccountSchema so the settings form, the legacy settings * writers and this route agree on what a valid bankgiro is. */ diff --git a/lib/invoices/payment-accounts.ts b/lib/invoices/payment-accounts.ts index d7940d5d..06a36acf 100644 --- a/lib/invoices/payment-accounts.ts +++ b/lib/invoices/payment-accounts.ts @@ -99,7 +99,7 @@ export function normalizeInvoicePaymentAccount( * written when the invoice chose a bank account and refreshed at issue). * 2. The company's payment account for the invoice currency * (company_settings.invoice_payment_accounts, mirrored from the default - * cash account per currency since migration 20260903150000). + * cash account per currency since migration 20260904010000). * 3. For SEK only, the legacy flat bank columns. */ export function resolveInvoicePaymentAccount( diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index ea4c9d6b..68f5c6aa 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -706,7 +706,7 @@ async function commitUpdateCompanySettings( } // The bank columns mirror the default SEK payee account (migration - // 20260903150000): write the change through FIRST so a failure leaves + // 20260904010000): write the change through FIRST so a failure leaves // nothing half-written, and the invoice PDF prints what the agent set. try { await propagateLegacyPayeeWrite(supabase, companyId, validated.changes) diff --git a/lib/reports/behandlingshistorik.ts b/lib/reports/behandlingshistorik.ts index bc57d111..ff932274 100644 --- a/lib/reports/behandlingshistorik.ts +++ b/lib/reports/behandlingshistorik.ts @@ -214,7 +214,7 @@ export const AUDITED_TABLES = [ // a behandlingsregel in the same sense. 'cash_accounts', // Which bank account customer invoices pay to, per currency (migration - // 20260903150000). + // 20260904010000). 'invoice_payee_defaults', ] as const @@ -425,7 +425,7 @@ const MAPPING_RULE_FIELDS: Record = { */ const CASH_ACCOUNT_FIELDS: Record = { voucher_series: 'Verifikationsserie', - // Payee fields (migration 20260903150000): what customer invoices print. + // Payee fields (migration 20260904010000): what customer invoices print. bank_name: 'Bank', clearing_number: 'Clearingnummer', account_number: 'Kontonummer', diff --git a/supabase/migrations/20260903150000_cash_accounts_invoice_payee.sql b/supabase/migrations/20260904010000_cash_accounts_invoice_payee.sql similarity index 92% rename from supabase/migrations/20260903150000_cash_accounts_invoice_payee.sql rename to supabase/migrations/20260904010000_cash_accounts_invoice_payee.sql index 68ca715c..35a18943 100644 --- a/supabase/migrations/20260903150000_cash_accounts_invoice_payee.sql +++ b/supabase/migrations/20260904010000_cash_accounts_invoice_payee.sql @@ -43,6 +43,10 @@ -- knew, including the IBAN): every invoice keeps printing exactly what -- it printed before. No rows are created: an entry with no matching -- account stays in the map as the fallback the resolver already honours. +-- Companies that are a migration-reset source (company_migration_resets) +-- are skipped: their rows are immutable by trigger, and the first attempt +-- (as 20260903150000) failed on prod when the mirror wrote one of them. +-- Their legacy map stays in company_settings, which the resolver reads. -- -- The mirror runs SECURITY DEFINER because company_settings updates are -- admin-gated by RLS. The admin guard in (4) is what makes that safe: only an @@ -78,14 +82,22 @@ COMMENT ON COLUMN public.cash_accounts.payee_iban IS -- (id, company_id) target so child tables can prove same-company membership -- with one composite FK (same pattern as parties in 20260902160000). -ALTER TABLE public.cash_accounts - ADD CONSTRAINT cash_accounts_id_company_unique UNIQUE (id, company_id); +-- Every statement in this file is rerunnable: the first issue of this +-- migration (20260903150000) ran on staging and on preview branches before +-- it failed on prod, and this re-issue must apply cleanly on top of it. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cash_accounts_id_company_unique') THEN + ALTER TABLE public.cash_accounts + ADD CONSTRAINT cash_accounts_id_company_unique UNIQUE (id, company_id); + END IF; +END $$; -- ============================================================ -- 2. Per-currency defaults -- ============================================================ -CREATE TABLE public.invoice_payee_defaults ( +CREATE TABLE IF NOT EXISTS public.invoice_payee_defaults ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, currency text NOT NULL CHECK (currency IN ('SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK')), @@ -98,11 +110,15 @@ CREATE TABLE public.invoice_payee_defaults ( REFERENCES public.cash_accounts(id, company_id) ON DELETE CASCADE ); -CREATE INDEX idx_invoice_payee_defaults_cash_account +CREATE INDEX IF NOT EXISTS idx_invoice_payee_defaults_cash_account ON public.invoice_payee_defaults (cash_account_id); ALTER TABLE public.invoice_payee_defaults ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "invoice_payee_defaults_select" ON public.invoice_payee_defaults; +DROP POLICY IF EXISTS "invoice_payee_defaults_insert" ON public.invoice_payee_defaults; +DROP POLICY IF EXISTS "invoice_payee_defaults_update" ON public.invoice_payee_defaults; +DROP POLICY IF EXISTS "invoice_payee_defaults_delete" ON public.invoice_payee_defaults; CREATE POLICY "invoice_payee_defaults_select" ON public.invoice_payee_defaults FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); CREATE POLICY "invoice_payee_defaults_insert" ON public.invoice_payee_defaults @@ -114,6 +130,7 @@ CREATE POLICY "invoice_payee_defaults_update" ON public.invoice_payee_defaults CREATE POLICY "invoice_payee_defaults_delete" ON public.invoice_payee_defaults FOR DELETE USING (public.user_is_company_admin(company_id)); +DROP TRIGGER IF EXISTS invoice_payee_defaults_updated_at ON public.invoice_payee_defaults; CREATE TRIGGER invoice_payee_defaults_updated_at BEFORE UPDATE ON public.invoice_payee_defaults FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); @@ -121,6 +138,7 @@ CREATE TRIGGER invoice_payee_defaults_updated_at -- Behandlingshistorik: which account customer invoices pay to is a -- behandlingsregel (BFNAR 2013:2 p. 9.16), same as the voucher-series -- override on cash_accounts (20260902124513). +DROP TRIGGER IF EXISTS audit_invoice_payee_defaults ON public.invoice_payee_defaults; CREATE TRIGGER audit_invoice_payee_defaults AFTER INSERT OR UPDATE OR DELETE ON public.invoice_payee_defaults FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); @@ -353,6 +371,7 @@ $$; REVOKE EXECUTE ON FUNCTION public.trg_mirror_invoice_payee_defaults() FROM PUBLIC, anon, authenticated; +DROP TRIGGER IF EXISTS mirror_invoice_payee_defaults_on_defaults ON public.invoice_payee_defaults; CREATE TRIGGER mirror_invoice_payee_defaults_on_defaults AFTER INSERT OR UPDATE OR DELETE ON public.invoice_payee_defaults FOR EACH ROW EXECUTE FUNCTION public.trg_mirror_invoice_payee_defaults(); @@ -360,6 +379,7 @@ CREATE TRIGGER mirror_invoice_payee_defaults_on_defaults -- Payee-field edits or a revoke on an account that is a default for some -- currency must reach the mirror too. Bank sync churn (balances, names, the -- enabled flag, the bank identity iban) never fires this. +DROP TRIGGER IF EXISTS mirror_invoice_payee_defaults_on_cash_account ON public.cash_accounts; CREATE TRIGGER mirror_invoice_payee_defaults_on_cash_account AFTER UPDATE ON public.cash_accounts FOR EACH ROW @@ -389,6 +409,7 @@ SELECT cs.company_id, k.key AS currency, k.value AS payee FROM public.company_settings cs CROSS JOIN LATERAL jsonb_each(cs.invoice_payment_accounts) k WHERE cs.invoice_payment_accounts <> '{}'::jsonb + AND NOT EXISTS (SELECT 1 FROM public.company_migration_resets r WHERE r.source_company_id = cs.company_id) UNION ALL SELECT cs.company_id, 'SEK', jsonb_strip_nulls(jsonb_build_object( @@ -403,6 +424,7 @@ SELECT cs.company_id, 'SEK', )) FROM public.company_settings cs WHERE NOT (COALESCE(cs.invoice_payment_accounts, '{}'::jsonb) ? 'SEK') + AND NOT EXISTS (SELECT 1 FROM public.company_migration_resets r WHERE r.source_company_id = cs.company_id) AND COALESCE( NULLIF(btrim(cs.bank_name), ''), NULLIF(btrim(cs.clearing_number), ''), NULLIF(btrim(cs.account_number), ''), NULLIF(btrim(cs.bankgiro), ''), diff --git a/supabase/migrations/20260903193000_invoices_payment_cash_account.sql b/supabase/migrations/20260904011000_invoices_payment_cash_account.sql similarity index 80% rename from supabase/migrations/20260903193000_invoices_payment_cash_account.sql rename to supabase/migrations/20260904011000_invoices_payment_cash_account.sql index dceb8103..e417e9a3 100644 --- a/supabase/migrations/20260903193000_invoices_payment_cash_account.sql +++ b/supabase/migrations/20260904011000_invoices_payment_cash_account.sql @@ -18,14 +18,19 @@ ALTER TABLE public.invoices CHECK (payment_details IS NULL OR jsonb_typeof(payment_details) = 'object'); -- Same-company proof in the constraint itself (the composite target --- cash_accounts(id, company_id) exists since 20260903150000): a direct +-- cash_accounts(id, company_id) exists since 20260904010000): a direct -- PostgREST write cannot attach another tenant's account. SET NULL is scoped -- to the account column (PG15 column list); company_id must never be nulled. -ALTER TABLE public.invoices - ADD CONSTRAINT invoices_payment_cash_account_same_company - FOREIGN KEY (payment_cash_account_id, company_id) - REFERENCES public.cash_accounts(id, company_id) - ON DELETE SET NULL (payment_cash_account_id); +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invoices_payment_cash_account_same_company') THEN + ALTER TABLE public.invoices + ADD CONSTRAINT invoices_payment_cash_account_same_company + FOREIGN KEY (payment_cash_account_id, company_id) + REFERENCES public.cash_accounts(id, company_id) + ON DELETE SET NULL (payment_cash_account_id); + END IF; +END $$; CREATE INDEX IF NOT EXISTS idx_invoices_payment_cash_account ON public.invoices (company_id, payment_cash_account_id) diff --git a/tests/pg/cash-accounts-invoice-payee.pg.test.ts b/tests/pg/cash-accounts-invoice-payee.pg.test.ts index e4b3d276..97e4a244 100644 --- a/tests/pg/cash-accounts-invoice-payee.pg.test.ts +++ b/tests/pg/cash-accounts-invoice-payee.pg.test.ts @@ -4,7 +4,7 @@ import { insertAuthUser, insertCashAccount, insertCompany, insertCompanyMember } import { getPool, withUserContext } from './setup' /** - * Migration 20260903150000: payee fields on cash_accounts, invoice_payee_defaults, + * Migration 20260904010000: payee fields on cash_accounts, invoice_payee_defaults, * and the mirror that keeps company_settings.invoice_payment_accounts plus the * legacy SEK columns equal to the default account per currency. */ @@ -49,7 +49,7 @@ async function setDefault(companyId: string, currency: string, cashAccountId: st ) } -describe('invoice payee accounts (20260903150000)', () => { +describe('invoice payee accounts (20260904010000)', () => { it('mirrors the default SEK account into the map and the legacy columns, and drops both when the default goes', async () => { const userId = await insertAuthUser() const companyId = await insertCompany({ createdBy: userId }) diff --git a/tests/pg/invoices-payment-cash-account.pg.test.ts b/tests/pg/invoices-payment-cash-account.pg.test.ts index b2f3e9b1..fb3ce3b6 100644 --- a/tests/pg/invoices-payment-cash-account.pg.test.ts +++ b/tests/pg/invoices-payment-cash-account.pg.test.ts @@ -4,7 +4,7 @@ import { insertAuthUser, insertCashAccount, insertCompany } from './fixtures' import { getPool } from './setup' /** - * Migration 20260903193000: invoices.payment_cash_account_id (FK, SET NULL) + * Migration 20260904011000: invoices.payment_cash_account_id (FK, SET NULL) * and invoices.payment_details (object snapshot). */ @@ -28,7 +28,7 @@ async function insertInvoice(companyId: string, userId: string, customerId: stri return id } -describe('invoice payee columns (20260903193000)', () => { +describe('invoice payee columns (20260904011000)', () => { it('stores the choice and the snapshot; deleting the account nulls the reference but keeps the snapshot', async () => { const userId = await insertAuthUser() const companyId = await insertCompany({ createdBy: userId }) diff --git a/types/index.ts b/types/index.ts index cc2f980a..bbad8707 100644 --- a/types/index.ts +++ b/types/index.ts @@ -644,7 +644,7 @@ export interface BankAccount { export type CashAccountSource = 'enable_banking' | 'manual' | 'sie_import' /** - * What a customer pays to. Lives on cash_accounts (migration 20260903150000) + * What a customer pays to. Lives on cash_accounts (migration 20260904010000) * and is the single source for the payee printed on customer invoices; the * per-currency map on company_settings is a trigger-maintained mirror of the * default account per currency. @@ -1363,7 +1363,7 @@ export interface Invoice { stripe_payment_link_id?: string | null // Per-invoice opt-out for automatic payment link creation on send. payment_link_auto?: boolean - // Per-invoice payee (migration 20260903193000): the bank account this + // Per-invoice payee (migration 20260904011000): the bank account this // invoice asks the customer to pay to (null = the per-currency default), // and its payee fields frozen when chosen and refreshed at issue. Issued // invoices print from payment_details; the resolver falls back to the