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:
@@ -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':
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user