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 <noreply@anthropic.com>

* docs(decisions): move the migration-reset entry to the end of the log

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-27 14:35:19 +02:00
committed by GitHub
parent dfed55cb6c
commit c0ecb34a2b
8 changed files with 231 additions and 131 deletions
+1
View File
@@ -1284,6 +1284,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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':
+18 -23
View File
@@ -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.
+1 -4
View File
@@ -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.}}",
+1 -4
View File
@@ -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.}}",
@@ -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';
+139 -89
View File
@@ -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,
})
})
})
-4
View File
@@ -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'