From c0ecb34a2b699fc66501ad2339f9617cb7ee97f9 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 27 Aug 2026 14:35:19 +0200 Subject: [PATCH] fix(company): migration reset no longer blocks on existing vouchers, sequences, or invoices (#1977) * fix(company): migration reset no longer blocks on existing vouchers, sequences, or invoices The 2026-08-18 eligibility rule stopped the archive-and-replace reset before the first journal entry, voucher sequence, or invoice. The reset deletes nothing: the source stays a write-closed, downloadable retention container, and the unchecked Radera foretag path already produced the same outcome without any of those guards, so the blockers only led owners into a dead end (Carrierstories, 2026-08-26). External-state blockers are unchanged. Closes #1916 Co-Authored-By: Claude Fable 5 * docs(decisions): move the migration-reset entry to the end of the log Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../settings/CompanyMigrationResetDialog.tsx | 7 - docs/support/company-migration-reset.md | 41 ++-- messages/en.json | 5 +- messages/sv.json | 5 +- ...igration_reset_with_accounting_records.sql | 71 ++++++ tests/pg/company-migration-reset.pg.test.ts | 228 +++++++++++------- types/index.ts | 4 - 8 files changed, 231 insertions(+), 131 deletions(-) create mode 100644 supabase/migrations/20260826150000_allow_migration_reset_with_accounting_records.sql diff --git a/DECISIONS.md b/DECISIONS.md index a3e5101e..7edb5f05 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1284,6 +1284,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-26] Webhook event catalogue lives in lib/webhooks/public-events.ts (grouped, with docs prose) and the fan-out handler set, the v1 create enum (so the OpenAPI spec and skills/accounted-api), and the docs page all derive from it: the enum and the docs had drifted to 24 of the 28 events the handler delivered, so the four reconciliation.* events were rejected at subscribe time. No API_V1_VERSION bump: the changelog already lists them as additive. [2026-08-26] Removed the phantom V1_ENDPOINT_SCOPES entries GET /api/v1/openapi.yaml, GET /api/v1/companies/:companyId and GET /api/v1/companies/:companyId/events instead of building the routes: no route file, registry entry, docs, skill or test referenced them, and the new scope-registry-parity test needs the map to describe only what exists. A company-detail GET can be added later with its entry in the same PR. [2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog. +[2026-08-26] Migration reset eligibility no longer blocks on existing journal entries, voucher sequences, or invoices (20260826150000, reverses the 2026-08-18 first-record rule): the reset never deletes, the retained source stays write-closed and downloadable from the replacement, and the unchecked "Radera företag" path already produced the same archived-source-plus-fresh-company outcome with no protection at all, so the blockers only routed owners into a dead end (Carrierstories, 2026-08-26, two fiscal years imported in the wrong order). External-state blockers (locked or closed periods, filings and staging state, live bank sync, running imports, integrations, workers, 30-day window, sandbox) are unchanged. [2026-08-26] Dashboard layout diet (responsiveness plan B4): the four nav-visibility probes (WooCommerce/Shopify connections, webshop orders, mileage trips) became one SECURITY INVOKER RPC get_dashboard_nav_flags (20260826120000) rather than a client-side badge hook, because the flags gate whole nav rows and loading them after mount pops rows into the sidebar; RLS still applies inside the function. The layout's getDashboardAuthContext now verifies the JWT locally (lib/auth/claims.ts, shared with requireAuth) with getUser() as the fallback: the proxy already did the per-request revocation check, so the layout's own getUser() was a second Supabase Auth round trip on every hard load. The user-keyed reads (profile, preferences, memberships) moved into the company-resolution wave and the memberships join now supplies the active company row and role, so the layout went from ~20 network calls in 4 waves to ~12 in 2. The memberships-with-embedded-settings single query was NOT attempted: the PostgREST embed shape was unverified and the settings-names query is parallel anyway. [2026-08-26] OAuth consent pre-checks ALL scopes (one-click, list collapsed in details): founder call after the read-only default dead-ended agent flows; defensible because every write is staged for approval, rows stay untickable, grant revocable. [2026-08-26] accounting_method optional with form default (AB=accrual, EF=cash) in CompanySetupSchema/planCompanySetup: founder call to cut agent onboarding input to orgnr + moms period; the default is flagged (accounting_method_defaulted) and must be read back in the preview, never silent. diff --git a/components/settings/CompanyMigrationResetDialog.tsx b/components/settings/CompanyMigrationResetDialog.tsx index 1ecb3ff7..27ee8b61 100644 --- a/components/settings/CompanyMigrationResetDialog.tsx +++ b/components/settings/CompanyMigrationResetDialog.tsx @@ -126,13 +126,6 @@ export function CompanyMigrationResetDialog({ return t('reset_blocker_sandbox') case 'locked_or_closed_periods': return t('reset_blocker_periods', { count: blocker.count }) - case 'journal_entries_exist': - case 'non_import_committed_entries': - return t('reset_blocker_entries', { count: blocker.count }) - case 'voucher_sequence_state_exists': - return t('reset_blocker_sequences', { count: blocker.count }) - case 'invoice_records_exist': - return t('reset_blocker_invoices', { count: blocker.count }) case 'authority_submission_detected': return t('reset_blocker_filings', { count: blocker.count }) case 'live_bank_connections': diff --git a/docs/support/company-migration-reset.md b/docs/support/company-migration-reset.md index 25361ff4..edb74822 100644 --- a/docs/support/company-migration-reset.md +++ b/docs/support/company-migration-reset.md @@ -34,14 +34,14 @@ The self-service boundary is intentionally narrow: - The caller must be an owner, not merely an admin or member. - No company lock date may exist, and no fiscal period may be locked, closed, or marked closed in the previous bookkeeping system. -- No journal entry may exist in any status or source. Drafts, imported entries, - opening balances, postings, reversals, and corrections all block the reset. -- No voucher-sequence row may exist, including a zero-valued sequence. This - prevents the same legal entity from receiving two independently restarted - voucher-number namespaces. -- No customer or supplier invoice may exist, even without a journal entry. - Issued invoices, credit-note references, and received supplier invoices are - retained accounting documents and cannot be stranded in a hidden source. +- Journal entries, voucher sequences, and customer or supplier invoices do not + block (since migration 20260826150000; the 2026-08-18 rule blocked on the + first of any of them). They stay unchanged in the retained source, which is + write-closed and downloadable from the replacement. The replacement starts a + fresh voucher namespace and continues the invoice and arrival-number + counters. The unchecked "Radera företag" path produced the same two + companies with none of the retention guards, so blocking here only routed + owners into a dead end. - No bank connection may be pending or active. This prevents the service-role sync job from writing new transactions into the retained source after reset. - No SIE, bank-file, or tax-account-file import may be pending or processing. @@ -99,11 +99,12 @@ The following stay on the archived source company unchanged: - fiscal periods and their lock or close state - documents, hashes, version chains, and voucher links - customers and suppliers +- journal entries, journal-entry lines, and voucher sequences +- customer and supplier invoices - authority and general audit logs - extension runtime state, including any non-blocking provider history -Eligibility requires journal-entry, voucher-sequence, customer-invoice, and -supplier-invoice counts to be zero. +Their counts are recorded in `company_migration_resets.source_counts`. Those tables remain covered by the immutable-source guards and audit counts as defense in depth. The replacement company intentionally has no fiscal period, journal entry, transaction, document, import record, or voucher sequence. The @@ -139,7 +140,7 @@ reuse ordinary write-capable screens. The replacement represents the same legal entity, not a newly formed business. Its `next_invoice_number` and `next_arrival_number` therefore continue from the -source settings even when eligibility confirms that no invoice rows exist. +source settings whether or not invoice rows exist on the source. Those counters can reflect an imported or previously allocated series, and resetting them to 1 could reuse a number or conceal a gap. Do not manually reset either counter as part of migration recovery. Escalate a suspected numbering @@ -196,15 +197,9 @@ an incident requiring investigation. Do not repair it by editing the source. disposable sandbox data into a retained migration archive. - `locked_or_closed_periods`: a company lock date or finalized period exists. Do not clear or unlock it to enable reset. -- `journal_entries_exist`: at least one draft or committed journal entry exists, - regardless of whether it came from an import. Do not delete, reverse, or edit - the entry to enable reset. Continue in the existing company or escalate for - case-specific review. -- `voucher_sequence_state_exists`: at least one voucher sequence has been - created. Do not renumber or remove it to enable reset. -- `invoice_records_exist`: at least one customer or supplier invoice exists. - Do not delete, cancel, credit, or detach it to enable reset. Continue in the - existing company or escalate for case-specific review. +- Journal entries, voucher sequences, and invoices are reported in the retained + counts and no longer block (20260826150000). They stay on the source; never + delete, reverse, renumber, or detach any of them. - `authority_submission_detected`: Accounted has evidence of an authority interaction, including a persisted VAT draft, an AGI upload awaiting BankID signing, or any generated ROT/RUT payout file. Do not reset even if the owner @@ -235,7 +230,8 @@ The migration files are and `supabase/migrations/20260818143004_close_migration_reset_archive_gaps.sql` and `supabase/migrations/20260818224000_block_vat_state_migration_reset.sql` and -`supabase/migrations/20260818231500_block_external_filing_staging_state.sql`. +`supabase/migrations/20260818231500_block_external_filing_staging_state.sql` and +`supabase/migrations/20260826150000_allow_migration_reset_with_accounting_records.sql`. Apply them only to the permitted `erpbase` staging branch through the normal migration workflow, then deploy application code. Never deploy the UI/API before all listed migrations exist. @@ -262,8 +258,7 @@ applied migration file. Escalate to Emil and an accounting/legal reviewer when the owner cannot make the external-filing attestation, the company is outside the 30-day window, a -period is closed or locked, any journal entry or voucher sequence exists, or a -customer or supplier invoice exists, or a known authority submission exists. A +period is closed or locked, or a known authority submission exists. A live bank connection is operational, not a legal override case: disconnect it before retrying. The safe fallback is to retain the source company and perform no reset. diff --git a/messages/en.json b/messages/en.json index 90065e5a..8feb9044 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1855,14 +1855,11 @@ "reset_checking": "Checking whether the company can be reset...", "reset_load_failed": "Could not check whether the company can be reset.", "reset_archive_title": "Nothing is deleted", - "reset_archive_description": "Imported transactions, documents, periods, and import history remain unchanged in the archived company. Self-service is blocked if a voucher, voucher sequence, or invoice already exists.", + "reset_archive_description": "Vouchers, invoices, transactions, documents, periods, and import history remain unchanged in the archived company. The new company copy starts with an empty voucher series, and the archived material can be downloaded from here afterwards.", "reset_blocked_title": "Self-service is blocked", "reset_blocker_window": "The self-service reset window ended on {date}.", "reset_blocker_sandbox": "Sandbox companies use their separate automatic cleanup and cannot be reset here.", "reset_blocker_periods": "{count, plural, =1 {One bookkeeping lock or closed fiscal year exists.} other {# bookkeeping locks or closed fiscal years exist.}}", - "reset_blocker_entries": "{count, plural, =1 {One voucher already exists; drafts and migrated vouchers also block reset.} other {# vouchers already exist; drafts and migrated vouchers also block reset.}}", - "reset_blocker_sequences": "{count, plural, =1 {One voucher sequence has already been created.} other {# voucher sequences have already been created.}}", - "reset_blocker_invoices": "{count, plural, =1 {One customer or supplier invoice already exists.} other {# customer or supplier invoices already exist.}}", "reset_blocker_filings": "{count, plural, =1 {One known submission to Skatteverket or Bolagsverket exists.} other {# known submissions to Skatteverket or Bolagsverket exist.}}", "reset_blocker_bank_connections": "{count, plural, =1 {One bank connection is still active or being connected. Disconnect it first.} other {# bank connections are still active or being connected. Disconnect them first.}}", "reset_blocker_imports": "{count, plural, =1 {One import is still in progress. Wait for it to finish or fail before retrying.} other {# imports are still in progress. Wait for them to finish or fail before retrying.}}", diff --git a/messages/sv.json b/messages/sv.json index ddb8aecc..8cc34a58 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1855,14 +1855,11 @@ "reset_checking": "Kontrollerar om företaget kan återställas...", "reset_load_failed": "Kunde inte kontrollera om företaget kan återställas.", "reset_archive_title": "Ingenting raderas", - "reset_archive_description": "Importerade transaktioner, dokument, perioder och importhistorik stannar oförändrade i det arkiverade företaget. Självservice blockeras om en verifikation, nummerserie eller faktura redan finns.", + "reset_archive_description": "Verifikationer, fakturor, transaktioner, dokument, perioder och importhistorik stannar oförändrade i det arkiverade företaget. Den nya företagskopian börjar med en tom verifikationsserie, och det arkiverade materialet kan laddas ner härifrån efteråt.", "reset_blocked_title": "Självservice är blockerad", "reset_blocker_window": "Tidsgränsen för återställning med självservice gick ut {date}.", "reset_blocker_sandbox": "Testföretag använder sin separata automatiska rensning och kan inte återställas här.", "reset_blocker_periods": "{count, plural, =1 {Ett bokföringslås eller stängt räkenskapsår finns.} other {# bokföringslås eller stängda räkenskapsår finns.}}", - "reset_blocker_entries": "{count, plural, =1 {En verifikation finns redan, även utkast och migrerade verifikationer blockerar återställning.} other {# verifikationer finns redan, även utkast och migrerade verifikationer blockerar återställning.}}", - "reset_blocker_sequences": "{count, plural, =1 {En verifikationsserie har redan skapats.} other {# verifikationsserier har redan skapats.}}", - "reset_blocker_invoices": "{count, plural, =1 {En kund- eller leverantörsfaktura finns redan.} other {# kund- eller leverantörsfakturor finns redan.}}", "reset_blocker_filings": "{count, plural, =1 {En känd inlämning till Skatteverket eller Bolagsverket finns.} other {# kända inlämningar till Skatteverket eller Bolagsverket finns.}}", "reset_blocker_bank_connections": "{count, plural, =1 {En bankkoppling är fortfarande aktiv eller håller på att anslutas. Koppla från den först.} other {# bankkopplingar är fortfarande aktiva eller håller på att anslutas. Koppla från dem först.}}", "reset_blocker_imports": "{count, plural, =1 {En import pågår fortfarande. Vänta tills den är klar eller har misslyckats innan du försöker igen.} other {# importer pågår fortfarande. Vänta tills de är klara eller har misslyckats innan du försöker igen.}}", diff --git a/supabase/migrations/20260826150000_allow_migration_reset_with_accounting_records.sql b/supabase/migrations/20260826150000_allow_migration_reset_with_accounting_records.sql new file mode 100644 index 00000000..e34b034b --- /dev/null +++ b/supabase/migrations/20260826150000_allow_migration_reset_with_accounting_records.sql @@ -0,0 +1,71 @@ +-- Allow archive-and-replace migration resets after accounting records exist. +-- +-- The 2026-08-18 eligibility rule stopped self-service before the first +-- journal entry, voucher sequence, or invoice on the retained source. The rule +-- protected voucher-namespace continuity inside the same legal entity, but the +-- reset never deletes anything: the source stays a write-closed retention +-- container holding every voucher, sequence, invoice, and document, and the +-- replacement owner can download it through the retained-source archive. The +-- unchecked "Radera företag" path already produced the same outcome (archived +-- source plus a fresh company with the same org number) with none of that +-- protection, so the blockers only pushed owners toward the weaker route. +-- Observed live 2026-08-26: an owner who imported two fiscal years in the +-- wrong order was shown "Självservice är blockerad" with no way forward. +-- +-- Journal-entry, voucher-sequence, and invoice counts stay in the snapshot as +-- retained-data information. Every external-state blocker is unchanged: locked +-- or closed periods, authority submissions and staging state, live bank +-- connections, running imports, active integrations, background work, the +-- 30-day window, and sandbox companies. + +ALTER FUNCTION public.company_migration_reset_snapshot(uuid) + RENAME TO company_migration_reset_snapshot_before_20260826150000; + +CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_snapshot jsonb; + v_blockers jsonb; +BEGIN + v_snapshot := public.company_migration_reset_snapshot_before_20260826150000( + p_company_id + ); + + IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN + RETURN v_snapshot; + END IF; + + -- Accounting records are retained, not disposed of, so their presence is + -- reported through counts and must not decide eligibility. + SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb) + INTO v_blockers + FROM jsonb_array_elements(v_snapshot -> 'blockers') + WITH ORDINALITY AS existing(blocker, position) + WHERE existing.blocker ->> 'code' NOT IN ( + 'journal_entries_exist', + 'non_import_committed_entries', + 'voucher_sequence_state_exists', + 'invoice_records_exist' + ); + + RETURN v_snapshot || jsonb_build_object( + 'eligible', jsonb_array_length(v_blockers) = 0, + 'blockers', v_blockers + ); +END; +$$; + +COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS + 'Internal fail-closed reset snapshot. Journal entries, voucher sequences, and invoices are retained data, not blockers; lock, filing, sync, import, integration, and worker state still block.'; + +REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260826150000(uuid) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid) + FROM PUBLIC, anon, authenticated; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/company-migration-reset.pg.test.ts b/tests/pg/company-migration-reset.pg.test.ts index a5951ab6..2b2c1cfb 100644 --- a/tests/pg/company-migration-reset.pg.test.ts +++ b/tests/pg/company-migration-reset.pg.test.ts @@ -72,6 +72,7 @@ describe('company migration reset RPCs (pg)', () => { legacy_snapshot_143004_authenticated: boolean legacy_snapshot_224000_authenticated: boolean legacy_snapshot_231500_authenticated: boolean + legacy_snapshot_20260826150000_authenticated: boolean }>(` SELECT has_function_privilege( @@ -118,7 +119,12 @@ describe('company migration reset RPCs (pg)', () => { 'authenticated', 'public.company_migration_reset_snapshot_before_20260818231500(uuid)', 'EXECUTE' - ) AS legacy_snapshot_231500_authenticated + ) AS legacy_snapshot_231500_authenticated, + has_function_privilege( + 'authenticated', + 'public.company_migration_reset_snapshot_before_20260826150000(uuid)', + 'EXECUTE' + ) AS legacy_snapshot_20260826150000_authenticated `) expect(rows[0]).toEqual({ @@ -131,6 +137,7 @@ describe('company migration reset RPCs (pg)', () => { legacy_snapshot_143004_authenticated: false, legacy_snapshot_224000_authenticated: false, legacy_snapshot_231500_authenticated: false, + legacy_snapshot_20260826150000_authenticated: false, }) }) @@ -175,7 +182,7 @@ describe('company migration reset RPCs (pg)', () => { }) }) - it('fails closed on locks, journal entries, filings, live bank sync, and age', async () => { + it('fails closed on locks, filings, live bank sync, and age', async () => { const locked = await seedCompany({ isClosed: true }) const lockedPreview = await preview(locked.userId, locked.companyId) expect(lockedPreview.eligibility?.blockers).toContainEqual({ @@ -183,19 +190,6 @@ describe('company migration reset RPCs (pg)', () => { count: 1, }) - const nonImport = await seedCompany() - await insertDraftJournalEntry({ - ...nonImport, - status: 'posted', - sourceType: 'manual', - voucherNumber: 1, - }) - const nonImportPreview = await preview(nonImport.userId, nonImport.companyId) - expect(nonImportPreview.eligibility?.blockers).toContainEqual({ - code: 'journal_entries_exist', - count: 1, - }) - const filed = await seedCompany() await getPool().query( `INSERT INTO public.skatteverket_api_audit_log @@ -361,7 +355,11 @@ describe('company migration reset RPCs (pg)', () => { }) }) - it('blocks drafts, imported postings, linked documents, and voucher sequence state', async () => { + // Journal entries, voucher sequences, and linked documents are retained + // data, not blockers (20260826150000). The reset must leave every one of + // them on the write-closed source and give the replacement a fresh voucher + // namespace. + it('retains drafts, postings, linked documents, and voucher sequence state instead of blocking', async () => { const draft = await seedCompany() await insertDraftJournalEntry({ ...draft, @@ -369,10 +367,9 @@ describe('company migration reset RPCs (pg)', () => { sourceType: 'manual', }) const draftPreview = await preview(draft.userId, draft.companyId) - expect(draftPreview.eligibility?.blockers).toContainEqual({ - code: 'journal_entries_exist', - count: 1, - }) + expect(draftPreview.eligibility?.eligible).toBe(true) + expect(draftPreview.eligibility?.blockers).toEqual([]) + expect(draftPreview.eligibility?.counts.journal_entries).toBe(1) const imported = await seedCompany() const importEntryId = await insertDraftJournalEntry({ @@ -382,6 +379,13 @@ describe('company migration reset RPCs (pg)', () => { voucherSeries: 'A', voucherNumber: 40, }) + const manualEntryId = await insertDraftJournalEntry({ + ...imported, + status: 'posted', + sourceType: 'manual', + voucherSeries: 'A', + voucherNumber: 41, + }) const documentId = randomUUID() await getPool().query( `INSERT INTO public.document_attachments @@ -400,100 +404,146 @@ describe('company migration reset RPCs (pg)', () => { await getPool().query( `INSERT INTO public.voucher_sequences (user_id, company_id, fiscal_period_id, voucher_series, last_number) - VALUES ($1, $2, $3, 'A', 40)`, + VALUES ($1, $2, $3, 'A', 41)`, [imported.userId, imported.companyId, imported.fiscalPeriodId], ) const importedPreview = await preview(imported.userId, imported.companyId) - expect(importedPreview.eligibility?.blockers).toEqual(expect.arrayContaining([ - { code: 'journal_entries_exist', count: 1 }, - { code: 'voucher_sequence_state_exists', count: 1 }, - ])) - expect(importedPreview.eligibility?.blockers).not.toContainEqual( - expect.objectContaining({ code: 'non_import_committed_entries' }), - ) - - await expect(execute(imported.userId, imported.companyId)).resolves.toMatchObject({ - ok: false, - code: 'COMPANY_RESET_INELIGIBLE', - }) - const retained = await getPool().query<{ - archived_at: string | null - entry_exists: boolean - linked_document_exists: boolean - sequence_last_number: number - reset_count: number - }>( - `SELECT - (SELECT archived_at::text FROM public.companies WHERE id = $1) AS archived_at, - EXISTS (SELECT 1 FROM public.journal_entries WHERE id = $2) AS entry_exists, - EXISTS ( - SELECT 1 FROM public.document_attachments - WHERE id = $3 AND journal_entry_id = $2 - ) AS linked_document_exists, - (SELECT last_number::int FROM public.voucher_sequences - WHERE company_id = $1 AND voucher_series = 'A') AS sequence_last_number, - (SELECT count(*)::int FROM public.company_migration_resets - WHERE source_company_id = $1) AS reset_count`, - [imported.companyId, importEntryId, documentId], - ) - expect(retained.rows[0]).toEqual({ - archived_at: null, - entry_exists: true, - linked_document_exists: true, - sequence_last_number: 40, - reset_count: 0, + expect(importedPreview.eligibility?.eligible).toBe(true) + expect(importedPreview.eligibility?.blockers).toEqual([]) + expect(importedPreview.eligibility?.counts).toMatchObject({ + journal_entries: 2, + committed_import_entries: 1, + voucher_sequences: 1, + documents: 1, }) - const sequenceOnly = await seedCompany() - await getPool().query( - `INSERT INTO public.voucher_sequences - (user_id, company_id, fiscal_period_id, voucher_series, last_number) - VALUES ($1, $2, $3, 'B', 0)`, - [sequenceOnly.userId, sequenceOnly.companyId, sequenceOnly.fiscalPeriodId], - ) - const sequencePreview = await preview(sequenceOnly.userId, sequenceOnly.companyId) - expect(sequencePreview.eligibility?.blockers).toContainEqual({ - code: 'voucher_sequence_state_exists', - count: 1, + // withUserContext rolls back, so the post-reset state is read inside the + // same transaction, mirroring the atomic archive-and-replace test below. + await withUserContext(imported.userId, async (client) => { + const { rows } = await client.query<{ result: RpcResult }>( + `SELECT public.reset_company_for_migration($1, $2, $3, true, true) AS result`, + [imported.companyId, 'Test AB', 'The fiscal years were imported in the wrong order.'], + ) + const result = rows[0]!.result + expect(result).toMatchObject({ ok: true, source_company_id: imported.companyId }) + const replacementId = result.replacement_company_id! + await client.query('RESET ROLE') + + const retained = await client.query<{ + source_archived: boolean + import_entry_exists: boolean + manual_entry_exists: boolean + linked_document_exists: boolean + sequence_last_number: number + recorded_journal_entries: number + replacement_entries: number + replacement_sequences: number + }>( + `SELECT + (SELECT archived_at IS NOT NULL FROM public.companies WHERE id = $1) AS source_archived, + EXISTS ( + SELECT 1 FROM public.journal_entries + WHERE id = $2 AND company_id = $1 AND status = 'posted' + ) AS import_entry_exists, + EXISTS ( + SELECT 1 FROM public.journal_entries + WHERE id = $3 AND company_id = $1 AND status = 'posted' + ) AS manual_entry_exists, + EXISTS ( + SELECT 1 FROM public.document_attachments + WHERE id = $4 AND company_id = $1 AND journal_entry_id = $2 + ) AS linked_document_exists, + (SELECT last_number::int FROM public.voucher_sequences + WHERE company_id = $1 AND voucher_series = 'A') AS sequence_last_number, + (SELECT (source_counts ->> 'journal_entries')::int + FROM public.company_migration_resets + WHERE source_company_id = $1) AS recorded_journal_entries, + (SELECT count(*)::int FROM public.journal_entries + WHERE company_id = $5) AS replacement_entries, + (SELECT count(*)::int FROM public.voucher_sequences + WHERE company_id = $5) AS replacement_sequences`, + [imported.companyId, importEntryId, manualEntryId, documentId, replacementId], + ) + expect(retained.rows[0]).toEqual({ + source_archived: true, + import_entry_exists: true, + manual_entry_exists: true, + linked_document_exists: true, + sequence_last_number: 41, + recorded_journal_entries: 2, + replacement_entries: 0, + replacement_sequences: 0, + }) }) }) - it('blocks customer and supplier invoice records before any voucher exists', async () => { - const customerInvoice = await seedCompany() + it('retains customer and supplier invoice records instead of blocking', async () => { + const { userId, companyId } = await seedCompany() await getPool().query( `INSERT INTO public.invoices (user_id, company_id, invoice_number, invoice_date, due_date, status) VALUES ($1, $2, 1, '2026-08-01', '2026-08-31', 'sent')`, - [customerInvoice.userId, customerInvoice.companyId], + [userId, companyId], ) - const customerPreview = await preview(customerInvoice.userId, customerInvoice.companyId) - expect(customerPreview.eligibility?.blockers).toContainEqual({ - code: 'invoice_records_exist', - count: 1, - }) - expect(customerPreview.eligibility?.blockers).not.toContainEqual( - expect.objectContaining({ code: 'journal_entries_exist' }), - ) - - const supplierInvoice = await seedCompany() const supplierId = randomUUID() await getPool().query( `INSERT INTO public.suppliers (id, user_id, company_id, name) VALUES ($1, $2, $3, 'Leverantor AB')`, - [supplierId, supplierInvoice.userId, supplierInvoice.companyId], + [supplierId, userId, companyId], ) await getPool().query( `INSERT INTO public.supplier_invoices (user_id, company_id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date) VALUES ($1, $2, $3, 1, 'SUP-1', '2026-08-01', '2026-08-31')`, - [supplierInvoice.userId, supplierInvoice.companyId, supplierId], + [userId, companyId, supplierId], ) - const supplierPreview = await preview(supplierInvoice.userId, supplierInvoice.companyId) - expect(supplierPreview.eligibility?.blockers).toContainEqual({ - code: 'invoice_records_exist', - count: 1, + + const invoicePreview = await preview(userId, companyId) + expect(invoicePreview.eligibility?.eligible).toBe(true) + expect(invoicePreview.eligibility?.blockers).toEqual([]) + expect(invoicePreview.eligibility?.counts).toMatchObject({ + invoices: 1, + supplier_invoices: 1, + suppliers: 1, + }) + + await withUserContext(userId, async (client) => { + const { rows } = await client.query<{ result: RpcResult }>( + `SELECT public.reset_company_for_migration($1, $2, $3, true, true) AS result`, + [companyId, 'Test AB', 'The invoice import mapped the wrong accounts.'], + ) + const result = rows[0]!.result + expect(result).toMatchObject({ ok: true, source_company_id: companyId }) + const replacementId = result.replacement_company_id! + await client.query('RESET ROLE') + + const retained = await client.query<{ + source_archived: boolean + source_invoices: number + source_supplier_invoices: number + replacement_invoices: number + replacement_supplier_invoices: number + replacement_suppliers: number + }>( + `SELECT + (SELECT archived_at IS NOT NULL FROM public.companies WHERE id = $1) AS source_archived, + (SELECT count(*)::int FROM public.invoices WHERE company_id = $1) AS source_invoices, + (SELECT count(*)::int FROM public.supplier_invoices WHERE company_id = $1) AS source_supplier_invoices, + (SELECT count(*)::int FROM public.invoices WHERE company_id = $2) AS replacement_invoices, + (SELECT count(*)::int FROM public.supplier_invoices WHERE company_id = $2) AS replacement_supplier_invoices, + (SELECT count(*)::int FROM public.suppliers WHERE company_id = $2) AS replacement_suppliers`, + [companyId, replacementId], + ) + expect(retained.rows[0]).toEqual({ + source_archived: true, + source_invoices: 1, + source_supplier_invoices: 1, + replacement_invoices: 0, + replacement_supplier_invoices: 0, + replacement_suppliers: 0, + }) }) }) diff --git a/types/index.ts b/types/index.ts index 50ac108c..0fd0d0b0 100644 --- a/types/index.ts +++ b/types/index.ts @@ -84,10 +84,6 @@ export type CompanyMigrationResetBlockerCode = | 'migration_window_expired' | 'sandbox_company' | 'locked_or_closed_periods' - | 'journal_entries_exist' - | 'non_import_committed_entries' - | 'voucher_sequence_state_exists' - | 'invoice_records_exist' | 'authority_submission_detected' | 'live_bank_connections' | 'imports_in_progress'