fix(migrations): re-issue the invoice payee migrations skipping migration-reset source companies (#2260)
* fix(migrations): re-issue the invoice payee migrations skipping migration-reset source companies #2233 merged, but its first migration (20260903150000) failed on prod at the backfill's INSERT into invoice_payee_defaults: ERROR: Archived migration reset source records are immutable (P0001) The insert fires the SECURITY DEFINER mirror into company_settings, and one of the companies with a legacy payment map is a migration-reset source, whose rows are immutable by trigger. The migration rolled back as a whole, prod has neither table nor column, and every migration merged after it is queued behind the failure. Same fix as #2249 used for the country backfill: both entry branches of the backfill now skip companies present in company_migration_resets, and both files are re-issued under fresh versions (20260904010000 and 20260904011000) so Supabase applies them in order after everything that landed today. The failed versions never applied on prod, so no orphan; staging applied them by hand and its schema_migrations rows must be renamed to match (see DECISIONS.md). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(migrations): make the re-issued payee migrations rerunnable pg-upgrade builds from main, where the first issue (20260903150000) already ran, then applies the re-issued file on top: the composite UNIQUE constraint already existed. Every statement in both files is now guarded (constraint DO blocks, CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY / DROP TRIGGER IF EXISTS before each CREATE), so staging and the preview branches that applied the first issue take the re-issue cleanly too, and prod, which never applied it, is unaffected. 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:
@@ -1556,3 +1556,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> = {
|
||||
*/
|
||||
const CASH_ACCOUNT_FIELDS: Record<string, string> = {
|
||||
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',
|
||||
|
||||
+26
-4
@@ -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), ''),
|
||||
+11
-6
@@ -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)
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user