* 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>
14 KiB
Company migration reset support runbook
This runbook covers the owner-only self-service flow for redoing a failed company migration. The operation is deliberately an archive-and-replace reset. It is not a deletion or an in-place rewrite.
Legal and product boundary
Swedish Bookkeeping Act (1999:1078) 1 kap. 2 § defines räkenskapsinformation broadly. It includes journal and ledger information, vouchers, supporting systems information, important agreements, and other information needed to understand the postings. Chapter 7 requires electronic accounting information to remain durable, accessible, and preserved through the seventh year after the relevant calendar year. BFNAR 2013:2 also requires treatment history to show added postings and system changes that affect how accounting information is processed.
Authoritative references:
- Bokföringslag (1999:1078), especially 1 kap. 2 § and 7 kap. 1-2 §§
- BFNAR 2013:2 Bokföring, especially points 2.17 and 9.16
Accounted cannot reliably prove that every provider-imported customer, invoice, transaction, or document is disposable test data. Some provider imports do not carry row-level provenance, and Accounted cannot observe every filing made directly at Skatteverket or Bolagsverket. Therefore this feature never deletes or rewrites source data.
The self-service boundary is intentionally narrow:
- The company must be active and no more than 30 days old.
- Sandbox companies are excluded because they have a separate disposable-data cleanup lifecycle and must not become retained legal archives.
- 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.
- 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.
- No commerce, Stripe, or Skatteverket connection, recurring invoice schedule, or pending accrual installment may still be able to write in the background.
- No import, OCR, API operation, invoice delivery, payment sync, or messaging worker may still be queued or processing for the company.
- No AGI upload, VAT declaration draft, lock or submission, ROT/RUT request, or production annual report submission may exist. Persisted VAT and AGI workflow state blocks even when an older direct action has no audit row. Every generated ROT/RUT payout file blocks because upload and signing happen outside Accounted before the local request can be marked submitted.
- The owner must separately attest that nothing was filed outside Accounted. Accounted cannot verify that external fact. If the owner is unsure, support must stop the reset and escalate instead of interpreting silence as consent.
- The owner must acknowledge that the source copy is retained, provide an audit reason, and type the exact displayed company name.
If any condition fails, self-service returns COMPANY_RESET_INELIGIBLE and
makes no change. Support must not override the result with deletion SQL.
What the reset does
reset_company_for_migration executes in one database transaction:
- It proves the caller is an owner before taking tenant-wide locks, then locks the source and repeats authorization, confirmation, and eligibility checks.
- It archives the source company by setting
archived_atandarchived_by. - It creates a replacement company with the same legal identity, team, owner and member roles, company settings, and invoice-number counters. Setup state and the company bookkeeping lock date are cleared on the replacement.
- It seeds a fresh chart of accounts and primary
1930cash account. - It transfers provider migration consents, subscription state, and existing capability grants. The automatically created fresh trial is removed before the original grants move, so resetting cannot extend a trial.
- It transfers the active inbound email address and any custom inbound domain to the replacement, so future documents are routed to the active company.
- It transfers unexpired pending member invitations. Accepted, revoked, and expired invitation history stays on the source.
- It switches active-company preferences to the replacement.
- It writes an immutable
company_migration_resetsaudit record and two append-onlyaudit_logrecords. - Database guards make the retained source's accounting and import rows write-closed, including bank connections, payroll and AGI records, annual report submissions, ROT/RUT requests, authority audit rows, and VAT or AGI workflow rows from requests that were already waiting on external signing.
The transaction does not disable a trigger. It does not delete, detach, renumber, recalculate, copy, or mutate any source bookkeeping record.
The following stay on the archived source company unchanged:
- SIE, bank-file, and tax-account-file import records
- bank transactions and expired or revoked bank connections
- 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
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
new migration creates the applicable periods and its first sequence state.
Provider migration consent is transferred so the owner can start again from
the import workspace. Bank connections are not transferred. Pending or active
connections block the reset and must be disconnected first. Reconnect
deliberately after the replacement migration is verified. The active inbound
email address and custom inbound domain move to the replacement; already
received documents do not move.
Support checks
Start with the request ID from the API response or browser network panel. The
routes log operations company.migration-reset.preview,
company.migration-reset.execute, and company.migration-reset.archive.
Use read-only checks only. Do not run a reset RPC on behalf of a customer, do
not run scripts/clear-user-data.sql, and do not disable retention or journal
enforcement triggers.
The retained source is hidden from normal company selection. A current owner of the active replacement can use Settings > Company > Previous migration > Download archive. That owner-only route verifies the immutable reset link, current replacement ownership, and the source archive marker before using the read-only archive exporter. It deliberately does not rely on retained-source membership, which can change through team removal or account anonymization. It never makes the source active. If documents push the direct ZIP over the response limit, the owner can download the structured data without documents and support must provide the complete document package through an approved read-only export path. Never unarchive the source merely to 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 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
error for a separate, documented compliance review.
After deployment, the audit chain can be inspected read-only with:
select
id,
source_company_id,
replacement_company_id,
actor_id,
reason,
confirmation_snapshot,
source_counts,
created_at
from public.company_migration_resets
where source_company_id = '<source-company-id>'
or replacement_company_id = '<replacement-company-id>';
Confirm the source was retained and the replacement is active:
select id, name, org_number, archived_at, archived_by, created_at
from public.companies
where id in ('<source-company-id>', '<replacement-company-id>');
Compare source record counts without selecting personal or accounting content:
select
(select count(*) from public.journal_entries where company_id = '<source-company-id>') as journal_entries,
(select count(*)
from public.journal_entry_lines line
join public.journal_entries entry on entry.id = line.journal_entry_id
where entry.company_id = '<source-company-id>') as journal_entry_lines,
(select count(*) from public.transactions where company_id = '<source-company-id>') as transactions,
(select count(*) from public.document_attachments where company_id = '<source-company-id>') as documents,
(select count(*) from public.fiscal_periods where company_id = '<source-company-id>') as fiscal_periods,
(select count(*) from public.voucher_sequences where company_id = '<source-company-id>') as voucher_sequences;
The counts should match company_migration_resets.source_counts. A mismatch is
an incident requiring investigation. Do not repair it by editing the source.
Common blocker interpretation
migration_window_expired: the company is older than the self-service recovery window. Escalate for a case-specific legal and accounting review.sandbox_company: use the existing sandbox cleanup lifecycle. Do not convert 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, 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 believes it was a test without first establishing the authority environment and legal status. Remove only a local VAT draft that never left Accounted, and only through the product flow; never clear extension data, payout requests, or audit rows manually. An AGI upload, generated payout file, locked declaration, or possibly signed declaration requires legal escalation, not cleanup.live_bank_connections: disconnect every pending or active bank connection first. Do not bypass the blocker because the sync cron can import without an interactive company session.imports_in_progress: wait until every import completes or fails. Never change an import status manually to bypass this concurrency guard.active_integrations_or_schedules: disable the reported integrations and automatic schedules first. Team-level grants and service credentials can otherwise keep background writers active after the session switches company.background_work_in_progress: wait for every queued or processing import, OCR, API, delivery, payment, or messaging job to reach a terminal state.COMPANY_RESET_CONFIRMATION_MISMATCH: confirm the user typed the displayed name fromcompany_settings.company_name, not a stale internal name.
Deployment and rollback
The migration files are
supabase/migrations/20260818084050_company_migration_reset.sql and
supabase/migrations/20260818141018_harden_company_migration_reset_eligibility.sql
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 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.
Before production rollout:
- Run the pg-real suite against an approved disposable test database.
- Reconcile every remote migration version with the repository.
- Verify anon has no execute privilege and authenticated has execute only on the preview and execution RPCs. The archive route uses the audit table's replacement-membership RLS policy and repeats owner checks with its service-role export client.
- Exercise the flow with synthetic data in an approved non-production environment, including every blocker and a forced transaction failure.
- Confirm monitoring captures the request ID and structured error code without logging the typed company name or reason.
Application rollback can hide the UI and route, but it must not drop the audit table or delete reset history. Database rollback is additive only: revoke new RPC execution if necessary and ship a new migration. Never modify or remove the applied migration file.
Escalation boundary
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, 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.