feat: add safe owner-only migration reset (#1682)

* feat: add safe company migration reset

* fix: harden company reset eligibility

* fix: close company reset compliance gaps

* test: fix migration reset pg-real probes

* fix: preserve migration archive access

* docs: explain migration numbering continuity

* fix: block reset with VAT workflow state

* fix: block externally staged reset data

* fix: address migration reset review findings

* fix: clear stale migration archive estimate

* fix: retry migration archive estimates
This commit is contained in:
Mattsson
2026-08-19 12:04:24 +02:00
committed by GitHub
parent b07a4a4bca
commit 3a1b842e4a
24 changed files with 5017 additions and 57 deletions
+113
View File
@@ -96,3 +96,116 @@ The contract is pinned by pg-real tests (run with `npm run test:pg`):
Any change to either function's signature, gate, or grants must update these
tests and this document in the same change.
## Company migration reset: `get_company_migration_reset_eligibility` and `reset_company_for_migration`
Defined in:
- `supabase/migrations/20260818084050_company_migration_reset.sql`
- `supabase/migrations/20260818141018_harden_company_migration_reset_eligibility.sql`
- `supabase/migrations/20260818143004_close_migration_reset_archive_gaps.sql`
- `supabase/migrations/20260818224000_block_vat_state_migration_reset.sql`
- `supabase/migrations/20260818231500_block_external_filing_staging_state.sql`
These functions support the owner-only archive-and-replace recovery flow for a
failed migration. The execution function archives the source company and
creates a clean replacement. It does not delete or rewrite source accounting
records and never sets a retention-trigger bypass.
### Why SECURITY DEFINER
The operation must atomically create a company, copy memberships and settings,
move operational provider consent and subscription state, preserve the
original entitlement expiry, move inbound document routing, switch active
preferences and pending invitations, and insert immutable audit records.
Authenticated callers do not have direct write policies for all of those
tables. SECURITY DEFINER makes the single transaction possible while the
in-function gate below keeps it tenant-scoped.
The internal `company_migration_reset_snapshot` function is also SECURITY
DEFINER so both preview and execution use one fail-closed eligibility
implementation. It has no EXECUTE grant for authenticated callers and is only
reached through the two guarded entry points.
The source-mutation trigger functions are SECURITY DEFINER only so their audit
lookup cannot be hidden by RLS from an invitation acceptor or a delayed
request. They accept no caller-controlled identifiers, expose no rows, and can
only return the row unchanged or raise a generic exception.
### Actor and tenant gate
Neither entry point accepts an actor parameter. The actor is always
`auth.uid()`, so a cookie-session caller cannot assert another user's identity
and a service-role call with no user identity cannot pass the gate.
The actor must have a `company_members.role = 'owner'` row for the requested
company. A non-member receives `COMPANY_RESET_NOT_FOUND`; a member with any
other role receives `COMPANY_RESET_FORBIDDEN`. The HTTP route also requires the
URL company ID to equal the active company resolved by `withRouteContext`.
Execution locks the active source company row and repeats the exact-name,
reason, attestation, and eligibility checks inside the transaction. Any
failure returns a structured result before the first write. Unexpected
database failures roll the transaction back.
### Eligibility and retention contract
Self-service is restricted to active companies created within 30 days. It is
blocked by the company lock date, a closed or locked period, any journal entry
in any status or source, any voucher-sequence row, any customer or supplier
invoice, an incomplete import, a known authority submission, or persisted VAT
declaration workflow state. The VAT state closes the historical direct-lock
path where the signing lock was stored in `extension_data` without a matching
audit row. This prevents a replacement for the same legal
entity from restarting voucher numbering after a draft, migrated voucher, or
sequence state already exists. Live integrations, bank connections, recurring
invoice schedules, pending accrual installments, and non-terminal background
jobs also block because they can write after the interactive session moves.
The owner must attest that no filing was made outside Accounted and acknowledge
that the source remains retained. Accounted cannot independently observe every
filing made directly at an authority, so uncertainty must fail closed and be
escalated rather than inferred from an empty internal audit log.
The source company's imports, transactions, periods, documents, journal
entries, and voucher sequences are not mutated. The replacement starts with no
such rows. An append-only `company_migration_resets` row captures the reason,
confirmations, and source counts, and ordinary immutable `audit_log` rows link
the source and replacement company IDs. The active inbound email address and
custom inbound domain move to the replacement; already received documents do
not move. A database trigger also rejects new memberships on the archived
source, closing the race with an invitation acceptance that began before the
reset transaction. Database mutation guards make the retained source's
imports, transactions, periods, documents, journal rows, invoices, and voucher
sequences write-closed after the audit row is committed. Filing-adjacent payroll,
AGI, annual report, ROT/RUT, bank-connection, authority-audit, and newly arriving
VAT workflow rows receive the same archive guard. Team membership sync
selects active companies only, so an archived source cannot block a later team
member from reaching the replacement.
### Grants
The migration explicitly revokes EXECUTE from PUBLIC and anon, grants the two
entry points only to authenticated, and revokes authenticated access to the
internal snapshot. The audit table grants authenticated SELECT only through an
RLS policy based on active membership of the replacement company. It has no
user DML policy, and UPDATE, DELETE, and TRUNCATE are blocked by triggers even
for elevated callers. An owner-only archive endpoint follows that immutable
replacement-to-source link, rechecks current ownership of the active
replacement, requires the source archive marker with a service-role client,
and exports without activating or mutating the source. Authorization does not
depend on retained-source membership because normal team removal and account
anonymization can legitimately change those rows; the immutable reset link
keeps the statutory archive reachable to the legal entity's current owners.
Support inspection follows reset chains with the service-side read-only query
in the runbook.
### Verification
The contract is pinned by:
- `tests/pg/company-migration-reset.pg.test.ts`
- `app/api/company/[id]/migration-reset/__tests__/route.test.ts`
Any change to the owner gate, eligibility boundary, source-retention
invariant, grants, or audit immutability must update those tests and this
document in the same change.
+269
View File
@@ -0,0 +1,269 @@
# 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 §§](https://www.riksdagen.se/sv/dokument-och-lagar/dokument/svensk-forfattningssamling/bokforingslag-19991078_sfs-1999-1078/)
- [BFNAR 2013:2 Bokföring, especially points 2.17 and 9.16](https://www.bfn.se/wp-content/uploads/2020/06/bfnar13-2-grund.pdf)
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.
- 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.
- 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:
1. It proves the caller is an owner before taking tenant-wide locks, then locks
the source and repeats authorization, confirmation, and eligibility checks.
2. It archives the source company by setting `archived_at` and `archived_by`.
3. 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.
4. It seeds a fresh chart of accounts and primary `1930` cash account.
5. 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.
6. It transfers the active inbound email address and any custom inbound domain
to the replacement, so future documents are routed to the active company.
7. It transfers unexpired pending member invitations. Accepted, revoked, and
expired invitation history stays on the source.
8. It switches active-company preferences to the replacement.
9. It writes an immutable `company_migration_resets` audit record and two
append-only `audit_log` records.
10. 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
- 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.
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 even when eligibility confirms that no invoice rows exist.
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:
```sql
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:
```sql
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:
```sql
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_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.
- `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 from `company_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`.
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:
1. Run the pg-real suite against an approved disposable test database.
2. Reconcile every remote migration version with the repository.
3. 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.
4. Exercise the flow with synthetic data in an approved non-production
environment, including every blocker and a forced transaction failure.
5. 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, any journal entry or voucher sequence exists, or a
customer or supplier invoice exists, 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.