From ebf69d29333002fffd169aceddbdef5f355e779a Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:35:52 +0200 Subject: [PATCH] fix: credit-note overdue countdown + voucher sequence resync after SIE import (#1069) * fix(invoices): hide overdue countdown for credit notes in invoice list Credit notes stay in status 'sent' forever (invoices_credit_note_not_paid blocks paid states), so the relative due-date label rendered an ever-growing 'X dagar forsenad' on every issued credit note. Skip the label for rows with credited_invoice_id set. Co-Authored-By: Claude Fable 5 * fix(bookkeeping): resync voucher_sequences counters left behind by pre-RPC SIE imports The batch SIE import path that predated import_sie_journal_entries (20260712150000) inserted vouchers with explicit numbers but never updated voucher_sequences, leaving counters behind max (year-end integrity error, duplicate-key crash on the next voucher) or missing entirely (next_voucher_number restarts at 1 and collides). Idempotent data repair: raise lagging counters to the observed max and insert missing rows attributed to the company owner. Already applied to prod and staging; replay is a no-op. Co-Authored-By: Claude Fable 5 * fix(bookkeeping): harden voucher-sequence resync per PR review Address PR #1069 review findings: close the ON CONFLICT race by upgrading DO NOTHING to DO UPDATE with GREATEST (a row created by next_voucher_number between snapshot and insert is raised instead of left at 1), unify the voucher_number > 0 filter across both statements, and record the manual prod/staging execution timestamps as the change record (ISO 27001 A.8.32, BFNAR 2013:2 behandlingshistorik). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/invoices/page.tsx | 4 +- ...00_resync_voucher_sequences_behind_max.sql | 67 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260719100000_resync_voucher_sequences_behind_max.sql diff --git a/DECISIONS.md b/DECISIONS.md index 073a589a..1332a608 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -219,3 +219,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-17] AGI XML generation no longer completes the arbetsgivardeklaration deadline: SFL 26 kap. deems the duty met only when the declaration reaches Skatteverket; kvittens reconcile remains the confirming path. [2026-07-17] Removed 'bokslut' deadline type (replaced by statutory 'arsstamma', ABL 7:10, 6 months): the 3-month milestone had no legal basis and its broken-FY date math was off by one month (May-start FY got 31 Aug; Nov-start rolled "Feb 31" into March). Completed bokslut rows kept for history; type removed from the union like the earlier 'moms'/'inkomstdeklaration' retirements. [2026-07-17] EU-trade/PS settings stay opt-in flags; a ledger-derived signal (postings on 3108/3308/3107, 15 months) only renders a suggestion callout in tax settings. Auto-flipping registration flags from ledger data would assert a Skatteverket registration we cannot know. +[2026-07-19] Voucher-sequence resync run on prod via execute_sql BEFORE the migration merges: data-only idempotent DML (no schema_migrations orphan risk) and a user was hard-blocked on year-end; migration file 20260719100000 ships the same SQL so every environment replays it as a no-op. diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index ef93c73a..7f8a2654 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -282,7 +282,9 @@ export default function InvoicesPage() { !invoice.is_self_billed const statusLabelKey = isUnsentInvoice ? 'status_unsent' : status.labelKey const statusVariant: InvoiceStatusVariant | 'outline' = isUnsentInvoice ? 'outline' : status.variant - const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null + // Credit notes are never payable (invoices_credit_note_not_paid), + // so a due-date countdown ("X dagar försenad") is meaningless for them. + const relativeTime = invoice.due_date && !isCreditNote ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null const displayedTotal = getDisplayTotal( { total: Number(invoice.total), currency: invoice.currency, ore_rounding: invoice.ore_rounding }, { ore_rounding: oreRounding }, diff --git a/supabase/migrations/20260719100000_resync_voucher_sequences_behind_max.sql b/supabase/migrations/20260719100000_resync_voucher_sequences_behind_max.sql new file mode 100644 index 00000000..39c70883 --- /dev/null +++ b/supabase/migrations/20260719100000_resync_voucher_sequences_behind_max.sql @@ -0,0 +1,67 @@ +-- Data repair: resync voucher_sequences counters that lag behind the highest +-- committed voucher number in their (company, fiscal period, series). +-- +-- The pre-RPC SIE import path (replaced by +-- 20260712150000_import_sie_journal_entries_rpc) inserted imported vouchers +-- with explicit numbers but left the series counter untouched. Affected +-- companies then fail year-end readiness ("Sequence counter integrity error") +-- and every new voucher in the series crashes on +-- uq_journal_entries_voucher_number, because next_voucher_number hands out +-- numbers that are already taken. +-- +-- Idempotent, data-only: raises last_number to the observed max where it is +-- behind; never lowers a counter (counter-ahead is a legal state handled by +-- voucher_gap_explanations) and never touches journal entries. Drafts are +-- excluded; cancelled entries keep voucher_number 0 and are filtered out. +-- +-- Change record (ISO 27001 A.8.32 / BFNAR 2013:2 behandlingshistorik): these +-- statements were executed manually against production (pwxtzglxptnnvjrpixpg) +-- and staging (metjnjrhvujscngnpzdv) on 2026-07-19 ~11:30-12:30 UTC to unblock +-- a customer's year-end (support case, DECISIONS.md 2026-07-19). This migration +-- is the reviewed change record for that repair; replay is a no-op. +UPDATE public.voucher_sequences vs +SET last_number = m.max_num, + updated_at = now() +FROM ( + SELECT company_id, fiscal_period_id, voucher_series, + max(voucher_number) AS max_num + FROM public.journal_entries + WHERE status <> 'draft' + AND voucher_number IS NOT NULL + AND voucher_number > 0 + GROUP BY company_id, fiscal_period_id, voucher_series +) m +WHERE vs.company_id = m.company_id + AND vs.fiscal_period_id = m.fiscal_period_id + AND vs.voucher_series = m.voucher_series + AND vs.last_number < m.max_num; + +-- Second failure shape of the same bug: committed vouchers whose (company, +-- period, series) has NO voucher_sequences row at all. next_voucher_number +-- would then INSERT a fresh row starting at 1 and the next voucher collides +-- with an existing number. Create the missing rows at the observed max, +-- attributed to the company owner (same fallback next_voucher_number uses; +-- companies without created_by are skipped rather than violating NOT NULL). +-- ON CONFLICT DO UPDATE with GREATEST keeps this idempotent AND closes the +-- race where next_voucher_number inserts the row (starting at 1) between the +-- max() snapshot and this INSERT: the conflict path raises such a row to the +-- observed max instead of silently leaving it at 1. The WHERE guard makes +-- healthy rows a no-op, so a counter is never lowered. +INSERT INTO public.voucher_sequences + (company_id, user_id, fiscal_period_id, voucher_series, last_number) +SELECT m.company_id, c.created_by, m.fiscal_period_id, m.voucher_series, m.max_num +FROM ( + SELECT company_id, fiscal_period_id, voucher_series, + max(voucher_number) AS max_num + FROM public.journal_entries + WHERE status <> 'draft' + AND voucher_number IS NOT NULL + AND voucher_number > 0 + GROUP BY company_id, fiscal_period_id, voucher_series +) m +JOIN public.companies c ON c.id = m.company_id +WHERE c.created_by IS NOT NULL +ON CONFLICT (company_id, fiscal_period_id, voucher_series) DO UPDATE +SET last_number = GREATEST(public.voucher_sequences.last_number, EXCLUDED.last_number), + updated_at = now() +WHERE public.voucher_sequences.last_number < EXCLUDED.last_number;