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
co-authored by Claude Fable 5 Jakob Wennberg
parent dfed55cb6c
commit c0ecb34a2b
8 changed files with 231 additions and 131 deletions
+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,
})
})
})