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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
dfed55cb6c
commit
c0ecb34a2b
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user