From 3a1b842e4a5ae60460d3913934d8c7bc41754fff Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:04:24 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 7 + .../migration-reset/__tests__/route.test.ts | 242 ++++ .../archive/__tests__/route.test.ts | 278 +++++ .../[id]/migration-reset/archive/route.ts | 199 +++ app/api/company/[id]/migration-reset/route.ts | 133 ++ components/import/FullArchiveDialog.tsx | 163 ++- components/settings/CompanyDangerZone.tsx | 24 + .../settings/CompanyMigrationArchiveRow.tsx | 97 ++ .../settings/CompanyMigrationResetDialog.tsx | 353 ++++++ docs/security/authorization-policy.md | 113 ++ docs/support/company-migration-reset.md | 269 ++++ .../__tests__/declaration-lock-audit.test.ts | 118 ++ extensions/general/skatteverket/index.ts | 8 + lib/api/schemas.ts | 29 + lib/errors/structured-errors.ts | 35 + messages/en.json | 67 +- messages/sv.json | 63 +- ...20260818084050_company_migration_reset.sql | 1016 +++++++++++++++ ...en_company_migration_reset_eligibility.sql | 101 ++ ...004_close_migration_reset_archive_gaps.sql | 240 ++++ ...224000_block_vat_state_migration_reset.sql | 147 +++ ...00_block_external_filing_staging_state.sql | 194 +++ tests/pg/company-migration-reset.pg.test.ts | 1107 +++++++++++++++++ types/index.ts | 71 ++ 24 files changed, 5017 insertions(+), 57 deletions(-) create mode 100644 app/api/company/[id]/migration-reset/__tests__/route.test.ts create mode 100644 app/api/company/[id]/migration-reset/archive/__tests__/route.test.ts create mode 100644 app/api/company/[id]/migration-reset/archive/route.ts create mode 100644 app/api/company/[id]/migration-reset/route.ts create mode 100644 components/settings/CompanyMigrationArchiveRow.tsx create mode 100644 components/settings/CompanyMigrationResetDialog.tsx create mode 100644 docs/support/company-migration-reset.md create mode 100644 extensions/general/skatteverket/__tests__/declaration-lock-audit.test.ts create mode 100644 supabase/migrations/20260818084050_company_migration_reset.sql create mode 100644 supabase/migrations/20260818141018_harden_company_migration_reset_eligibility.sql create mode 100644 supabase/migrations/20260818143004_close_migration_reset_archive_gaps.sql create mode 100644 supabase/migrations/20260818224000_block_vat_state_migration_reset.sql create mode 100644 supabase/migrations/20260818231500_block_external_filing_staging_state.sql create mode 100644 tests/pg/company-migration-reset.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 2272c50e..24ca2ba6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1055,6 +1055,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-18] Shopify webshop_orders port: vat_breakdown is reconstructed from the ORDER-LEVEL taxLines (net = tax / rate, remainder as a 0%-bucket, refuse on missing rates or overshoot) instead of summing line items like the WooCommerce sync: Shopify's discountedTotalSet excludes cart-level discount allocations and lineItems is a paginated connection, so part-summing can silently produce a wrong per-rate net, while tax-per-rate and the charged total are authoritative order-level facts. Refund VAT is always prorated from the parent's mix (Shopify's Refund object exposes no per-rate tax without paging refundLineItems per refund). [2026-08-18] Shopify order feed keeps its paid-only qualification (PAID/PARTIALLY_REFUNDED/REFUNDED) after the webshop_orders port, unlike WooCommerce which also imports unpaid orders for the invoice flow: widening qualification is a product decision, out of scope for the port; unpaid orders re-surface via updatedAt when payment captures. The line-item snapshot is stored only when the parts reconstruct the charged total to the ore (else [] and the invoice conversion falls back to one aggregate line), and the bookkeeping-lock row filter was dropped: an Orders-page row behind the lock is an overview row, not permanent inbox noise, and booking is still blocked by the lock triggers (parity with WooCommerce). [2026-08-18] Skattekontoutdrag sum mismatch (opening + events != closing) demoted from a hard 400 to a preview confirm gate showing ingående/händelser/utgående/differens, mirroring the orgnr-mismatch gate: Sebastian's real export was refused on it (2026-08-18) with no way forward and no figures to diagnose; nothing is booked at import and dedup makes a later complete re-import safe, so refusing the file only blocked the rows that WERE readable. Parser also takes the earliest opening / latest closing across several marker pairs, reads a marker saldo from a trailing running-saldo column, and accepts U+2212 / plus-sign amounts; the route logs the figures (amounts and counts, never row text) so the next report is diagnosable from Vercel logs. Kept the hard reject only for zero readable rows. +[2026-08-18] Issue #1666 uses an atomic archive-and-replace migration reset instead of hard deletion: provider-imported rows cannot be proven disposable, external filings cannot be fully observed, and BFL retention requires the original documents, vouchers, treatment history, and audit trail to remain intact. +[2026-08-18] Company migration reset eligibility stops before the first journal entry or voucher-sequence row, regardless of status or import provenance: the archived source and replacement are the same legal entity, so a fresh replacement sequence is safe only when no prior voucher namespace or stranded draft exists; imported transactions, documents, periods, and import history may still be retained and restarted. +[2026-08-18] Company migration reset also stops before any customer or supplier invoice and gives the replacement owner a read-only retained-source archive download: invoices can carry issued-document and credit-note continuity before a voucher exists, while BFL accessibility is met without exposing an archived company in write-capable selection. [2026-08-18] Categorizing an ignored transaction atomically clears is_ignored in the batch route, single-transaction routes, and shared categorization core: categorization is explicit intent to book the row, and one update preserves transactions_is_ignored_no_journal_entry without forcing a separate unignore-and-retry action; the constraint name maps to TX_CATEGORIZE_IGNORED_CONFLICT as defense in depth. [2026-08-18] Issue #1668 keeps VAT confirmation in a dedicated sticky end column and makes truncated source names reveal on hover, focus, and activation: column truncation alone would still strand the hard-blocking action at responsive widths, while activation gives touch users the same full-text affordance. [2026-08-18] Issue #1659 exposes one canonical per-period VAT deadline resolver from deadline-config and makes both the MCP close check and VAT period default consume it: monthly, quarterly, annual, over-40M, and January/August rules must not drift across parallel formulas again; the MCP adapter alone applies the same banking-day adjustment as generated tax deadlines. @@ -1069,4 +1072,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-18] AGI receipt fallback (#1597): GET /agi/status serves the signed record from agi_declarations (kvittensnummer, response_data.signeradAv/signeradTid, submitted_at) only when the agi_submission_{period} cache is absent, and the declaration-sourced record deliberately carries NO salaryRunId: the period row is UNIQUE per company+period and regenerating a correction repoints its salary_run_id at the correction run while the stored kvittens still belongs to the original, so trusting the column would render the correction as filed with a superseded receipt. Ownership rests on signeradTid/submittedAt vs the run's agi_submitted_at stamp (same value) plus updatedAt = submitted_at, which predates any later correction's XML. Cache present still wins because it is the only place the in-flight states live. Rejected: a second client fetch in AGIPanel (two sources of truth for one card) and merging both records in the route (mixes another declaration's fields into an in-flight state). [2026-08-18] Trial seeding trigger widened to every PAID capability (20260818170000): the 2026-06-29 trigger hardcoded the four launch keys while PAID_CAPABILITIES grew to seven (stripe_payments, woocommerce_sync, shopify_sync); the Stripe webhook seeds from the constant, so payers had all seven and trialers four (prod 2026-08-18: 226/230 active trialers lacked stripe_payments). Fixed by redefining the function with all seven and mirroring existing trial bank_sync grants for the three keys; the pg test now compares the seeded set against PAID_CAPABILITIES itself so the two cannot drift silently again. Rejected: generating the VALUES list from the TS constant at build time (no codegen path into migrations exists; a test that pins them together is the cheaper guard). [2026-08-18] Invoice ROT/RUT personnummer surfaces (detail page, invoice PDF, preview PDF, editor kept-hint) switch to the payroll mask convention YYYYMMDD-XXXX (birth date visible, last four hidden), computed on read from deduction_personnummer_encrypted via lib/invoices/deduction-personnummer.ts: no schema change, nothing stored, never throws (bad ciphertext logs and renders no personnummer). The browser gets the mask from GET /api/invoices/[id]/rot-rut and never both the mask and the last four (that is the full number); v1 REST and MCP keep deduction_personnummer_last4 for compatibility (an additive deduction_personnummer_masked is a possible follow-up). InvoicePDF derives the mask itself when the caller passes the stored row, so none of the 11 render call sites can silently drop the personnummer; the preview route passes an already-masked value since it only has plaintext. The separate Skattereduktion card on the invoice detail page is folded into Detaljer as plain rows (Personnummer, Fastighet, Skattereduktion status with the begäran lifecycle) per founder decision 2026-08-18: it duplicated the totals block. +[2026-08-18] Migration-reset archive access follows the immutable reset link from a currently owned replacement instead of requiring mutable membership on the retained source: freezing archived company_members would break legitimate team removal and account anonymization, while replacement ownership plus an archived linked source keeps retained accounting information reachable without reactivating it. +[2026-08-18] Migration-reset replacements preserve next_invoice_number and next_arrival_number from the retained source: the replacement is the same legal entity, and a non-default counter can represent imported or previously allocated numbering even when no invoice row remains; restarting at 1 risks reuse or an unexplained break, while pg-real pins continuity at values above 1. +[2026-08-18] Migration-reset eligibility treats every persisted Skatteverket VAT `submission_*` workflow row as authority interaction evidence, not only successful audit-log rows: historical direct locks stored the signing state without auditing it, and Accounted cannot observe whether a user completed BankID signing outside the app. Unsigned drafts must be removed through the product; locked or uncertain state fails closed and is escalated. +[2026-08-18] Migration-reset eligibility also blocks AGI `pending_signature` and `agi_submission_*` state plus every ROT/RUT payout request: both flows hand work to Skatteverket for external upload or BankID signing before Accounted can observe the filing outcome, so a missing receipt or locally generated/cancelled status cannot prove that the data is disposable. [2026-08-19] Keep reversal allocation metadata limited to failures before any reversal header exists: later cleanup preserves a cancelled header with the allocated voucher number, so documenting it as an unused voucher gap would be false. diff --git a/app/api/company/[id]/migration-reset/__tests__/route.test.ts b/app/api/company/[id]/migration-reset/__tests__/route.test.ts new file mode 100644 index 00000000..a3a2bea9 --- /dev/null +++ b/app/api/company/[id]/migration-reset/__tests__/route.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(supabase), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { GET, POST } from '../route' + +const params = { params: Promise.resolve({ id: 'company-1' }) } +const validBody = { + confirm_name: 'Testbolaget AB', + reason: 'Den första migreringen fick fel periodindelning.', + confirm_no_filed_declarations: true, + confirm_retained_archive: true, +} + +describe('/api/company/[id]/migration-reset', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1', email: 'owner@example.com' } }, + error: null, + }) + }) + + it('returns 401 when unauthenticated', async () => { + supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null }) + + const response = await POST( + createMockRequest('/api/company/company-1/migration-reset', { + method: 'POST', + body: validBody, + }), + params, + ) + + expect(response.status).toBe(401) + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('returns 400 when a strong confirmation is missing', async () => { + const response = await POST( + createMockRequest('/api/company/company-1/migration-reset', { + method: 'POST', + body: { ...validBody, confirm_retained_archive: false }, + }), + params, + ) + + expect(response.status).toBe(400) + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('returns 400 when the audit reason is too short', async () => { + const response = await POST( + createMockRequest('/api/company/company-1/migration-reset', { + method: 'POST', + body: { ...validBody, reason: 'För kort' }, + }), + params, + ) + + expect(response.status).toBe(400) + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('returns 404 when the URL is not the active company', async () => { + const response = await POST( + createMockRequest('/api/company/company-2/migration-reset', { + method: 'POST', + body: validBody, + }), + { params: Promise.resolve({ id: 'company-2' }) }, + ) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(404) + expect(body.error.code).toBe('COMPANY_RESET_NOT_FOUND') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('returns 403 when the eligibility RPC rejects a non-owner', async () => { + enqueue({ data: { ok: false, code: 'COMPANY_RESET_FORBIDDEN' }, error: null }) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset'), + params, + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('COMPANY_RESET_FORBIDDEN') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('returns the owner eligibility preview', async () => { + enqueue({ + data: { + ok: true, + eligibility: { + eligible: true, + display_name: 'Testbolaget AB', + counts: { journal_entries: 0, documents: 2, voucher_sequences: 0 }, + blockers: [], + }, + }, + error: null, + }) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset'), + params, + ) + const { status, body } = await parseJsonResponse<{ + data: { eligible: boolean; counts: { documents: number } } + }>(response) + + expect(status).toBe(200) + expect(body.data.eligible).toBe(true) + expect(body.data.counts.documents).toBe(2) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(supabase.rpc).toHaveBeenCalledWith( + 'get_company_migration_reset_eligibility', + { p_company_id: 'company-1' }, + ) + }) + + it('returns 409 with current blockers when execution is ineligible', async () => { + enqueue({ + data: { + ok: false, + code: 'COMPANY_RESET_INELIGIBLE', + details: { + eligible: false, + blockers: [{ code: 'authority_submission_detected', count: 1 }], + }, + }, + error: null, + }) + + const response = await POST( + createMockRequest('/api/company/company-1/migration-reset', { + method: 'POST', + body: validBody, + }), + params, + ) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { blockers: Array<{ code: string }> } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('COMPANY_RESET_INELIGIBLE') + expect(body.error.details.blockers[0].code).toBe('authority_submission_detected') + }) + + it('returns the replacement and switches the active-company cookie', async () => { + enqueue({ + data: { + ok: true, + reset_id: 'reset-1', + source_company_id: 'company-1', + replacement_company_id: 'company-new', + archived_at: '2026-08-18T09:00:00.000Z', + counts: { journal_entries: 0, documents: 2 }, + }, + error: null, + }) + + const response = await POST( + createMockRequest('/api/company/company-1/migration-reset', { + method: 'POST', + body: validBody, + }), + params, + ) + const { status, body } = await parseJsonResponse<{ + data: { resetId: string; replacementCompanyId: string; retainedCounts: unknown } + }>(response) + + expect(status).toBe(200) + expect(body.data).toMatchObject({ + resetId: 'reset-1', + replacementCompanyId: 'company-new', + retainedCounts: { journal_entries: 0, documents: 2 }, + }) + expect(response.headers.get('set-cookie')).toContain('gnubok-company-id=company-new') + expect(supabase.rpc).toHaveBeenCalledWith('reset_company_for_migration', { + p_company_id: 'company-1', + p_confirmed_name: validBody.confirm_name, + p_reason: validBody.reason, + p_confirm_no_filed_declarations: true, + p_confirm_retained_archive: true, + }) + }) + + it('returns 500 when the atomic RPC fails', async () => { + enqueue({ data: null, error: { code: 'XX000', message: 'transaction failed' } }) + + const response = await POST( + createMockRequest('/api/company/company-1/migration-reset', { + method: 'POST', + body: validBody, + }), + params, + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(500) + expect(body.error.code).toBe('COMPANY_RESET_FAILED') + }) + + it('falls back to COMPANY_RESET_FAILED for an unexpected RPC code', async () => { + enqueue({ data: { ok: false, code: 'SOME_INTERNAL_CODE' }, error: null }) + + const response = await POST( + createMockRequest('/api/company/company-1/migration-reset', { + method: 'POST', + body: validBody, + }), + params, + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(500) + expect(body.error.code).toBe('COMPANY_RESET_FAILED') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) +}) diff --git a/app/api/company/[id]/migration-reset/archive/__tests__/route.test.ts b/app/api/company/[id]/migration-reset/archive/__tests__/route.test.ts new file mode 100644 index 00000000..44356fa3 --- /dev/null +++ b/app/api/company/[id]/migration-reset/archive/__tests__/route.test.ts @@ -0,0 +1,278 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() +const { + supabase: archiveSupabase, + enqueue: enqueueArchive, + reset: resetArchive, + calls: archiveCalls, +} = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(supabase), + createServiceClient: () => archiveSupabase, +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/reports/full-archive-export', () => ({ + estimateArchiveSize: vi.fn(), + generateBaseDataArchive: vi.fn(), +})) + +import { + estimateArchiveSize, + generateBaseDataArchive, +} from '@/lib/reports/full-archive-export' +import { GET } from '../route' + +const mockEstimate = vi.mocked(estimateArchiveSize) +const mockGenerate = vi.mocked(generateBaseDataArchive) +const params = { params: Promise.resolve({ id: 'company-1' }) } + +function enqueueAuthorizedArchive() { + enqueue({ data: { role: 'owner' }, error: null }) + enqueue({ + data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' }, + error: null, + }) + enqueueArchive({ + data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' }, + error: null, + }) + enqueueArchive({ data: { role: 'owner' }, error: null }) + enqueueArchive({ data: { archived_at: '2026-08-18T14:00:00.000Z' }, error: null }) +} + +describe('GET /api/company/[id]/migration-reset/archive', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + resetArchive() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1', email: 'owner@example.com' } }, + error: null, + }) + mockEstimate.mockResolvedValue({ + total_bytes: 10_000_000, + document_bytes: 1_000_000, + document_count: 2, + }) + }) + + it('returns 401 when unauthenticated', async () => { + supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null }) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive'), + params, + ) + + expect(response.status).toBe(401) + expect(mockEstimate).not.toHaveBeenCalled() + }) + + it('returns 404 when the URL is not the active replacement company', async () => { + const response = await GET( + createMockRequest('/api/company/company-2/migration-reset/archive'), + { params: Promise.resolve({ id: 'company-2' }) }, + ) + + expect(response.status).toBe(404) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockEstimate).not.toHaveBeenCalled() + }) + + it('returns 403 to a non-owner replacement member', async () => { + enqueue({ data: { role: 'admin' }, error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await GET(createMockRequest('/api/company/company-1/migration-reset/archive'), params), + ) + + expect(status).toBe(403) + expect(body.error.code).toBe('COMPANY_RESET_FORBIDDEN') + expect(mockEstimate).not.toHaveBeenCalled() + }) + + it('returns 404 when the active company has no retained reset source', async () => { + enqueue({ data: { role: 'owner' }, error: null }) + enqueue({ data: null, error: null }) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive'), + params, + ) + + expect(response.status).toBe(404) + expect(mockEstimate).not.toHaveBeenCalled() + }) + + it('returns retained-source metadata and size for an owner', async () => { + enqueueAuthorizedArchive() + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive', { + searchParams: { estimate: '1' }, + }), + params, + ) + const { status, body } = await parseJsonResponse<{ + data: { archived_at: string; document_count: number; within_limit: boolean } + }>(response) + + expect(status).toBe(200) + expect(body.data).toMatchObject({ + archived_at: '2026-08-18T14:00:00.000Z', + document_count: 2, + within_limit: true, + }) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'source-1', 'all') + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('downloads the retained source without changing the active company', async () => { + enqueueAuthorizedArchive() + mockGenerate.mockResolvedValue(new ArrayBuffer(1024)) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive'), + params, + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/zip') + expect(response.headers.get('Content-Disposition')).toMatch( + /^attachment; filename="migration_reset_archive_\d{8}\.zip"$/, + ) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockGenerate).toHaveBeenCalledWith(archiveSupabase, 'source-1', { + include_documents: true, + }) + }) + + it('requires an explicit document-free download when the ZIP is over limit', async () => { + enqueueAuthorizedArchive() + mockEstimate.mockResolvedValue({ + total_bytes: 100 * 1024 * 1024, + document_bytes: 92 * 1024 * 1024, + document_count: 10, + }) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive'), + params, + ) + + expect(response.status).toBe(413) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('downloads without documents when the planned payload is within the limit', async () => { + enqueueAuthorizedArchive() + mockEstimate.mockResolvedValue({ + total_bytes: 100 * 1024 * 1024, + document_bytes: 92 * 1024 * 1024, + document_count: 10, + }) + mockGenerate.mockResolvedValue(new ArrayBuffer(1024)) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive', { + searchParams: { include_documents: 'false' }, + }), + params, + ) + + expect(response.status).toBe(200) + expect(mockGenerate).toHaveBeenCalledWith(archiveSupabase, 'source-1', { + include_documents: false, + }) + }) + + it('blocks a document-free download when its planned payload is still over the limit', async () => { + enqueueAuthorizedArchive() + mockEstimate.mockResolvedValue({ + total_bytes: 100 * 1024 * 1024, + document_bytes: 10 * 1024 * 1024, + document_count: 10, + }) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive', { + searchParams: { include_documents: 'false' }, + }), + params, + ) + const { status, body } = await parseJsonResponse<{ + size_bytes: number + size_limit_bytes: number + }>(response) + + expect(status).toBe(413) + expect(body.size_bytes).toBe(90 * 1024 * 1024) + expect(body.size_limit_bytes).toBe(80 * 1024 * 1024) + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('keeps the archive reachable when retained-source membership changes', async () => { + enqueueAuthorizedArchive() + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive', { + searchParams: { estimate: '1' }, + }), + params, + ) + + expect(response.status).toBe(200) + expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'source-1', 'all') + expect(archiveCalls).not.toContainEqual({ + table: 'company_members', + method: 'eq', + args: ['company_id', 'source-1'], + }) + }) + + it('fails closed when the retained source is not archived', async () => { + enqueue({ data: { role: 'owner' }, error: null }) + enqueue({ + data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' }, + error: null, + }) + enqueueArchive({ + data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' }, + error: null, + }) + enqueueArchive({ data: { role: 'owner' }, error: null }) + enqueueArchive({ data: { archived_at: null }, error: null }) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive'), + params, + ) + + expect(response.status).toBe(403) + expect(mockEstimate).not.toHaveBeenCalled() + }) + + it('returns 500 when archive generation fails', async () => { + enqueueAuthorizedArchive() + mockGenerate.mockRejectedValue(new Error('storage unavailable')) + + const response = await GET( + createMockRequest('/api/company/company-1/migration-reset/archive'), + params, + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(500) + expect(body.error.code).toBe('COMPANY_RESET_FAILED') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) +}) diff --git a/app/api/company/[id]/migration-reset/archive/route.ts b/app/api/company/[id]/migration-reset/archive/route.ts new file mode 100644 index 00000000..372f2f7b --- /dev/null +++ b/app/api/company/[id]/migration-reset/archive/route.ts @@ -0,0 +1,199 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { + estimateArchiveSize, + generateBaseDataArchive, +} from '@/lib/reports/full-archive-export' +import { createServiceClient } from '@/lib/supabase/server' + +export const runtime = 'nodejs' +export const maxDuration = 300 + +const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 + +type Params = { params: Promise<{ id: string }> } + +interface ResetArchiveRow { + source_company_id: string + created_at: string +} + +function privateNoStore(response: NextResponse): NextResponse { + response.headers.set('Cache-Control', 'private, no-store') + return response +} + +/** + * GET /api/company/[id]/migration-reset/archive + * + * Gives the replacement-company owner a read-only ZIP of the retained source. + * The archived source never becomes active and no source row is modified. + */ +export const GET = withRouteContext( + 'company.migration-reset.archive', + async (request, { supabase, companyId, user, log, requestId }, { params }) => { + const { id } = await params + if (id !== companyId) { + return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId })) + } + + const { data: membership, error: membershipError } = await supabase + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', user.id) + .maybeSingle() + if (membershipError) { + log.error('failed to authorize migration reset archive', membershipError) + return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId })) + } + if (membership?.role !== 'owner') { + return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId })) + } + + const { data: visibleReset, error: visibleResetError } = await supabase + .from('company_migration_resets') + .select('source_company_id, created_at') + .eq('replacement_company_id', companyId) + .maybeSingle() + if (visibleResetError) { + log.error('failed to find migration reset archive', visibleResetError) + return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId })) + } + if (!visibleReset) { + return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId })) + } + + // Service credentials are required for a complete statutory export, but + // authorization is repeated before they are used. The immutable reset row + // must still link this active replacement to an archived source. Access is + // based on current ownership of the replacement, not mutable membership of + // the retained source, so normal team removal or account anonymization + // cannot accidentally strand the statutory archive. + const archiveClient = createServiceClient() + const { data: verifiedReset, error: verifiedResetError } = await archiveClient + .from('company_migration_resets') + .select('source_company_id, created_at') + .eq('replacement_company_id', companyId) + .maybeSingle() + if (verifiedResetError) { + log.error('failed to verify migration reset archive link', verifiedResetError) + return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId })) + } + + const reset = verifiedReset as ResetArchiveRow | null + if (!reset || reset.source_company_id !== visibleReset.source_company_id) { + log.warn('migration reset archive link verification denied', { + userId: user.id, + companyId, + }) + return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId })) + } + + const [{ data: replacementMembership, error: replacementMembershipError }, { + data: sourceCompany, + error: sourceCompanyError, + }] = await Promise.all([ + archiveClient + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', user.id) + .maybeSingle(), + archiveClient + .from('companies') + .select('archived_at') + .eq('id', reset.source_company_id) + .maybeSingle(), + ]) + if (replacementMembershipError || sourceCompanyError) { + log.error( + 'failed to verify retained migration source', + replacementMembershipError ?? sourceCompanyError, + ) + return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId })) + } + if (replacementMembership?.role !== 'owner' || !sourceCompany?.archived_at) { + log.warn('retained migration source access denied', { + userId: user.id, + companyId, + sourceCompanyId: reset.source_company_id, + }) + return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId })) + } + + const { searchParams } = new URL(request.url) + const estimateOnly = searchParams.get('estimate') === '1' + const includeDocuments = searchParams.get('include_documents') !== 'false' + + try { + const estimate = await estimateArchiveSize(archiveClient, reset.source_company_id, 'all') + const plannedSizeBytes = includeDocuments + ? estimate.total_bytes + : Math.max(0, estimate.total_bytes - estimate.document_bytes) + if (estimateOnly) { + return privateNoStore(NextResponse.json( + { + data: { + ...estimate, + archived_at: reset.created_at, + size_limit_bytes: SIZE_LIMIT_BYTES, + within_limit: plannedSizeBytes <= SIZE_LIMIT_BYTES, + }, + }, + )) + } + + if (plannedSizeBytes > SIZE_LIMIT_BYTES) { + return privateNoStore(NextResponse.json( + { + error: 'archive_too_large', + size_bytes: plannedSizeBytes, + size_limit_bytes: SIZE_LIMIT_BYTES, + }, + { status: 413 }, + )) + } + + const zipBuffer = await generateBaseDataArchive(archiveClient, reset.source_company_id, { + include_documents: includeDocuments, + }) + const filename = `migration_reset_archive_${formatDateStamp(new Date())}.zip` + + log.info('migration reset source archive generated', { + userId: user.id, + companyId, + sourceCompanyId: reset.source_company_id, + includeDocuments, + filename, + sizeBytes: zipBuffer.byteLength, + }) + + return new NextResponse(zipBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'private, no-store', + }, + }) + } catch (error) { + log.error('migration reset source archive generation failed', error as Error, { + userId: user.id, + companyId, + sourceCompanyId: reset.source_company_id, + }) + return privateNoStore( + errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }), + ) + } + }, +) + +function formatDateStamp(date: Date): string { + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + return `${year}${month}${day}` +} diff --git a/app/api/company/[id]/migration-reset/route.ts b/app/api/company/[id]/migration-reset/route.ts new file mode 100644 index 00000000..c6c8304e --- /dev/null +++ b/app/api/company/[id]/migration-reset/route.ts @@ -0,0 +1,133 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { CompanyMigrationResetSchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { CompanyMigrationResetRpcResult } from '@/types' + +type Params = { params: Promise<{ id: string }> } + +const EXPECTED_CODES = new Set([ + 'COMPANY_RESET_NOT_FOUND', + 'COMPANY_RESET_FORBIDDEN', + 'COMPANY_RESET_INELIGIBLE', + 'COMPANY_RESET_CONFIRMATION_MISMATCH', + 'COMPANY_RESET_REASON_INVALID', + 'COMPANY_RESET_CONFIRMATION_REQUIRED', +]) + +function rpcFailure( + result: CompanyMigrationResetRpcResult, + log: Parameters[1], + requestId: string, +) { + const code = result.code && EXPECTED_CODES.has(result.code) + ? result.code + : 'COMPANY_RESET_FAILED' + return errorResponseFromCode(code, log, { + requestId, + details: result.details, + }) +} + +function privateNoStore(response: NextResponse): NextResponse { + response.headers.set('Cache-Control', 'private, no-store') + return response +} + +/** + * GET /api/company/[id]/migration-reset + * + * Returns the owner-only, fail-closed eligibility preview. The execution RPC + * rechecks every condition, so this response is informational only. + */ +export const GET = withRouteContext( + 'company.migration-reset.preview', + async (_request, { supabase, companyId, log, requestId }, { params }) => { + const { id } = await params + if (id !== companyId) { + return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId })) + } + + const { data, error } = await supabase.rpc( + 'get_company_migration_reset_eligibility', + { p_company_id: companyId }, + ) + + if (error) { + log.error('migration reset eligibility RPC failed', error) + return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId })) + } + + const result = data as CompanyMigrationResetRpcResult | null + if (!result?.ok) { + return privateNoStore(rpcFailure(result ?? { ok: false }, log, requestId)) + } + + return privateNoStore(NextResponse.json({ data: result.eligibility })) + }, +) + +/** + * POST /api/company/[id]/migration-reset + * + * Atomically archives the source company and creates a clean active company. + * No source accounting record is deleted, detached, renumbered, or copied. + */ +export const POST = withRouteContext( + 'company.migration-reset.execute', + async (request, { supabase, companyId, log, requestId }, { params }) => { + const { id } = await params + if (id !== companyId) { + return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId })) + } + + const validation = await validateBody(request, CompanyMigrationResetSchema, { + log, + operation: 'company.migration-reset.execute', + }) + if (!validation.success) return privateNoStore(validation.response) + + const body = validation.data + const { data, error } = await supabase.rpc('reset_company_for_migration', { + p_company_id: companyId, + p_confirmed_name: body.confirm_name, + p_reason: body.reason, + p_confirm_no_filed_declarations: body.confirm_no_filed_declarations, + p_confirm_retained_archive: body.confirm_retained_archive, + }) + + if (error) { + log.error('migration reset RPC failed', error) + return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId })) + } + + const result = data as CompanyMigrationResetRpcResult | null + if (!result?.ok) { + return privateNoStore(rpcFailure(result ?? { ok: false }, log, requestId)) + } + if (!result.replacement_company_id) { + log.error('migration reset RPC returned no replacement company id') + return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId })) + } + + const response = NextResponse.json({ + data: { + resetId: result.reset_id, + sourceCompanyId: result.source_company_id, + replacementCompanyId: result.replacement_company_id, + archivedAt: result.archived_at, + retainedCounts: result.counts, + }, + }) + response.cookies.set('gnubok-company-id', result.replacement_company_id, { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 365, + }) + return privateNoStore(response) + }, + { requireWrite: true }, +) diff --git a/components/import/FullArchiveDialog.tsx b/components/import/FullArchiveDialog.tsx index d6a0e000..9f771a93 100644 --- a/components/import/FullArchiveDialog.tsx +++ b/components/import/FullArchiveDialog.tsx @@ -20,33 +20,33 @@ import { useCompany } from '@/contexts/CompanyContext' import { useFormat } from '@/lib/hooks/use-format' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' +import type { ApiResponse, ArchiveEstimate } from '@/types' import { Download, Loader2 } from 'lucide-react' type Scope = 'all' | 'period' - -interface EstimateResponse { - total_bytes: number - document_bytes: number - document_count: number - size_limit_bytes: number - within_limit: boolean -} +type ArchiveMode = 'active-company' | 'migration-reset-source' const LAST_DOWNLOAD_STORAGE_KEY = 'Accounted:last-backup-download' /** * Direct download of the complete company archive (SIE + reports + all - * supporting documents) as a ZIP, via GET /api/reports/full-archive. - * The route is owner/admin-only; the caller gates the entry point. + * supporting documents) as a ZIP. The normal mode uses the owner/admin-only + * full-archive route; migration-reset mode uses its owner-only retained-source + * route without making that archived company active. */ export function FullArchiveDialog({ open, onOpenChange, + mode = 'active-company', + companyId: explicitCompanyId, }: { open: boolean onOpenChange: (open: boolean) => void + mode?: ArchiveMode + companyId?: string }) { const t = useTranslations('import') + const tCompany = useTranslations('settings_company') const errorLocale = useLocale() as ErrorLocale const { toast } = useToast() const { company } = useCompany() @@ -55,14 +55,22 @@ export function FullArchiveDialog({ const [scope, setScope] = useState('all') const [periodId, setPeriodId] = useState(null) const [includeDocuments, setIncludeDocuments] = useState(true) - const [estimate, setEstimate] = useState(null) + const [estimate, setEstimate] = useState(null) const [isLoadingEstimate, setIsLoadingEstimate] = useState(false) const [isDownloading, setIsDownloading] = useState(false) const [lastDownloadedAt, setLastDownloadedAt] = useState(null) + const isMigrationResetSource = mode === 'migration-reset-source' + const archiveCompanyId = isMigrationResetSource + ? explicitCompanyId ?? company?.id + : company?.id const storageKey = useMemo( - () => (company ? `${LAST_DOWNLOAD_STORAGE_KEY}:${company.id}` : null), - [company] + () => archiveCompanyId + ? isMigrationResetSource + ? `${LAST_DOWNLOAD_STORAGE_KEY}:migration-reset:${archiveCompanyId}` + : `${LAST_DOWNLOAD_STORAGE_KEY}:${archiveCompanyId}` + : null, + [archiveCompanyId, isMigrationResetSource] ) useEffect(() => { @@ -71,14 +79,22 @@ export function FullArchiveDialog({ }, [storageKey, open]) const archiveUrl = useMemo(() => { + if (isMigrationResetSource) { + if (!archiveCompanyId) return '' + const params = new URLSearchParams() + if (!includeDocuments) params.set('include_documents', 'false') + const query = params.toString() + return `/api/company/${archiveCompanyId}/migration-reset/archive${query ? `?${query}` : ''}` + } + const params = new URLSearchParams({ scope }) if (scope === 'period' && periodId) params.set('period_id', periodId) if (!includeDocuments) params.set('include_documents', 'false') return `/api/reports/full-archive?${params.toString()}` - }, [scope, periodId, includeDocuments]) + }, [archiveCompanyId, includeDocuments, isMigrationResetSource, periodId, scope]) useEffect(() => { - if (!open || (scope === 'period' && !periodId)) { + if (!open || !archiveUrl || (!isMigrationResetSource && scope === 'period' && !periodId)) { setEstimate(null) return } @@ -87,10 +103,11 @@ export function FullArchiveDialog({ setEstimate(null) ;(async () => { try { - const res = await fetch(`${archiveUrl}&estimate=1`) + const separator = archiveUrl.includes('?') ? '&' : '?' + const res = await fetch(`${archiveUrl}${separator}estimate=1`) if (!res.ok) return - const { data } = (await res.json()) as { data: EstimateResponse } - if (!cancelled) setEstimate(data) + const { data } = (await res.json()) as ApiResponse + if (!cancelled && data) setEstimate(data) } catch { // leave estimate null; the user can still attempt the download } finally { @@ -100,10 +117,10 @@ export function FullArchiveDialog({ return () => { cancelled = true } - }, [open, archiveUrl, scope, periodId]) + }, [open, archiveUrl, isMigrationResetSource, scope, periodId]) const handleDownload = useCallback(async () => { - if (scope === 'period' && !periodId) return + if (!archiveUrl || (!isMigrationResetSource && scope === 'period' && !periodId)) return setIsDownloading(true) try { @@ -114,21 +131,27 @@ export function FullArchiveDialog({ const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null toast({ title: t('archive_toast_too_large_title'), - description: sizeMb - ? t('archive_toast_too_large_with_size', { size: sizeMb }) - : t('archive_toast_too_large_generic'), + description: isMigrationResetSource + ? sizeMb + ? tCompany('reset_archive_too_large_with_size', { size: sizeMb }) + : tCompany('reset_archive_too_large') + : sizeMb + ? t('archive_toast_too_large_with_size', { size: sizeMb }) + : t('archive_toast_too_large_generic'), variant: 'destructive', }) return } const body = await res.json().catch(() => ({})) - throw new Error(body.error || t('archive_toast_failed')) + throw new Error(readArchiveError(body, t('archive_toast_failed'))) } const blob = await res.blob() const contentDisposition = res.headers.get('Content-Disposition') || '' const match = contentDisposition.match(/filename="?([^";]+)"?/) - const filename = match?.[1] || 'arkiv.zip' + const filename = match?.[1] || (isMigrationResetSource + ? 'migration_reset_archive.zip' + : 'arkiv.zip') const url = window.URL.createObjectURL(blob) const link = document.createElement('a') @@ -158,43 +181,66 @@ export function FullArchiveDialog({ } finally { setIsDownloading(false) } - }, [archiveUrl, scope, periodId, storageKey, toast, t, errorLocale]) + }, [ + archiveUrl, + errorLocale, + isMigrationResetSource, + periodId, + scope, + storageKey, + t, + tCompany, + toast, + ]) - const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments - const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!periodId) + const plannedSizeBytes = estimate + ? includeDocuments + ? estimate.total_bytes + : Math.max(0, estimate.total_bytes - estimate.document_bytes) + : 0 + const isOverLimit = !!estimate && plannedSizeBytes > estimate.size_limit_bytes + const canDownload = !isDownloading + && !isOverLimit + && (isMigrationResetSource || scope === 'all' || !!periodId) return ( - {t('export_archive_title')} + {isMigrationResetSource + ? tCompany('reset_archive_download_title') + : t('export_archive_title')} - {t('archive_dialog_description')} + {isMigrationResetSource + ? tCompany('reset_archive_download_description') + : t('archive_dialog_description')}
-
- - {/* The two scopes count different document sets (all documents vs - only those linked to posted vouchers in the year), so a company - with unlinked inbox receipts sees very different counts. Say so, - or the gap reads as a pagination bug. */} -

- {scope === 'all' ? t('archive_scope_all_note') : t('archive_scope_period_note')} -

-
+ {!isMigrationResetSource ? ( +
+ + {/* The two scopes count different document sets (all documents vs + only those linked to posted vouchers in the year), so a company + with unlinked inbox receipts sees very different counts. Say so, + or the gap reads as a pagination bug. */} +

+ {scope === 'all' ? t('archive_scope_all_note') : t('archive_scope_period_note')} +

+
+ ) : null} - {scope === 'period' && ( + {!isMigrationResetSource && scope === 'period' && ( {t('archive_estimated_size')}{' '} - {formatBytes(estimate.total_bytes)} + {formatBytes(plannedSizeBytes)} {' '} ({estimate.document_count}{' '} {estimate.document_count === 1 @@ -246,7 +292,11 @@ export function FullArchiveDialog({ {isOverLimit && ( - {t('archive_over_limit', { limit: formatBytes(estimate!.size_limit_bytes) })} + {isMigrationResetSource + ? tCompany('reset_archive_over_limit', { + limit: formatBytes(estimate!.size_limit_bytes), + }) + : t('archive_over_limit', { limit: formatBytes(estimate!.size_limit_bytes) })} )}
@@ -278,3 +328,14 @@ function formatBytes(bytes: number): string { if (mb < 1024) return `${mb.toFixed(1)} MB` return `${(mb / 1024).toFixed(2)} GB` } + +function readArchiveError(body: unknown, fallback: string): string { + if (!body || typeof body !== 'object') return fallback + const error = (body as { error?: unknown }).error + if (typeof error === 'string') return error + if (error && typeof error === 'object') { + const message = (error as { message?: unknown }).message + if (typeof message === 'string') return message + } + return fallback +} diff --git a/components/settings/CompanyDangerZone.tsx b/components/settings/CompanyDangerZone.tsx index f92a2101..f7b03832 100644 --- a/components/settings/CompanyDangerZone.tsx +++ b/components/settings/CompanyDangerZone.tsx @@ -26,6 +26,8 @@ import { Loader2 } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { getBranding } from '@/lib/branding/service' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { CompanyMigrationResetDialog } from '@/components/settings/CompanyMigrationResetDialog' +import { CompanyMigrationArchiveRow } from '@/components/settings/CompanyMigrationArchiveRow' const branding = getBranding() @@ -48,6 +50,7 @@ export function CompanyDangerZone() { const { company, role } = useCompany() const [showDialog, setShowDialog] = useState(false) + const [showResetDialog, setShowResetDialog] = useState(false) const [confirmText, setConfirmText] = useState('') const [isDeleting, setIsDeleting] = useState(false) @@ -89,7 +92,21 @@ export function CompanyDangerZone() { return ( <> + + + + {t('reset_row_note')} + + + +
+ + ) } diff --git a/components/settings/CompanyMigrationArchiveRow.tsx b/components/settings/CompanyMigrationArchiveRow.tsx new file mode 100644 index 00000000..d553aff4 --- /dev/null +++ b/components/settings/CompanyMigrationArchiveRow.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { FullArchiveDialog } from '@/components/import/FullArchiveDialog' +import { + SettingsGroup, + SettingsRow, + SettingsRowEnd, + SettingsRowNote, +} from '@/components/settings/SettingsRows' +import type { ApiResponse, ArchiveEstimate } from '@/types' + +const ESTIMATE_RETRY_DELAY_MS = 1_000 +const ESTIMATE_MAX_ATTEMPTS = 3 + +export function CompanyMigrationArchiveRow({ companyId }: { companyId: string }) { + const t = useTranslations('settings_company') + const [loadedEstimate, setLoadedEstimate] = useState<{ + companyId: string + value: ArchiveEstimate + } | null>(null) + const [open, setOpen] = useState(false) + const estimate = loadedEstimate?.companyId === companyId + ? loadedEstimate.value + : null + + useEffect(() => { + const controller = new AbortController() + let cancelled = false + let retryTimer: ReturnType | undefined + + const scheduleRetry = (attempt: number) => { + if (cancelled || attempt >= ESTIMATE_MAX_ATTEMPTS) return + retryTimer = setTimeout(() => { + void loadEstimate(attempt + 1) + }, ESTIMATE_RETRY_DELAY_MS) + } + + const loadEstimate = async (attempt: number) => { + try { + const response = await fetch( + `/api/company/${companyId}/migration-reset/archive?estimate=1`, + { signal: controller.signal }, + ) + if (!response.ok) { + if (response.status >= 500 || response.status === 429) scheduleRetry(attempt) + return + } + const body = await response.json() as ApiResponse + if (!cancelled && body.data) { + setLoadedEstimate({ companyId, value: body.data }) + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return + scheduleRetry(attempt) + } + } + + void loadEstimate(1) + return () => { + cancelled = true + if (retryTimer) clearTimeout(retryTimer) + controller.abort() + } + }, [companyId]) + + if (!estimate) return null + + return ( + <> + + + + {t('reset_archive_row_note', { count: estimate.document_count })} + + + + + + + + + + ) +} diff --git a/components/settings/CompanyMigrationResetDialog.tsx b/components/settings/CompanyMigrationResetDialog.tsx new file mode 100644 index 00000000..1ecb3ff7 --- /dev/null +++ b/components/settings/CompanyMigrationResetDialog.tsx @@ -0,0 +1,353 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' +import { AlertTriangle, Archive, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { useToast } from '@/components/ui/use-toast' +import { getBranding } from '@/lib/branding/service' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { useFormat } from '@/lib/hooks/use-format' +import { + COMPANY_MIGRATION_RESET_COUNT_KEYS, + type CompanyMigrationResetBlocker, + type CompanyMigrationResetEligibility, +} from '@/types' + +interface CompanyMigrationResetDialogProps { + companyId: string + companyName: string + open: boolean + onOpenChange: (open: boolean) => void +} + +const branding = getBranding() + +function readApiError(body: unknown, fallback: string): string { + if (!body || typeof body !== 'object') return fallback + const error = (body as { error?: unknown }).error + if (typeof error === 'string') return error + if (error && typeof error === 'object') { + const message = (error as { message?: unknown }).message + if (typeof message === 'string') return message + } + return fallback +} + +export function CompanyMigrationResetDialog({ + companyId, + companyName, + open, + onOpenChange, +}: CompanyMigrationResetDialogProps) { + const t = useTranslations('settings_company') + const router = useRouter() + const { toast } = useToast() + const { formatDateLong } = useFormat() + const loadFailedMessage = t('reset_load_failed') + const [eligibility, setEligibility] = useState(null) + const [loadError, setLoadError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isResetting, setIsResetting] = useState(false) + const [reason, setReason] = useState('') + const [confirmName, setConfirmName] = useState('') + const [confirmedNoFilings, setConfirmedNoFilings] = useState(false) + const [confirmedArchive, setConfirmedArchive] = useState(false) + + useEffect(() => { + if (!open) return + + let cancelled = false + async function loadEligibility() { + setIsLoading(true) + setLoadError(null) + setEligibility(null) + try { + const response = await fetch(`/api/company/${companyId}/migration-reset`, { + cache: 'no-store', + }) + const body = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(readApiError(body, loadFailedMessage)) + } + if (!cancelled) setEligibility(body.data as CompanyMigrationResetEligibility) + } catch (error) { + if (!cancelled) { + setLoadError( + error instanceof Error ? getUserErrorMessage(error) : loadFailedMessage, + ) + } + } finally { + if (!cancelled) setIsLoading(false) + } + } + + void loadEligibility() + return () => { + cancelled = true + } + }, [companyId, loadFailedMessage, open]) + + function resetForm() { + setEligibility(null) + setLoadError(null) + setReason('') + setConfirmName('') + setConfirmedNoFilings(false) + setConfirmedArchive(false) + } + + function handleOpenChange(nextOpen: boolean) { + if (isResetting) return + onOpenChange(nextOpen) + if (!nextOpen) resetForm() + } + + function blockerMessage(blocker: CompanyMigrationResetBlocker): string { + switch (blocker.code) { + case 'migration_window_expired': + return t('reset_blocker_window', { + date: eligibility ? formatDateLong(eligibility.window_ends_at) : '', + }) + case 'sandbox_company': + 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': + return t('reset_blocker_bank_connections', { count: blocker.count }) + case 'imports_in_progress': + return t('reset_blocker_imports', { count: blocker.count }) + case 'active_integrations_or_schedules': + return t('reset_blocker_automations', { count: blocker.count }) + case 'background_work_in_progress': + return t('reset_blocker_background_work', { count: blocker.count }) + default: + return t('reset_blocker_other') + } + } + + function countLabel(key: (typeof COMPANY_MIGRATION_RESET_COUNT_KEYS)[number]): string { + switch (key) { + case 'journal_entries': return t('reset_count_journal_entries') + case 'journal_entry_lines': return t('reset_count_journal_entry_lines') + case 'committed_import_entries': return t('reset_count_committed_import_entries') + case 'transactions': return t('reset_count_transactions') + case 'fiscal_periods': return t('reset_count_fiscal_periods') + case 'documents': return t('reset_count_documents') + case 'voucher_sequences': return t('reset_count_voucher_sequences') + case 'sie_imports': return t('reset_count_sie_imports') + case 'bank_file_imports': return t('reset_count_bank_file_imports') + case 'skattekonto_file_imports': return t('reset_count_skattekonto_file_imports') + case 'bank_connections': return t('reset_count_bank_connections') + case 'customers': return t('reset_count_customers') + case 'suppliers': return t('reset_count_suppliers') + case 'invoices': return t('reset_count_invoices') + case 'supplier_invoices': return t('reset_count_supplier_invoices') + } + } + + const confirmationName = eligibility?.display_name ?? companyName + const canReset = eligibility?.eligible === true + && reason.trim().length >= 20 + && confirmName.trim() === confirmationName.trim() + && confirmedNoFilings + && confirmedArchive + && !isResetting + + async function handleReset() { + if (!canReset) return + setIsResetting(true) + try { + const response = await fetch(`/api/company/${companyId}/migration-reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + confirm_name: confirmName, + reason, + confirm_no_filed_declarations: confirmedNoFilings, + confirm_retained_archive: confirmedArchive, + }), + }) + const body = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(readApiError(body, t('reset_failed_default'))) + } + + toast({ + title: t('reset_success_title'), + description: t('reset_success_description'), + }) + setIsResetting(false) + onOpenChange(false) + resetForm() + router.push('/import') + router.refresh() + } catch (error) { + toast({ + title: t('reset_failed_title'), + description: error instanceof Error + ? getUserErrorMessage(error) + : t('reset_failed_default'), + variant: 'destructive', + }) + setIsResetting(false) + } + } + + return ( + + + + + {t('reset_dialog_title', { companyName })} + + {t('reset_dialog_description')} + + + {isLoading ? ( +
+ + {t('reset_checking')} +
+ ) : loadError ? ( +
+ {loadError} +
+ ) : eligibility ? ( +
+
+ +
+

{t('reset_archive_title')}

+

{t('reset_archive_description')}

+
+
+ + {eligibility.blockers.length > 0 ? ( +
+
+ + {t('reset_blocked_title')} +
+
    + {eligibility.blockers.map((blocker) => ( +
  • {blockerMessage(blocker)}
  • + ))} +
+
+ ) : null} + +
+

{t('reset_retained_heading')}

+
+ {COMPANY_MIGRATION_RESET_COUNT_KEYS.map((key) => ( +
+
{countLabel(key)}
+
{eligibility.counts[key] ?? 0}
+
+ ))} +
+
+ + {eligibility.eligible ? ( + <> +
+ +