6ea92f3152
Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed). Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw. What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body. Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows. Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB
1186 lines
46 KiB
TypeScript
1186 lines
46 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getClient, getPool, withUserContext } from './setup'
|
|
import {
|
|
insertAuthUser,
|
|
insertCompanyMember,
|
|
insertDraftJournalEntry,
|
|
insertTransaction,
|
|
seedCompany,
|
|
} from './fixtures'
|
|
|
|
interface RpcResult {
|
|
ok: boolean
|
|
code?: string
|
|
reset_id?: string
|
|
source_company_id?: string
|
|
replacement_company_id?: string
|
|
eligibility?: {
|
|
eligible: boolean
|
|
blockers: Array<{ code: string; count: number }>
|
|
counts: Record<string, number>
|
|
}
|
|
details?: {
|
|
blockers: Array<{ code: string; count: number }>
|
|
}
|
|
}
|
|
|
|
async function preview(userId: string, companyId: string): Promise<RpcResult> {
|
|
return withUserContext(userId, async (client) => {
|
|
const { rows } = await client.query<{ result: RpcResult }>(
|
|
`SELECT public.get_company_migration_reset_eligibility($1) AS result`,
|
|
[companyId],
|
|
)
|
|
return rows[0]!.result
|
|
})
|
|
}
|
|
|
|
async function execute(
|
|
userId: string,
|
|
companyId: string,
|
|
overrides: Partial<{
|
|
name: string
|
|
reason: string
|
|
noFilings: boolean
|
|
retainedArchive: boolean
|
|
}> = {},
|
|
): Promise<RpcResult> {
|
|
return withUserContext(userId, async (client) => {
|
|
const { rows } = await client.query<{ result: RpcResult }>(
|
|
`SELECT public.reset_company_for_migration($1, $2, $3, $4, $5) AS result`,
|
|
[
|
|
companyId,
|
|
overrides.name ?? 'Test AB',
|
|
overrides.reason ?? 'The first migration used the wrong fiscal periods.',
|
|
overrides.noFilings ?? true,
|
|
overrides.retainedArchive ?? true,
|
|
],
|
|
)
|
|
return rows[0]!.result
|
|
})
|
|
}
|
|
|
|
describe('company migration reset RPCs (pg)', () => {
|
|
it('exposes only the two owner entry points to authenticated callers', async () => {
|
|
const { rows } = await getPool().query<{
|
|
preview_anon: boolean
|
|
preview_authenticated: boolean
|
|
execute_anon: boolean
|
|
execute_authenticated: boolean
|
|
snapshot_authenticated: boolean
|
|
legacy_snapshot_141018_authenticated: boolean
|
|
legacy_snapshot_143004_authenticated: boolean
|
|
legacy_snapshot_224000_authenticated: boolean
|
|
legacy_snapshot_231500_authenticated: boolean
|
|
legacy_snapshot_20260826150000_authenticated: boolean
|
|
legacy_snapshot_20260909100400_authenticated: boolean
|
|
legacy_reset_20260909100400_authenticated: boolean
|
|
}>(`
|
|
SELECT
|
|
has_function_privilege(
|
|
'anon',
|
|
'public.get_company_migration_reset_eligibility(uuid)',
|
|
'EXECUTE'
|
|
) AS preview_anon,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.get_company_migration_reset_eligibility(uuid)',
|
|
'EXECUTE'
|
|
) AS preview_authenticated,
|
|
has_function_privilege(
|
|
'anon',
|
|
'public.reset_company_for_migration(uuid,text,text,boolean,boolean)',
|
|
'EXECUTE'
|
|
) AS execute_anon,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.reset_company_for_migration(uuid,text,text,boolean,boolean)',
|
|
'EXECUTE'
|
|
) AS execute_authenticated,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.company_migration_reset_snapshot(uuid)',
|
|
'EXECUTE'
|
|
) AS snapshot_authenticated,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.company_migration_reset_snapshot_before_20260818141018(uuid)',
|
|
'EXECUTE'
|
|
) AS legacy_snapshot_141018_authenticated,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.company_migration_reset_snapshot_before_20260818143004(uuid)',
|
|
'EXECUTE'
|
|
) AS legacy_snapshot_143004_authenticated,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.company_migration_reset_snapshot_before_20260818224000(uuid)',
|
|
'EXECUTE'
|
|
) AS legacy_snapshot_224000_authenticated,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.company_migration_reset_snapshot_before_20260818231500(uuid)',
|
|
'EXECUTE'
|
|
) AS legacy_snapshot_231500_authenticated,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.company_migration_reset_snapshot_before_20260826150000(uuid)',
|
|
'EXECUTE'
|
|
) AS legacy_snapshot_20260826150000_authenticated,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.company_migration_reset_snapshot_before_20260909100400(uuid)',
|
|
'EXECUTE'
|
|
) AS legacy_snapshot_20260909100400_authenticated,
|
|
has_function_privilege(
|
|
'authenticated',
|
|
'public.reset_company_for_migration_before_20260909100400(uuid,text,text,boolean,boolean)',
|
|
'EXECUTE'
|
|
) AS legacy_reset_20260909100400_authenticated
|
|
`)
|
|
|
|
expect(rows[0]).toEqual({
|
|
preview_anon: false,
|
|
preview_authenticated: true,
|
|
execute_anon: false,
|
|
execute_authenticated: true,
|
|
snapshot_authenticated: false,
|
|
legacy_snapshot_141018_authenticated: false,
|
|
legacy_snapshot_143004_authenticated: false,
|
|
legacy_snapshot_224000_authenticated: false,
|
|
legacy_snapshot_231500_authenticated: false,
|
|
legacy_snapshot_20260826150000_authenticated: false,
|
|
legacy_snapshot_20260909100400_authenticated: false,
|
|
legacy_reset_20260909100400_authenticated: false,
|
|
})
|
|
})
|
|
|
|
it('denies anon at call time', async () => {
|
|
const client = await getClient()
|
|
try {
|
|
await client.query('BEGIN')
|
|
await client.query('SET LOCAL ROLE anon')
|
|
await expect(
|
|
client.query(
|
|
`SELECT public.reset_company_for_migration($1, 'Test AB', $2, true, true)`,
|
|
[randomUUID(), 'This is a sufficiently long audit reason.'],
|
|
),
|
|
).rejects.toThrow(/permission denied/i)
|
|
} finally {
|
|
await client.query('ROLLBACK').catch(() => {})
|
|
client.release()
|
|
}
|
|
})
|
|
|
|
it('returns not found to outsiders and forbidden to non-owner members', async () => {
|
|
const { companyId } = await seedCompany()
|
|
const outsiderId = await insertAuthUser()
|
|
const adminId = await insertAuthUser()
|
|
await insertCompanyMember({ companyId, userId: adminId, role: 'admin' })
|
|
|
|
await expect(preview(outsiderId, companyId)).resolves.toMatchObject({
|
|
ok: false,
|
|
code: 'COMPANY_RESET_NOT_FOUND',
|
|
})
|
|
await expect(preview(adminId, companyId)).resolves.toMatchObject({
|
|
ok: false,
|
|
code: 'COMPANY_RESET_FORBIDDEN',
|
|
})
|
|
await expect(execute(outsiderId, companyId)).resolves.toMatchObject({
|
|
ok: false,
|
|
code: 'COMPANY_RESET_NOT_FOUND',
|
|
})
|
|
await expect(execute(adminId, companyId)).resolves.toMatchObject({
|
|
ok: false,
|
|
code: 'COMPANY_RESET_FORBIDDEN',
|
|
})
|
|
})
|
|
|
|
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({
|
|
code: 'locked_or_closed_periods',
|
|
count: 1,
|
|
})
|
|
|
|
const filed = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.skatteverket_api_audit_log
|
|
(company_id, user_id, endpoint, outcome, response_status)
|
|
VALUES ($1, $2, 'agi/submit', 'ok', 200)`,
|
|
[filed.companyId, filed.userId],
|
|
)
|
|
const filedPreview = await preview(filed.userId, filed.companyId)
|
|
expect(filedPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'authority_submission_detected',
|
|
count: 1,
|
|
})
|
|
|
|
const vatFiled = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.skatteverket_api_audit_log
|
|
(company_id, user_id, endpoint, outcome, response_status)
|
|
VALUES ($1, $2, 'declaration/submit', 'ok', 200)`,
|
|
[vatFiled.companyId, vatFiled.userId],
|
|
)
|
|
const vatFiledPreview = await preview(vatFiled.userId, vatFiled.companyId)
|
|
expect(vatFiledPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'authority_submission_detected',
|
|
count: 1,
|
|
})
|
|
|
|
const vatDraft = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.extension_data
|
|
(user_id, company_id, extension_id, key, value)
|
|
VALUES ($1, $2, 'skatteverket', 'submission_202606',
|
|
to_jsonb($3::text))`,
|
|
[
|
|
vatDraft.userId,
|
|
vatDraft.companyId,
|
|
JSON.stringify({ status: 'draft_saved', redovisningsperiod: '202606' }),
|
|
],
|
|
)
|
|
const vatDraftPreview = await preview(vatDraft.userId, vatDraft.companyId)
|
|
expect(vatDraftPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'authority_submission_detected',
|
|
count: 1,
|
|
})
|
|
|
|
const agiPendingSignature = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.agi_declarations
|
|
(company_id, user_id, period_year, period_month, xml_content, status,
|
|
individuppgifter)
|
|
VALUES ($1, $2, 2026, 8, '<agd/>', 'pending_signature', '[]'::jsonb)`,
|
|
[agiPendingSignature.companyId, agiPendingSignature.userId],
|
|
)
|
|
await getPool().query(
|
|
`INSERT INTO public.extension_data
|
|
(user_id, company_id, extension_id, key, value)
|
|
VALUES ($1, $2, 'skatteverket', 'agi_submission_2026-08',
|
|
to_jsonb($3::text))`,
|
|
[
|
|
agiPendingSignature.userId,
|
|
agiPendingSignature.companyId,
|
|
JSON.stringify({ status: 'underlag_submitted', period: '2026-08' }),
|
|
],
|
|
)
|
|
const agiPendingPreview = await preview(
|
|
agiPendingSignature.userId,
|
|
agiPendingSignature.companyId,
|
|
)
|
|
expect(agiPendingPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'authority_submission_detected',
|
|
count: 1,
|
|
})
|
|
|
|
const rotRutGenerated = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.rot_rut_payout_requests
|
|
(company_id, user_id, deduction_type, name, status, requested_total,
|
|
file_name)
|
|
VALUES ($1, $2, 'rot', 'TESTBEGARAN', 'generated', 1000,
|
|
'rot-begaran.xml')`,
|
|
[rotRutGenerated.companyId, rotRutGenerated.userId],
|
|
)
|
|
const rotRutGeneratedPreview = await preview(
|
|
rotRutGenerated.userId,
|
|
rotRutGenerated.companyId,
|
|
)
|
|
expect(rotRutGeneratedPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'authority_submission_detected',
|
|
count: 1,
|
|
})
|
|
|
|
const bankConnected = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.bank_connections
|
|
(company_id, user_id, provider, bank_name, status)
|
|
VALUES ($1, $2, 'enable_banking', 'Test Bank', 'active')`,
|
|
[bankConnected.companyId, bankConnected.userId],
|
|
)
|
|
const bankPreview = await preview(bankConnected.userId, bankConnected.companyId)
|
|
expect(bankPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'live_bank_connections',
|
|
count: 1,
|
|
})
|
|
|
|
const importing = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.sie_imports
|
|
(user_id, company_id, filename, file_hash, sie_type, status)
|
|
VALUES ($1, $2, 'pending.se', $3, 4, 'pending')`,
|
|
[importing.userId, importing.companyId, randomUUID()],
|
|
)
|
|
const importingPreview = await preview(importing.userId, importing.companyId)
|
|
expect(importingPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'imports_in_progress',
|
|
count: 1,
|
|
})
|
|
|
|
const automated = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.stripe_connections (company_id, user_id, status)
|
|
VALUES ($1, $2, 'pending')`,
|
|
[automated.companyId, automated.userId],
|
|
)
|
|
const automatedPreview = await preview(automated.userId, automated.companyId)
|
|
expect(automatedPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'active_integrations_or_schedules',
|
|
count: 1,
|
|
})
|
|
|
|
// A pending Zettle connection is an integration too (20260909100400 wraps
|
|
// the snapshot instead of re-issuing its body).
|
|
const pos = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.zettle_connections (company_id, user_id, status)
|
|
VALUES ($1, $2, 'pending')`,
|
|
[pos.companyId, pos.userId],
|
|
)
|
|
const posPreview = await preview(pos.userId, pos.companyId)
|
|
expect(posPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'active_integrations_or_schedules',
|
|
count: 1,
|
|
})
|
|
|
|
const busy = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.operations (company_id, user_id, operation_type, status)
|
|
VALUES ($1, $2, 'imports.sie', 'queued')`,
|
|
[busy.companyId, busy.userId],
|
|
)
|
|
const busyPreview = await preview(busy.userId, busy.companyId)
|
|
expect(busyPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'background_work_in_progress',
|
|
count: 1,
|
|
})
|
|
|
|
const old = await seedCompany()
|
|
await getPool().query(
|
|
`UPDATE public.companies SET created_at = now() - interval '31 days' WHERE id = $1`,
|
|
[old.companyId],
|
|
)
|
|
const oldPreview = await preview(old.userId, old.companyId)
|
|
expect(oldPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'migration_window_expired',
|
|
count: 1,
|
|
})
|
|
|
|
const sandbox = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.company_settings
|
|
(user_id, company_id, entity_type, company_name, is_sandbox)
|
|
VALUES ($1, $2, 'aktiebolag', 'Sandbox AB', true)`,
|
|
[sandbox.userId, sandbox.companyId],
|
|
)
|
|
const sandboxPreview = await preview(sandbox.userId, sandbox.companyId)
|
|
expect(sandboxPreview.eligibility?.blockers).toContainEqual({
|
|
code: 'sandbox_company',
|
|
count: 1,
|
|
})
|
|
})
|
|
|
|
// 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,
|
|
status: 'draft',
|
|
sourceType: 'manual',
|
|
})
|
|
const draftPreview = await preview(draft.userId, draft.companyId)
|
|
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({
|
|
...imported,
|
|
status: 'posted',
|
|
sourceType: 'import',
|
|
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
|
|
(id, user_id, company_id, storage_path, file_name, sha256_hash,
|
|
upload_source, journal_entry_id)
|
|
VALUES ($1, $2, $3, $4, 'posted-underlag.pdf', $5, 'file_upload', $6)`,
|
|
[
|
|
documentId,
|
|
imported.userId,
|
|
imported.companyId,
|
|
`${imported.companyId}/posted-underlag.pdf`,
|
|
'b'.repeat(64),
|
|
importEntryId,
|
|
],
|
|
)
|
|
await getPool().query(
|
|
`INSERT INTO public.voucher_sequences
|
|
(user_id, company_id, fiscal_period_id, voucher_series, last_number)
|
|
VALUES ($1, $2, $3, 'A', 41)`,
|
|
[imported.userId, imported.companyId, imported.fiscalPeriodId],
|
|
)
|
|
|
|
const importedPreview = await preview(imported.userId, imported.companyId)
|
|
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,
|
|
})
|
|
|
|
// 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('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')`,
|
|
[userId, companyId],
|
|
)
|
|
const supplierId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.suppliers (id, user_id, company_id, name)
|
|
VALUES ($1, $2, $3, 'Leverantor AB')`,
|
|
[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')`,
|
|
[userId, companyId, supplierId],
|
|
)
|
|
|
|
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,
|
|
})
|
|
})
|
|
})
|
|
|
|
it('write-closes retained filing and connection evidence after reset', async () => {
|
|
const fixture = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.company_settings (user_id, company_id, company_name)
|
|
VALUES ($1, $2, 'Test AB')`,
|
|
[fixture.userId, fixture.companyId],
|
|
)
|
|
const salaryRunId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.salary_runs
|
|
(id, company_id, user_id, period_year, period_month, payment_date)
|
|
VALUES ($1, $2, $3, 2026, 8, '2026-08-25')`,
|
|
[salaryRunId, fixture.companyId, fixture.userId],
|
|
)
|
|
|
|
await withUserContext(fixture.userId, async (client) => {
|
|
const { rows: resetRows } = await client.query<{ result: RpcResult }>(
|
|
`SELECT public.reset_company_for_migration($1, $2, $3, true, true) AS result`,
|
|
[fixture.companyId, 'Test AB', 'The first migration used the wrong fiscal periods.'],
|
|
)
|
|
const result = resetRows[0]!.result
|
|
expect(result.ok).toBe(true)
|
|
const replacementId = result.replacement_company_id!
|
|
|
|
// Keep the reset and guard probes in one transaction. withUserContext
|
|
// rolls back on return, so probing through a separate pool connection
|
|
// would observe the pre-reset state instead of the retained archive.
|
|
await client.query('SET LOCAL ROLE postgres')
|
|
const resetLink = await client.query<{ n: number }>(
|
|
`SELECT count(*)::int AS n
|
|
FROM public.company_migration_resets
|
|
WHERE source_company_id = $1 AND replacement_company_id = $2`,
|
|
[fixture.companyId, replacementId],
|
|
)
|
|
expect(resetLink.rows[0]!.n).toBe(1)
|
|
|
|
await client.query('SAVEPOINT immutable_salary_run')
|
|
await expect(
|
|
client.query(`UPDATE public.salary_runs SET status = 'review' WHERE id = $1`, [
|
|
salaryRunId,
|
|
]),
|
|
).rejects.toThrow(/source records are immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_salary_run')
|
|
|
|
await client.query('SAVEPOINT immutable_company_settings')
|
|
await expect(
|
|
client.query(
|
|
`UPDATE public.company_settings SET company_name = 'Changed'
|
|
WHERE company_id = $1`,
|
|
[fixture.companyId],
|
|
),
|
|
).rejects.toThrow(/source records are immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_company_settings')
|
|
|
|
await client.query('SAVEPOINT immutable_source_company')
|
|
await expect(
|
|
client.query(`UPDATE public.companies SET name = 'Changed' WHERE id = $1`, [
|
|
fixture.companyId,
|
|
]),
|
|
).rejects.toThrow(/source company is immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_source_company')
|
|
|
|
const replacementSalaryRunId = randomUUID()
|
|
await client.query(
|
|
`INSERT INTO public.salary_runs
|
|
(id, company_id, user_id, period_year, period_month, payment_date)
|
|
VALUES ($1, $2, $3, 2026, 9, '2026-09-25')`,
|
|
[replacementSalaryRunId, replacementId, fixture.userId],
|
|
)
|
|
await client.query('SAVEPOINT reject_move_into_source')
|
|
await expect(
|
|
client.query(`UPDATE public.salary_runs SET company_id = $1 WHERE id = $2`, [
|
|
fixture.companyId,
|
|
replacementSalaryRunId,
|
|
]),
|
|
).rejects.toThrow(/source records are immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT reject_move_into_source')
|
|
|
|
await client.query('SAVEPOINT reject_source_authority_audit')
|
|
await expect(
|
|
client.query(
|
|
`INSERT INTO public.skatteverket_api_audit_log
|
|
(company_id, user_id, endpoint, outcome, response_status)
|
|
VALUES ($1, $2, 'declaration/validate', 'ok', 200)`,
|
|
[fixture.companyId, fixture.userId],
|
|
),
|
|
).rejects.toThrow(/source records are immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT reject_source_authority_audit')
|
|
|
|
await client.query('SAVEPOINT reject_source_vat_state')
|
|
await expect(
|
|
client.query(
|
|
`INSERT INTO public.extension_data
|
|
(user_id, company_id, extension_id, key, value)
|
|
VALUES ($1, $2, 'skatteverket', 'submission_202606',
|
|
to_jsonb($3::text))`,
|
|
[
|
|
fixture.userId,
|
|
fixture.companyId,
|
|
JSON.stringify({ status: 'draft_locked', redovisningsperiod: '202606' }),
|
|
],
|
|
),
|
|
).rejects.toThrow(/source authority workflow state is immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT reject_source_vat_state')
|
|
|
|
await client.query('SAVEPOINT reject_source_agi_state')
|
|
await expect(
|
|
client.query(
|
|
`INSERT INTO public.extension_data
|
|
(user_id, company_id, extension_id, key, value)
|
|
VALUES ($1, $2, 'skatteverket', 'agi_submission_2026-08',
|
|
to_jsonb($3::text))`,
|
|
[
|
|
fixture.userId,
|
|
fixture.companyId,
|
|
JSON.stringify({ status: 'underlag_submitted', period: '2026-08' }),
|
|
],
|
|
),
|
|
).rejects.toThrow(/source authority workflow state is immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT reject_source_agi_state')
|
|
|
|
const guardedTables = [
|
|
'agi_declarations',
|
|
'arsredovisning_submissions',
|
|
'bank_connections',
|
|
'company_settings',
|
|
'rot_rut_payout_requests',
|
|
'salary_line_items',
|
|
'salary_run_employees',
|
|
'salary_runs',
|
|
'skatteverket_api_audit_log',
|
|
]
|
|
const { rows } = await client.query<{ table_name: string }>(
|
|
`SELECT c.relname AS table_name
|
|
FROM pg_trigger t
|
|
JOIN pg_class c ON c.oid = t.tgrelid
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = 'public'
|
|
AND NOT t.tgisinternal
|
|
AND t.tgname = left(
|
|
c.relname || '_block_migration_reset_source_mutation',
|
|
63
|
|
)
|
|
AND c.relname = ANY($1::text[])
|
|
ORDER BY c.relname`,
|
|
[guardedTables],
|
|
)
|
|
expect(rows.map((row) => row.table_name)).toEqual(guardedTables)
|
|
})
|
|
})
|
|
|
|
it('does not archive or create anything when execution is ineligible', async () => {
|
|
const fixture = await seedCompany({ isClosed: true })
|
|
const before = await getPool().query<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM public.companies WHERE created_by = $1`,
|
|
[fixture.userId],
|
|
)
|
|
|
|
const result = await execute(fixture.userId, fixture.companyId)
|
|
expect(result).toMatchObject({ ok: false, code: 'COMPANY_RESET_INELIGIBLE' })
|
|
|
|
const source = await getPool().query<{ archived_at: string | null }>(
|
|
`SELECT archived_at FROM public.companies WHERE id = $1`,
|
|
[fixture.companyId],
|
|
)
|
|
const after = await getPool().query<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM public.companies WHERE created_by = $1`,
|
|
[fixture.userId],
|
|
)
|
|
const resets = await getPool().query<{ n: number }>(
|
|
`SELECT count(*)::int AS n
|
|
FROM public.company_migration_resets
|
|
WHERE source_company_id = $1`,
|
|
[fixture.companyId],
|
|
)
|
|
|
|
expect(source.rows[0]!.archived_at).toBeNull()
|
|
expect(after.rows[0]!.n).toBe(before.rows[0]!.n)
|
|
expect(resets.rows[0]!.n).toBe(0)
|
|
})
|
|
|
|
it('rolls back the archive and replacement when an audit write fails', async () => {
|
|
const fixture = await seedCompany()
|
|
const before = await getPool().query<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM public.companies WHERE created_by = $1`,
|
|
[fixture.userId],
|
|
)
|
|
|
|
await getPool().query(`
|
|
CREATE OR REPLACE FUNCTION public.pg_test_fail_company_reset_audit()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $function$
|
|
BEGIN
|
|
RAISE EXCEPTION 'forced reset audit failure';
|
|
END;
|
|
$function$;
|
|
|
|
DROP TRIGGER IF EXISTS pg_test_fail_company_reset_audit
|
|
ON public.company_migration_resets;
|
|
CREATE TRIGGER pg_test_fail_company_reset_audit
|
|
BEFORE INSERT ON public.company_migration_resets
|
|
FOR EACH ROW EXECUTE FUNCTION public.pg_test_fail_company_reset_audit();
|
|
`)
|
|
|
|
try {
|
|
await expect(execute(fixture.userId, fixture.companyId)).rejects.toThrow(
|
|
/forced reset audit failure/i,
|
|
)
|
|
|
|
const source = await getPool().query<{ archived_at: string | null }>(
|
|
`SELECT archived_at FROM public.companies WHERE id = $1`,
|
|
[fixture.companyId],
|
|
)
|
|
const after = await getPool().query<{ n: number }>(
|
|
`SELECT count(*)::int AS n FROM public.companies WHERE created_by = $1`,
|
|
[fixture.userId],
|
|
)
|
|
const resets = await getPool().query<{ n: number }>(
|
|
`SELECT count(*)::int AS n
|
|
FROM public.company_migration_resets
|
|
WHERE source_company_id = $1`,
|
|
[fixture.companyId],
|
|
)
|
|
|
|
expect(source.rows[0]!.archived_at).toBeNull()
|
|
expect(after.rows[0]!.n).toBe(before.rows[0]!.n)
|
|
expect(resets.rows[0]!.n).toBe(0)
|
|
} finally {
|
|
await getPool().query(`
|
|
DROP TRIGGER IF EXISTS pg_test_fail_company_reset_audit
|
|
ON public.company_migration_resets;
|
|
DROP FUNCTION IF EXISTS public.pg_test_fail_company_reset_audit();
|
|
`)
|
|
}
|
|
})
|
|
|
|
it('requires the exact display name, audit reason, and both attestations', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.company_settings
|
|
(user_id, company_id, entity_type, company_name, org_number, onboarding_complete)
|
|
VALUES ($1, $2, 'aktiebolag', 'Visat Namn AB', '5590000000', true)`,
|
|
[userId, companyId],
|
|
)
|
|
|
|
await expect(execute(userId, companyId)).resolves.toMatchObject({
|
|
ok: false,
|
|
code: 'COMPANY_RESET_CONFIRMATION_MISMATCH',
|
|
})
|
|
await expect(execute(userId, companyId, {
|
|
name: 'Visat Namn AB',
|
|
reason: 'Too short',
|
|
})).resolves.toMatchObject({ ok: false, code: 'COMPANY_RESET_REASON_INVALID' })
|
|
await expect(execute(userId, companyId, {
|
|
name: 'Visat Namn AB',
|
|
noFilings: false,
|
|
})).resolves.toMatchObject({ ok: false, code: 'COMPANY_RESET_CONFIRMATION_REQUIRED' })
|
|
await expect(execute(userId, companyId, {
|
|
name: 'Visat Namn AB',
|
|
retainedArchive: false,
|
|
})).resolves.toMatchObject({ ok: false, code: 'COMPANY_RESET_CONFIRMATION_REQUIRED' })
|
|
})
|
|
|
|
it('atomically archives and replaces while retaining every source record unchanged', async () => {
|
|
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
|
const memberId = await insertAuthUser()
|
|
const lateMemberId = await insertAuthUser()
|
|
const lateTeamMemberId = await insertAuthUser()
|
|
const teamId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.teams (id, name, created_by) VALUES ($1, 'Test team', $2)`,
|
|
[teamId, userId],
|
|
)
|
|
await getPool().query(
|
|
`INSERT INTO public.team_members (team_id, user_id, role)
|
|
VALUES ($1, $2, 'owner')`,
|
|
[teamId, userId],
|
|
)
|
|
await getPool().query(`UPDATE public.companies SET team_id = $1 WHERE id = $2`, [
|
|
teamId,
|
|
companyId,
|
|
])
|
|
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
|
|
await getPool().query(
|
|
`UPDATE public.company_members SET source = 'team'
|
|
WHERE company_id = $1 AND user_id = $2`,
|
|
[companyId, memberId],
|
|
)
|
|
await getPool().query(
|
|
`INSERT INTO public.company_settings
|
|
(user_id, company_id, entity_type, company_name, org_number,
|
|
onboarding_complete, next_invoice_number, next_arrival_number)
|
|
VALUES ($1, $2, 'aktiebolag', 'Migration AB', '5590000001', true, 41, 17)`,
|
|
[userId, companyId],
|
|
)
|
|
// The replacement is the same legal entity. Values above 1 pin that the
|
|
// invoice and arrival-number series continue rather than silently restart.
|
|
const sieImportId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.sie_imports
|
|
(id, user_id, company_id, filename, file_hash, sie_type, status)
|
|
VALUES ($1, $2, $3, 'migration.se', $4, 4, 'completed')`,
|
|
[sieImportId, userId, companyId, randomUUID()],
|
|
)
|
|
|
|
const transactionId = await insertTransaction({ companyId, userId })
|
|
const documentId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.document_attachments
|
|
(id, user_id, company_id, storage_path, file_name, sha256_hash,
|
|
upload_source)
|
|
VALUES ($1, $2, $3, $4, 'underlag.pdf', $5, 'file_upload')`,
|
|
[
|
|
documentId,
|
|
userId,
|
|
companyId,
|
|
`${companyId}/underlag.pdf`,
|
|
'a'.repeat(64),
|
|
],
|
|
)
|
|
const consentId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.provider_consents (id, company_id, name, status, provider)
|
|
VALUES ($1, $2, 'migration-source', 1, 'fortnox')`,
|
|
[consentId, companyId],
|
|
)
|
|
const inviteId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.company_invitations
|
|
(id, company_id, email, role, token_hash, invited_by, status, expires_at)
|
|
VALUES ($1, $2, 'invitee@example.com', 'member', $3, $4, 'pending', now() + interval '1 day')`,
|
|
[inviteId, companyId, randomUUID(), userId],
|
|
)
|
|
await getPool().query(
|
|
`INSERT INTO public.user_preferences (user_id, active_company_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
|
|
[userId, companyId],
|
|
)
|
|
|
|
const originalTrial = await getPool().query<{ capability_key: string; expires_at: string }>(
|
|
`SELECT capability_key, expires_at::text
|
|
FROM public.capability_grants
|
|
WHERE company_id = $1 AND source = 'trial'
|
|
ORDER BY capability_key`,
|
|
[companyId],
|
|
)
|
|
|
|
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, 'Migration AB', 'The imported fiscal periods were mapped incorrectly.'],
|
|
)
|
|
const result = rows[0]!.result
|
|
expect(result.ok).toBe(true)
|
|
expect(result.source_company_id).toBe(companyId)
|
|
expect(result.replacement_company_id).toBeTruthy()
|
|
|
|
const replacementId = result.replacement_company_id!
|
|
await client.query('RESET ROLE')
|
|
|
|
const source = await client.query<{ archived_at: string | null; archived_by: string | null }>(
|
|
`SELECT archived_at::text, archived_by::text FROM public.companies WHERE id = $1`,
|
|
[companyId],
|
|
)
|
|
expect(source.rows[0]!.archived_at).not.toBeNull()
|
|
expect(source.rows[0]!.archived_by).toBe(userId)
|
|
|
|
const retained = await client.query<{
|
|
journal_entries: number
|
|
journal_entry_lines: number
|
|
transactions: number
|
|
documents: number
|
|
periods: number
|
|
sequences: number
|
|
sie_imports: number
|
|
}>(
|
|
`SELECT
|
|
(SELECT count(*)::int FROM public.journal_entries WHERE company_id = $1) AS journal_entries,
|
|
(SELECT count(*)::int FROM public.journal_entry_lines line
|
|
JOIN public.journal_entries entry ON entry.id = line.journal_entry_id
|
|
WHERE entry.company_id = $1) AS journal_entry_lines,
|
|
(SELECT count(*)::int FROM public.transactions WHERE company_id = $1) AS transactions,
|
|
(SELECT count(*)::int FROM public.document_attachments WHERE company_id = $1) AS documents,
|
|
(SELECT count(*)::int FROM public.fiscal_periods WHERE company_id = $1) AS periods,
|
|
(SELECT count(*)::int FROM public.voucher_sequences WHERE company_id = $1) AS sequences,
|
|
(SELECT count(*)::int FROM public.sie_imports WHERE company_id = $1) AS sie_imports`,
|
|
[companyId],
|
|
)
|
|
expect(retained.rows[0]).toEqual({
|
|
journal_entries: 0,
|
|
journal_entry_lines: 0,
|
|
transactions: 1,
|
|
documents: 1,
|
|
periods: 1,
|
|
sequences: 0,
|
|
sie_imports: 1,
|
|
})
|
|
|
|
const exactRows = await client.query<{
|
|
transaction_exists: boolean
|
|
document_exists: boolean
|
|
sie_import_exists: boolean
|
|
}>(
|
|
`SELECT
|
|
EXISTS (SELECT 1 FROM public.transactions WHERE id = $1) AS transaction_exists,
|
|
EXISTS (SELECT 1 FROM public.document_attachments WHERE id = $2) AS document_exists,
|
|
EXISTS (SELECT 1 FROM public.sie_imports WHERE id = $3) AS sie_import_exists`,
|
|
[transactionId, documentId, sieImportId],
|
|
)
|
|
expect(exactRows.rows[0]).toEqual({
|
|
transaction_exists: true,
|
|
document_exists: true,
|
|
sie_import_exists: true,
|
|
})
|
|
|
|
const replacement = await client.query<{
|
|
journal_entries: number
|
|
transactions: number
|
|
documents: number
|
|
periods: number
|
|
sequences: number
|
|
sie_imports: number
|
|
chart_accounts: number
|
|
cash_accounts: number
|
|
}>(
|
|
`SELECT
|
|
(SELECT count(*)::int FROM public.journal_entries WHERE company_id = $1) AS journal_entries,
|
|
(SELECT count(*)::int FROM public.transactions WHERE company_id = $1) AS transactions,
|
|
(SELECT count(*)::int FROM public.document_attachments WHERE company_id = $1) AS documents,
|
|
(SELECT count(*)::int FROM public.fiscal_periods WHERE company_id = $1) AS periods,
|
|
(SELECT count(*)::int FROM public.voucher_sequences WHERE company_id = $1) AS sequences,
|
|
(SELECT count(*)::int FROM public.sie_imports WHERE company_id = $1) AS sie_imports,
|
|
(SELECT count(*)::int FROM public.chart_of_accounts WHERE company_id = $1) AS chart_accounts,
|
|
(SELECT count(*)::int FROM public.cash_accounts WHERE company_id = $1) AS cash_accounts`,
|
|
[replacementId],
|
|
)
|
|
expect(replacement.rows[0]).toMatchObject({
|
|
journal_entries: 0,
|
|
transactions: 0,
|
|
documents: 0,
|
|
periods: 0,
|
|
sequences: 0,
|
|
sie_imports: 0,
|
|
})
|
|
expect(replacement.rows[0]!.chart_accounts).toBeGreaterThan(0)
|
|
expect(replacement.rows[0]!.cash_accounts).toBe(1)
|
|
|
|
const operational = await client.query<{
|
|
provider_company_id: string
|
|
invitation_company_id: string
|
|
active_company_id: string
|
|
member_count: number
|
|
team_member_count: number
|
|
source_active_inboxes: number
|
|
replacement_active_inboxes: number
|
|
next_invoice_number: number
|
|
next_arrival_number: number
|
|
onboarding_complete: boolean
|
|
}>(
|
|
`SELECT
|
|
(SELECT company_id::text FROM public.provider_consents WHERE id = $2) AS provider_company_id,
|
|
(SELECT company_id::text FROM public.company_invitations WHERE id = $5) AS invitation_company_id,
|
|
(SELECT active_company_id::text FROM public.user_preferences WHERE user_id = $3) AS active_company_id,
|
|
(SELECT count(*)::int FROM public.company_members WHERE company_id = $1) AS member_count,
|
|
(SELECT count(*)::int FROM public.company_members
|
|
WHERE company_id = $1 AND source = 'team') AS team_member_count,
|
|
(SELECT count(*)::int FROM public.company_inboxes WHERE company_id = $4 AND status = 'active') AS source_active_inboxes,
|
|
(SELECT count(*)::int FROM public.company_inboxes WHERE company_id = $1 AND status = 'active') AS replacement_active_inboxes,
|
|
(SELECT next_invoice_number::int FROM public.company_settings WHERE company_id = $1) AS next_invoice_number,
|
|
(SELECT next_arrival_number::int FROM public.company_settings WHERE company_id = $1) AS next_arrival_number,
|
|
(SELECT onboarding_complete FROM public.company_settings WHERE company_id = $1) AS onboarding_complete`,
|
|
[replacementId, consentId, userId, companyId, inviteId],
|
|
)
|
|
expect(operational.rows[0]).toEqual({
|
|
provider_company_id: replacementId,
|
|
invitation_company_id: replacementId,
|
|
active_company_id: replacementId,
|
|
member_count: 2,
|
|
team_member_count: 1,
|
|
source_active_inboxes: 0,
|
|
replacement_active_inboxes: 1,
|
|
next_invoice_number: 41,
|
|
next_arrival_number: 17,
|
|
onboarding_complete: false,
|
|
})
|
|
|
|
const replacementTrial = await client.query<{ capability_key: string; expires_at: string }>(
|
|
`SELECT capability_key, expires_at::text
|
|
FROM public.capability_grants
|
|
WHERE company_id = $1 AND source = 'trial'
|
|
ORDER BY capability_key`,
|
|
[replacementId],
|
|
)
|
|
expect(replacementTrial.rows).toEqual(originalTrial.rows)
|
|
|
|
const resetAudit = await client.query<{
|
|
n: number
|
|
source_documents: number
|
|
source_journal_lines: number
|
|
source_sie_imports: number
|
|
}>(
|
|
`SELECT count(*)::int AS n,
|
|
max((source_counts ->> 'documents')::int)::int AS source_documents,
|
|
max((source_counts ->> 'journal_entry_lines')::int)::int AS source_journal_lines,
|
|
max((source_counts ->> 'sie_imports')::int)::int AS source_sie_imports
|
|
FROM public.company_migration_resets
|
|
WHERE id = $1
|
|
AND source_company_id = $2
|
|
AND replacement_company_id = $3`,
|
|
[result.reset_id, companyId, replacementId],
|
|
)
|
|
expect(resetAudit.rows[0]).toEqual({
|
|
n: 1,
|
|
source_documents: 1,
|
|
source_journal_lines: 0,
|
|
source_sie_imports: 1,
|
|
})
|
|
|
|
const auditLog = await client.query<{ n: number }>(
|
|
`SELECT count(*)::int AS n
|
|
FROM public.audit_log
|
|
WHERE record_id IN ($1, $2)
|
|
AND new_state ->> 'migration_reset_id' = $3`,
|
|
[companyId, replacementId, result.reset_id],
|
|
)
|
|
expect(auditLog.rows[0]!.n).toBe(2)
|
|
|
|
await client.query(
|
|
`INSERT INTO public.team_members (team_id, user_id, role)
|
|
VALUES ($1, $2, 'member')`,
|
|
[teamId, lateTeamMemberId],
|
|
)
|
|
const lateTeamMembership = await client.query<{
|
|
source_memberships: number
|
|
replacement_memberships: number
|
|
}>(
|
|
`SELECT
|
|
(SELECT count(*)::int FROM public.company_members
|
|
WHERE company_id = $1 AND user_id = $3) AS source_memberships,
|
|
(SELECT count(*)::int FROM public.company_members
|
|
WHERE company_id = $2 AND user_id = $3) AS replacement_memberships`,
|
|
[companyId, replacementId, lateTeamMemberId],
|
|
)
|
|
expect(lateTeamMembership.rows[0]).toEqual({
|
|
source_memberships: 0,
|
|
replacement_memberships: 1,
|
|
})
|
|
|
|
await client.query('SAVEPOINT immutable_audit')
|
|
await expect(
|
|
client.query(
|
|
`UPDATE public.company_migration_resets SET reason = 'Changed after the fact' WHERE id = $1`,
|
|
[result.reset_id],
|
|
),
|
|
).rejects.toThrow(/cannot be modified or deleted/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_audit')
|
|
|
|
await client.query('SAVEPOINT immutable_audit_delete')
|
|
await expect(
|
|
client.query(`DELETE FROM public.company_migration_resets WHERE id = $1`, [result.reset_id]),
|
|
).rejects.toThrow(/cannot be modified or deleted/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_audit_delete')
|
|
|
|
await client.query('SAVEPOINT immutable_audit_truncate')
|
|
await expect(
|
|
client.query(`TRUNCATE public.company_migration_resets`),
|
|
).rejects.toThrow(/cannot be modified or deleted/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_audit_truncate')
|
|
|
|
await client.query('SAVEPOINT archived_source_membership')
|
|
await expect(
|
|
client.query(
|
|
`INSERT INTO public.company_members (company_id, user_id, role)
|
|
VALUES ($1, $2, 'member')`,
|
|
[companyId, lateMemberId],
|
|
),
|
|
).rejects.toThrow(/archived migration reset source/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT archived_source_membership')
|
|
|
|
await client.query('SAVEPOINT immutable_source_transaction')
|
|
await expect(
|
|
client.query(`UPDATE public.transactions SET description = 'Changed' WHERE id = $1`, [
|
|
transactionId,
|
|
]),
|
|
).rejects.toThrow(/source records are immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_source_transaction')
|
|
|
|
await client.query('SAVEPOINT immutable_source_sequence')
|
|
await expect(
|
|
client.query(
|
|
`INSERT INTO public.voucher_sequences
|
|
(user_id, company_id, fiscal_period_id, voucher_series, last_number)
|
|
VALUES ($1, $2, $3, 'A', 1)`,
|
|
[userId, companyId, fiscalPeriodId],
|
|
),
|
|
).rejects.toThrow(/source records are immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_source_sequence')
|
|
|
|
await client.query('SAVEPOINT immutable_source_archive')
|
|
await expect(
|
|
client.query(`UPDATE public.companies SET archived_at = NULL WHERE id = $1`, [companyId]),
|
|
).rejects.toThrow(/archive markers are immutable/i)
|
|
await client.query('ROLLBACK TO SAVEPOINT immutable_source_archive')
|
|
})
|
|
})
|
|
})
|