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:
@@ -1055,6 +1055,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<Params>(
|
||||
'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}`
|
||||
}
|
||||
@@ -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<typeof errorResponseFromCode>[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<Params>(
|
||||
'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<Params>(
|
||||
'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 },
|
||||
)
|
||||
@@ -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<Scope>('all')
|
||||
const [periodId, setPeriodId] = useState<string | null>(null)
|
||||
const [includeDocuments, setIncludeDocuments] = useState(true)
|
||||
const [estimate, setEstimate] = useState<EstimateResponse | null>(null)
|
||||
const [estimate, setEstimate] = useState<ArchiveEstimate | null>(null)
|
||||
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [lastDownloadedAt, setLastDownloadedAt] = useState<string | null>(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<ArchiveEstimate>
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{t('export_archive_title')}
|
||||
{isMigrationResetSource
|
||||
? tCompany('reset_archive_download_title')
|
||||
: t('export_archive_title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[13px] leading-relaxed">
|
||||
{t('archive_dialog_description')}
|
||||
{isMigrationResetSource
|
||||
? tCompany('reset_archive_download_description')
|
||||
: t('archive_dialog_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<SegmentedControl
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
aria-label={t('archive_scope_label')}
|
||||
options={[
|
||||
{ value: 'all', label: t('archive_scope_all') },
|
||||
{ value: 'period', label: t('archive_scope_period') },
|
||||
]}
|
||||
/>
|
||||
{/* 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. */}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{scope === 'all' ? t('archive_scope_all_note') : t('archive_scope_period_note')}
|
||||
</p>
|
||||
</div>
|
||||
{!isMigrationResetSource ? (
|
||||
<div className="space-y-2">
|
||||
<SegmentedControl
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
aria-label={t('archive_scope_label')}
|
||||
options={[
|
||||
{ value: 'all', label: t('archive_scope_all') },
|
||||
{ value: 'period', label: t('archive_scope_period') },
|
||||
]}
|
||||
/>
|
||||
{/* 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. */}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{scope === 'all' ? t('archive_scope_all_note') : t('archive_scope_period_note')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scope === 'period' && (
|
||||
{!isMigrationResetSource && scope === 'period' && (
|
||||
<FiscalYearSelector
|
||||
value={periodId}
|
||||
onChange={setPeriodId}
|
||||
@@ -225,7 +271,7 @@ export function FullArchiveDialog({
|
||||
<>
|
||||
{t('archive_estimated_size')}{' '}
|
||||
<strong className="font-medium tabular-nums text-foreground">
|
||||
{formatBytes(estimate.total_bytes)}
|
||||
{formatBytes(plannedSizeBytes)}
|
||||
</strong>{' '}
|
||||
({estimate.document_count}{' '}
|
||||
{estimate.document_count === 1
|
||||
@@ -246,7 +292,11 @@ export function FullArchiveDialog({
|
||||
|
||||
{isOverLimit && (
|
||||
<AttnLine>
|
||||
{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) })}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<CompanyMigrationArchiveRow companyId={company.id} />
|
||||
|
||||
<SettingsDangerZone label={t('danger_heading')}>
|
||||
<SettingsRow label={t('reset_row_label')}>
|
||||
<SettingsRowNote>{t('reset_row_note')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80"
|
||||
>
|
||||
{t('reset_row_action')}
|
||||
</button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('danger_button')}
|
||||
borderless
|
||||
@@ -173,6 +190,13 @@ export function CompanyDangerZone() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CompanyMigrationResetDialog
|
||||
companyId={company.id}
|
||||
companyName={company.name}
|
||||
open={showResetDialog}
|
||||
onOpenChange={setShowResetDialog}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<ArchiveEstimate>
|
||||
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 (
|
||||
<>
|
||||
<SettingsGroup label={t('reset_archive_group_label')}>
|
||||
<SettingsRow label={t('reset_archive_row_label')} borderless>
|
||||
<SettingsRowNote>
|
||||
{t('reset_archive_row_note', { count: estimate.document_count })}
|
||||
</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-sm font-medium text-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground/70"
|
||||
>
|
||||
{t('reset_archive_row_action')}
|
||||
</button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<FullArchiveDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
mode="migration-reset-source"
|
||||
companyId={companyId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<CompanyMigrationResetEligibility | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle data-ph-mask="">
|
||||
{t('reset_dialog_title', { companyName })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('reset_dialog_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('reset_checking')}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{loadError}
|
||||
</div>
|
||||
) : eligibility ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-3 rounded-lg border border-border bg-muted/40 p-4">
|
||||
<Archive className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">{t('reset_archive_title')}</p>
|
||||
<p className="text-muted-foreground">{t('reset_archive_description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{eligibility.blockers.length > 0 ? (
|
||||
<div className="space-y-2 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('reset_blocked_title')}
|
||||
</div>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{eligibility.blockers.map((blocker) => (
|
||||
<li key={blocker.code}>{blockerMessage(blocker)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">{t('reset_retained_heading')}</h3>
|
||||
<dl className="divide-y divide-border border-y border-border">
|
||||
{COMPANY_MIGRATION_RESET_COUNT_KEYS.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between py-2 text-sm">
|
||||
<dt className="text-muted-foreground">{countLabel(key)}</dt>
|
||||
<dd className="tabular-nums font-medium">{eligibility.counts[key] ?? 0}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{eligibility.eligible ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="migration-reset-reason">{t('reset_reason_label')}</Label>
|
||||
<Textarea
|
||||
id="migration-reset-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={t('reset_reason_placeholder')}
|
||||
maxLength={1000}
|
||||
data-ph-mask=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('reset_reason_help', { count: reason.trim().length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="migration-reset-no-filings"
|
||||
checked={confirmedNoFilings}
|
||||
onCheckedChange={(checked) => setConfirmedNoFilings(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="migration-reset-no-filings" className="font-normal leading-5">
|
||||
{t('reset_confirm_no_filings', { appName: branding.appName })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="migration-reset-retained"
|
||||
checked={confirmedArchive}
|
||||
onCheckedChange={(checked) => setConfirmedArchive(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="migration-reset-retained" className="font-normal leading-5">
|
||||
{t('reset_confirm_archive')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label data-ph-mask="" htmlFor="migration-reset-name">
|
||||
{t.rich('reset_confirm_name', {
|
||||
companyName: confirmationName,
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="migration-reset-name"
|
||||
value={confirmName}
|
||||
onChange={(event) => setConfirmName(event.target.value)}
|
||||
placeholder={confirmationName}
|
||||
autoComplete="off"
|
||||
className="ph-no-capture"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isResetting}>
|
||||
{t('danger_cancel')}
|
||||
</Button>
|
||||
{eligibility?.eligible ? (
|
||||
<Button variant="destructive" onClick={handleReset} disabled={!canReset}>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('reset_resetting')}
|
||||
</>
|
||||
) : t('reset_submit')}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSkvRequest = vi.fn()
|
||||
const mockWriteSkatteverketAudit = vi.fn()
|
||||
|
||||
vi.mock('../lib/api-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/api-client')>()
|
||||
return {
|
||||
...actual,
|
||||
skvRequest: (...args: unknown[]) => mockSkvRequest(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../lib/audit', () => ({
|
||||
writeSkatteverketAudit: (...args: unknown[]) => mockWriteSkatteverketAudit(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return {
|
||||
...actual,
|
||||
requireCapability: vi.fn().mockResolvedValue(null),
|
||||
}
|
||||
})
|
||||
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
import { skatteverketExtension } from '../index'
|
||||
|
||||
function makeContext(): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'skatteverket',
|
||||
requestId: 'req-lock-audit',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: {} as any,
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
function lockRoute() {
|
||||
const route = skatteverketExtension.apiRoutes?.find(
|
||||
(candidate) => candidate.method === 'PUT' && candidate.path === '/declaration/lock',
|
||||
)
|
||||
expect(route).toBeDefined()
|
||||
return route!
|
||||
}
|
||||
|
||||
describe('direct VAT declaration lock audit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('records a successful lock before persisting the signing state', async () => {
|
||||
mockSkvRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ signeringsLank: 'https://skv.test/sign/vat' }),
|
||||
})
|
||||
const ctx = makeContext()
|
||||
const response = await lockRoute().handler(
|
||||
new Request(
|
||||
'https://test.local/api/extensions/ext/skatteverket/declaration/lock?redovisare=165560000000&redovisningsperiod=202606',
|
||||
{ method: 'PUT' },
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockWriteSkatteverketAudit).toHaveBeenCalledWith(ctx, {
|
||||
endpoint: 'declaration/lock',
|
||||
agRegistreradId: '165560000000',
|
||||
redovisningsperiod: '202606',
|
||||
outcome: 'ok',
|
||||
responseStatus: 200,
|
||||
})
|
||||
expect(ctx.settings.set).toHaveBeenCalledWith(
|
||||
'submission_202606',
|
||||
expect.stringContaining('"status":"draft_locked"'),
|
||||
)
|
||||
expect(mockWriteSkatteverketAudit.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(ctx.settings.set).mock.invocationCallOrder[0]!,
|
||||
)
|
||||
})
|
||||
|
||||
it('records a rejected lock response', async () => {
|
||||
mockSkvRequest.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
text: async () => 'already locked',
|
||||
})
|
||||
const ctx = makeContext()
|
||||
const response = await lockRoute().handler(
|
||||
new Request(
|
||||
'https://test.local/api/extensions/ext/skatteverket/declaration/lock?redovisare=165560000000&redovisningsperiod=202606',
|
||||
{ method: 'PUT' },
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(mockWriteSkatteverketAudit).toHaveBeenCalledWith(ctx, {
|
||||
endpoint: 'declaration/lock',
|
||||
agRegistreradId: '165560000000',
|
||||
redovisningsperiod: '202606',
|
||||
outcome: 'skv_error',
|
||||
responseStatus: 409,
|
||||
})
|
||||
expect(ctx.settings.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -973,6 +973,14 @@ export const skatteverketExtension: Extension = {
|
||||
`/las/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'declaration/lock',
|
||||
agRegistreradId: redovisare,
|
||||
redovisningsperiod,
|
||||
outcome: response.ok ? 'ok' : 'skv_error',
|
||||
responseStatus: response.status,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -3640,3 +3640,32 @@ export const SkattekontoFileExecuteSchema = z.object({
|
||||
variant: z.enum(['csv', 'skv']),
|
||||
closing_saldo: z.number().finite().nullable().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Company migration reset
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* POST /api/company/[id]/migration-reset
|
||||
*
|
||||
* Every confirmation is required independently. The database repeats these
|
||||
* checks inside the atomic RPC, including the exact display-name match.
|
||||
*/
|
||||
export const CompanyMigrationResetSchema = z.object({
|
||||
confirm_name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Ange företagsnamnet exakt som det visas')
|
||||
.max(200, 'Företagsnamnet får vara högst 200 tecken'),
|
||||
reason: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(20, 'Beskriv varför migreringen behöver göras om med minst 20 tecken')
|
||||
.max(1000, 'Beskrivningen får vara högst 1 000 tecken'),
|
||||
confirm_no_filed_declarations: z.literal(true, {
|
||||
error: 'Bekräfta att inga deklarationer eller årsredovisningar har lämnats in',
|
||||
}),
|
||||
confirm_retained_archive: z.literal(true, {
|
||||
error: 'Bekräfta att den tidigare företagskopian sparas som arkiv',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2698,6 +2698,41 @@ const COMPANY: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Företaget kunde inte skapas.',
|
||||
message_en: 'Failed to create company.',
|
||||
},
|
||||
COMPANY_RESET_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Företaget kunde inte hittas.',
|
||||
message_en: 'Company not found.',
|
||||
},
|
||||
COMPANY_RESET_FORBIDDEN: {
|
||||
httpStatus: 403,
|
||||
message_sv: 'Endast företagets ägare kan starta om en migrering.',
|
||||
message_en: 'Only the company owner can reset a migration.',
|
||||
},
|
||||
COMPANY_RESET_INELIGIBLE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Företaget kan inte återställas med självservice. Kontakta supporten för en individuell bedömning.',
|
||||
message_en: 'The company is not eligible for a self-service reset. Contact support for an individual review.',
|
||||
},
|
||||
COMPANY_RESET_CONFIRMATION_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Företagsnamnet stämmer inte överens.',
|
||||
message_en: 'The company name does not match.',
|
||||
},
|
||||
COMPANY_RESET_REASON_INVALID: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Beskriv varför migreringen behöver göras om med 20 till 1 000 tecken.',
|
||||
message_en: 'Explain why the migration must be redone using 20 to 1,000 characters.',
|
||||
},
|
||||
COMPANY_RESET_CONFIRMATION_REQUIRED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Alla säkerhetsbekräftelser krävs.',
|
||||
message_en: 'All safety confirmations are required.',
|
||||
},
|
||||
COMPANY_RESET_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Migreringen kunde inte startas om. Inga ändringar har sparats.',
|
||||
message_en: 'The migration reset failed. No changes were saved.',
|
||||
},
|
||||
}
|
||||
|
||||
const API_KEY: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
+63
-4
@@ -1811,10 +1811,69 @@
|
||||
"fp_blocked_first_year": "First fiscal year:",
|
||||
"fp_blocked_calendar_year": " (calendar year)",
|
||||
"fp_blocked_broken_year": " (broken fiscal year)",
|
||||
"fp_blocked_explainer": "If you have to start over you can delete the company at the bottom of this page and create a new one. Bookkeeping data is retained for 7 years per BFL 7 kap. 2§.",
|
||||
"fp_year_label_single": "Räkenskapsår {year}",
|
||||
"fp_year_label_range": "Räkenskapsår {startYear}/{endYear}",
|
||||
"danger_heading": "Delete company",
|
||||
"fp_blocked_explainer": "If this is a recent failed migration, the company owner can use Redo migration under Danger zone. The previous bookkeeping copy is archived and retained under BFL.",
|
||||
"fp_year_label_single": "Fiscal year {year}",
|
||||
"fp_year_label_range": "Fiscal year {startYear}/{endYear}",
|
||||
"danger_heading": "Danger zone",
|
||||
"reset_row_label": "Redo migration",
|
||||
"reset_row_note": "Archive the current data and start in an empty company copy.",
|
||||
"reset_row_action": "Start over",
|
||||
"reset_dialog_title": "Redo the migration for {companyName}",
|
||||
"reset_dialog_description": "The current company will be archived and an empty company copy will be created for the new migration. This cannot be undone through self-service.",
|
||||
"reset_checking": "Checking whether the company can be reset...",
|
||||
"reset_load_failed": "Could not check whether the company can be reset.",
|
||||
"reset_archive_title": "Nothing is deleted",
|
||||
"reset_archive_description": "Imported transactions, documents, periods, and import history remain unchanged in the archived company. Self-service is blocked if a voucher, voucher sequence, or invoice already exists.",
|
||||
"reset_blocked_title": "Self-service is blocked",
|
||||
"reset_blocker_window": "The self-service reset window ended on {date}.",
|
||||
"reset_blocker_sandbox": "Sandbox companies use their separate automatic cleanup and cannot be reset here.",
|
||||
"reset_blocker_periods": "{count, plural, =1 {One bookkeeping lock or closed fiscal year exists.} other {# bookkeeping locks or closed fiscal years exist.}}",
|
||||
"reset_blocker_entries": "{count, plural, =1 {One voucher already exists; drafts and migrated vouchers also block reset.} other {# vouchers already exist; drafts and migrated vouchers also block reset.}}",
|
||||
"reset_blocker_sequences": "{count, plural, =1 {One voucher sequence has already been created.} other {# voucher sequences have already been created.}}",
|
||||
"reset_blocker_invoices": "{count, plural, =1 {One customer or supplier invoice already exists.} other {# customer or supplier invoices already exist.}}",
|
||||
"reset_blocker_filings": "{count, plural, =1 {One known submission to Skatteverket or Bolagsverket exists.} other {# known submissions to Skatteverket or Bolagsverket exist.}}",
|
||||
"reset_blocker_bank_connections": "{count, plural, =1 {One bank connection is still active or being connected. Disconnect it first.} other {# bank connections are still active or being connected. Disconnect them first.}}",
|
||||
"reset_blocker_imports": "{count, plural, =1 {One import is still in progress. Wait for it to finish or fail before retrying.} other {# imports are still in progress. Wait for them to finish or fail before retrying.}}",
|
||||
"reset_blocker_automations": "{count, plural, =1 {One integration or automatic schedule can still create data. Disable it first.} other {# integrations or automatic schedules can still create data. Disable them first.}}",
|
||||
"reset_blocker_background_work": "{count, plural, =1 {One background task is still running. Wait for it to finish or fail.} other {# background tasks are still running. Wait for them to finish or fail.}}",
|
||||
"reset_blocker_other": "The company status does not allow a reset.",
|
||||
"reset_retained_heading": "Data retained in the archived company",
|
||||
"reset_count_journal_entries": "Vouchers",
|
||||
"reset_count_journal_entry_lines": "Voucher lines",
|
||||
"reset_count_committed_import_entries": "Committed import vouchers",
|
||||
"reset_count_transactions": "Transactions",
|
||||
"reset_count_fiscal_periods": "Fiscal years",
|
||||
"reset_count_documents": "Documents",
|
||||
"reset_count_voucher_sequences": "Voucher sequences",
|
||||
"reset_count_sie_imports": "SIE imports",
|
||||
"reset_count_bank_file_imports": "Bank file imports",
|
||||
"reset_count_skattekonto_file_imports": "Tax account file imports",
|
||||
"reset_count_bank_connections": "Bank connections",
|
||||
"reset_count_customers": "Customers",
|
||||
"reset_count_suppliers": "Suppliers",
|
||||
"reset_count_invoices": "Customer invoices",
|
||||
"reset_count_supplier_invoices": "Supplier invoices",
|
||||
"reset_reason_label": "Why does the migration need to be redone?",
|
||||
"reset_reason_placeholder": "Describe what went wrong in the previous migration...",
|
||||
"reset_reason_help": "{count}/1,000 characters, minimum 20",
|
||||
"reset_confirm_no_filings": "I confirm that no tax returns, control statements, ROT/RUT requests, or annual reports have been filed for this company, through {appName} or otherwise.",
|
||||
"reset_confirm_archive": "I understand that the old company copy is retained as a locked archive for traceability and statutory retention, and is not deleted.",
|
||||
"reset_confirm_name": "Type <strong>{companyName}</strong> to confirm",
|
||||
"reset_submit": "Archive and start over",
|
||||
"reset_resetting": "Creating a new company copy...",
|
||||
"reset_success_title": "The migration can be redone",
|
||||
"reset_success_description": "The old company copy is archived. You can now start the new import.",
|
||||
"reset_failed_title": "Could not redo the migration",
|
||||
"reset_failed_default": "No changes were saved. Try again.",
|
||||
"reset_archive_group_label": "Previous migration",
|
||||
"reset_archive_row_label": "Archived company copy",
|
||||
"reset_archive_row_note": "Locked and retained, including {count, plural, =1 {one document} other {# documents}}.",
|
||||
"reset_archive_row_action": "Download archive",
|
||||
"reset_archive_download_title": "Download the previous migration",
|
||||
"reset_archive_download_description": "Creates a read-only ZIP from the locked company copy. The archived data stays unchanged and the copy is not reopened.",
|
||||
"reset_archive_over_limit": "The retained archive is larger than {limit}. Turn off documents to download the structured data now, then contact support for the complete document package.",
|
||||
"reset_archive_too_large_with_size": "The retained archive is about {size} MB. Turn off documents for a structured-data ZIP, then contact support for the complete document package.",
|
||||
"reset_archive_too_large": "Turn off documents for a structured-data ZIP, then contact support for the complete document package.",
|
||||
"danger_button": "Delete company",
|
||||
"danger_dialog_title": "Delete {companyName}",
|
||||
"danger_dialog_description": "The company is hidden from {appName}. The bookkeeping is retained securely for 7 years per BFL. Type the company name exactly to confirm.",
|
||||
|
||||
+61
-2
@@ -1811,10 +1811,69 @@
|
||||
"fp_blocked_first_year": "Första räkenskapsåret:",
|
||||
"fp_blocked_calendar_year": " (kalenderår)",
|
||||
"fp_blocked_broken_year": " (brutet räkenskapsår)",
|
||||
"fp_blocked_explainer": "Om du måste börja om kan du radera företaget längst ner på sidan och skapa ett nytt. Bokföringsdata behålls i 7 år enligt BFL 7 kap. 2§.",
|
||||
"fp_blocked_explainer": "Om detta är en nyligen misslyckad migrering kan företagets ägare använda Gör om migrering under Riskzon. Den tidigare bokföringskopian arkiveras och behålls enligt BFL.",
|
||||
"fp_year_label_single": "Räkenskapsår {year}",
|
||||
"fp_year_label_range": "Räkenskapsår {startYear}/{endYear}",
|
||||
"danger_heading": "Radera företag",
|
||||
"danger_heading": "Riskzon",
|
||||
"reset_row_label": "Gör om migrering",
|
||||
"reset_row_note": "Arkivera nuvarande data och börja i en tom företagskopia.",
|
||||
"reset_row_action": "Starta om",
|
||||
"reset_dialog_title": "Gör om migreringen för {companyName}",
|
||||
"reset_dialog_description": "Det nuvarande företaget arkiveras och en tom företagskopia skapas för den nya migreringen. Åtgärden kan inte ångras med självservice.",
|
||||
"reset_checking": "Kontrollerar om företaget kan återställas...",
|
||||
"reset_load_failed": "Kunde inte kontrollera om företaget kan återställas.",
|
||||
"reset_archive_title": "Ingenting raderas",
|
||||
"reset_archive_description": "Importerade transaktioner, dokument, perioder och importhistorik stannar oförändrade i det arkiverade företaget. Självservice blockeras om en verifikation, nummerserie eller faktura redan finns.",
|
||||
"reset_blocked_title": "Självservice är blockerad",
|
||||
"reset_blocker_window": "Tidsgränsen för återställning med självservice gick ut {date}.",
|
||||
"reset_blocker_sandbox": "Testföretag använder sin separata automatiska rensning och kan inte återställas här.",
|
||||
"reset_blocker_periods": "{count, plural, =1 {Ett bokföringslås eller stängt räkenskapsår finns.} other {# bokföringslås eller stängda räkenskapsår finns.}}",
|
||||
"reset_blocker_entries": "{count, plural, =1 {En verifikation finns redan, även utkast och migrerade verifikationer blockerar återställning.} other {# verifikationer finns redan, även utkast och migrerade verifikationer blockerar återställning.}}",
|
||||
"reset_blocker_sequences": "{count, plural, =1 {En verifikationsserie har redan skapats.} other {# verifikationsserier har redan skapats.}}",
|
||||
"reset_blocker_invoices": "{count, plural, =1 {En kund- eller leverantörsfaktura finns redan.} other {# kund- eller leverantörsfakturor finns redan.}}",
|
||||
"reset_blocker_filings": "{count, plural, =1 {En känd inlämning till Skatteverket eller Bolagsverket finns.} other {# kända inlämningar till Skatteverket eller Bolagsverket finns.}}",
|
||||
"reset_blocker_bank_connections": "{count, plural, =1 {En bankkoppling är fortfarande aktiv eller håller på att anslutas. Koppla från den först.} other {# bankkopplingar är fortfarande aktiva eller håller på att anslutas. Koppla från dem först.}}",
|
||||
"reset_blocker_imports": "{count, plural, =1 {En import pågår fortfarande. Vänta tills den är klar eller har misslyckats innan du försöker igen.} other {# importer pågår fortfarande. Vänta tills de är klara eller har misslyckats innan du försöker igen.}}",
|
||||
"reset_blocker_automations": "{count, plural, =1 {En integration eller automatisk körning kan fortfarande skapa data. Inaktivera den först.} other {# integrationer eller automatiska körningar kan fortfarande skapa data. Inaktivera dem först.}}",
|
||||
"reset_blocker_background_work": "{count, plural, =1 {Ett bakgrundsjobb pågår fortfarande. Vänta tills det är klart eller har misslyckats.} other {# bakgrundsjobb pågår fortfarande. Vänta tills de är klara eller har misslyckats.}}",
|
||||
"reset_blocker_other": "Företagets status tillåter inte återställning.",
|
||||
"reset_retained_heading": "Data som sparas i det arkiverade företaget",
|
||||
"reset_count_journal_entries": "Verifikationer",
|
||||
"reset_count_journal_entry_lines": "Verifikationsrader",
|
||||
"reset_count_committed_import_entries": "Bokförda importverifikationer",
|
||||
"reset_count_transactions": "Transaktioner",
|
||||
"reset_count_fiscal_periods": "Räkenskapsår",
|
||||
"reset_count_documents": "Dokument",
|
||||
"reset_count_voucher_sequences": "Verifikationsserier",
|
||||
"reset_count_sie_imports": "SIE-importer",
|
||||
"reset_count_bank_file_imports": "Bankfilsimporter",
|
||||
"reset_count_skattekonto_file_imports": "Skattekontofilsimporter",
|
||||
"reset_count_bank_connections": "Bankkopplingar",
|
||||
"reset_count_customers": "Kunder",
|
||||
"reset_count_suppliers": "Leverantörer",
|
||||
"reset_count_invoices": "Kundfakturor",
|
||||
"reset_count_supplier_invoices": "Leverantörsfakturor",
|
||||
"reset_reason_label": "Varför behöver migreringen göras om?",
|
||||
"reset_reason_placeholder": "Beskriv felet i den tidigare migreringen...",
|
||||
"reset_reason_help": "{count}/1 000 tecken, minst 20",
|
||||
"reset_confirm_no_filings": "Jag bekräftar att inga deklarationer, kontrolluppgifter, ROT/RUT-begäranden eller årsredovisningar har lämnats in från företaget, varken via {appName} eller på annat sätt.",
|
||||
"reset_confirm_archive": "Jag förstår att den gamla företagskopian sparas som ett låst arkiv för spårbarhet och lagstadgad bevaring, och att den inte raderas.",
|
||||
"reset_confirm_name": "Skriv <strong>{companyName}</strong> för att bekräfta",
|
||||
"reset_submit": "Arkivera och börja om",
|
||||
"reset_resetting": "Skapar ny företagskopia...",
|
||||
"reset_success_title": "Migreringen kan göras om",
|
||||
"reset_success_description": "Den gamla företagskopian är arkiverad. Du kan nu starta den nya importen.",
|
||||
"reset_failed_title": "Kunde inte göra om migreringen",
|
||||
"reset_failed_default": "Inga ändringar sparades. Försök igen.",
|
||||
"reset_archive_group_label": "Föregående migrering",
|
||||
"reset_archive_row_label": "Arkiverad företagskopia",
|
||||
"reset_archive_row_note": "Låst och bevarad, inklusive {count, plural, =1 {ett dokument} other {# dokument}}.",
|
||||
"reset_archive_row_action": "Ladda ner arkiv",
|
||||
"reset_archive_download_title": "Ladda ner föregående migrering",
|
||||
"reset_archive_download_description": "Skapar en skrivskyddad ZIP från den låsta företagskopian. Arkivuppgifterna ändras inte och kopian öppnas inte igen.",
|
||||
"reset_archive_over_limit": "Det bevarade arkivet är större än {limit}. Stäng av dokument för att ladda ner strukturerade uppgifter nu och kontakta sedan support för hela dokumentpaketet.",
|
||||
"reset_archive_too_large_with_size": "Det bevarade arkivet är cirka {size} MB. Stäng av dokument för en ZIP med strukturerade uppgifter och kontakta sedan support för hela dokumentpaketet.",
|
||||
"reset_archive_too_large": "Stäng av dokument för en ZIP med strukturerade uppgifter och kontakta sedan support för hela dokumentpaketet.",
|
||||
"danger_button": "Radera företag",
|
||||
"danger_dialog_title": "Radera {companyName}",
|
||||
"danger_dialog_description": "Företaget döljs från {appName}. Bokföringen behålls säkert i 7 år enligt BFL. Skriv företagets namn exakt för att bekräfta.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
-- Keep archive-and-replace resets before the first accounting record exists.
|
||||
-- A replacement represents the same legal entity, so it must not start a
|
||||
-- second voucher-number namespace after any draft, posting, or sequence state
|
||||
-- has already been created on the retained source.
|
||||
|
||||
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
RENAME TO company_migration_reset_snapshot_before_20260818141018;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_snapshot jsonb;
|
||||
v_blockers jsonb;
|
||||
v_journal_entries integer;
|
||||
v_voucher_sequences integer;
|
||||
v_authority_submissions integer;
|
||||
v_direct_vat_submissions integer;
|
||||
BEGIN
|
||||
v_snapshot := public.company_migration_reset_snapshot_before_20260818141018(
|
||||
p_company_id
|
||||
);
|
||||
|
||||
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
|
||||
RETURN v_snapshot;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_journal_entries
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id;
|
||||
|
||||
SELECT count(*) INTO v_voucher_sequences
|
||||
FROM public.voucher_sequences
|
||||
WHERE company_id = p_company_id;
|
||||
|
||||
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
|
||||
INTO v_authority_submissions
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
|
||||
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
|
||||
|
||||
-- The VAT submission chain normally records a successful declaration lock
|
||||
-- first. Count the direct submission endpoint as independent evidence too,
|
||||
-- so a historical or interrupted audit chain still fails closed.
|
||||
SELECT count(*) INTO v_direct_vat_submissions
|
||||
FROM public.skatteverket_api_audit_log
|
||||
WHERE company_id = p_company_id
|
||||
AND outcome = 'ok'
|
||||
AND endpoint = 'declaration/submit';
|
||||
|
||||
v_authority_submissions := v_authority_submissions + v_direct_vat_submissions;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
|
||||
INTO v_blockers
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers')
|
||||
WITH ORDINALITY AS existing(blocker, position)
|
||||
WHERE existing.blocker ->> 'code' NOT IN (
|
||||
'non_import_committed_entries',
|
||||
'authority_submission_detected'
|
||||
);
|
||||
|
||||
IF v_journal_entries > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'journal_entries_exist',
|
||||
'count', v_journal_entries
|
||||
));
|
||||
END IF;
|
||||
|
||||
IF v_voucher_sequences > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'voucher_sequence_state_exists',
|
||||
'count', v_voucher_sequences
|
||||
));
|
||||
END IF;
|
||||
|
||||
IF v_authority_submissions > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'authority_submission_detected',
|
||||
'count', v_authority_submissions
|
||||
));
|
||||
END IF;
|
||||
|
||||
RETURN v_snapshot || jsonb_build_object(
|
||||
'eligible', jsonb_array_length(v_blockers) = 0,
|
||||
'blockers', v_blockers
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
|
||||
'Internal fail-closed reset snapshot. Any journal entry or voucher sequence blocks archive-and-replace.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818141018(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,240 @@
|
||||
-- Close the remaining archive-and-replace boundaries found in compliance
|
||||
-- review. Existing invoice records are accounting documents even before a
|
||||
-- voucher is posted, and retained filing/import evidence must stay static.
|
||||
|
||||
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
RENAME TO company_migration_reset_snapshot_before_20260818143004;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_snapshot jsonb;
|
||||
v_blockers jsonb;
|
||||
v_invoice_records integer;
|
||||
BEGIN
|
||||
v_snapshot := public.company_migration_reset_snapshot_before_20260818143004(
|
||||
p_company_id
|
||||
);
|
||||
|
||||
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
|
||||
RETURN v_snapshot;
|
||||
END IF;
|
||||
|
||||
v_invoice_records :=
|
||||
COALESCE((v_snapshot -> 'counts' ->> 'invoices')::integer, 0)
|
||||
+ COALESCE((v_snapshot -> 'counts' ->> 'supplier_invoices')::integer, 0);
|
||||
|
||||
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
|
||||
INTO v_blockers
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers')
|
||||
WITH ORDINALITY AS existing(blocker, position)
|
||||
WHERE existing.blocker ->> 'code' <> 'invoice_records_exist';
|
||||
|
||||
IF v_invoice_records > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'invoice_records_exist',
|
||||
'count', v_invoice_records
|
||||
));
|
||||
END IF;
|
||||
|
||||
RETURN v_snapshot || jsonb_build_object(
|
||||
'eligible', jsonb_array_length(v_blockers) = 0,
|
||||
'blockers', v_blockers
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
|
||||
'Internal fail-closed reset snapshot. Any journal, voucher sequence, or invoice record blocks archive-and-replace.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818143004(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- Check both sides of an UPDATE. The first version checked only OLD, which
|
||||
-- blocked edits to an archived row but did not independently reject moving an
|
||||
-- active row into an archived company through an elevated writer.
|
||||
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_old_company_id uuid;
|
||||
v_new_company_id uuid;
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
||||
v_old_company_id := OLD.company_id;
|
||||
END IF;
|
||||
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
||||
v_new_company_id := NEW.company_id;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id IN (v_old_company_id, v_new_company_id)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Archived migration reset source records are immutable';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.block_migration_reset_source_mutation()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- These tables can still contain draft or disconnected evidence on an
|
||||
-- otherwise eligible source. Once archived, all such rows must be as static as
|
||||
-- transactions and documents. The existing trigger function checks the reset
|
||||
-- audit row and does not affect active companies.
|
||||
DO $$
|
||||
DECLARE
|
||||
v_table text;
|
||||
BEGIN
|
||||
FOREACH v_table IN ARRAY ARRAY[
|
||||
'bank_connections',
|
||||
'company_settings',
|
||||
'salary_runs',
|
||||
'salary_run_employees',
|
||||
'salary_line_items',
|
||||
'agi_declarations',
|
||||
'arsredovisning_submissions',
|
||||
'rot_rut_payout_requests',
|
||||
'skatteverket_api_audit_log'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE ON public.%I '
|
||||
'FOR EACH ROW EXECUTE FUNCTION public.block_migration_reset_source_mutation()',
|
||||
v_table || '_block_migration_reset_source_mutation',
|
||||
v_table
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Company identity and archive metadata are part of the retained system
|
||||
-- documentation. The reset transaction finishes all source-company updates
|
||||
-- before inserting the audit link, so later source updates are never needed.
|
||||
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_company_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = OLD.id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Archived migration reset source company is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER zz_companies_block_migration_reset_source_mutation
|
||||
BEFORE UPDATE ON public.companies
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.block_migration_reset_source_company_mutation();
|
||||
|
||||
REVOKE ALL ON FUNCTION public.block_migration_reset_source_company_mutation()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- Journal lines are company-scoped through their parent. Check both parent
|
||||
-- references on UPDATE so a line cannot be reassigned into a retained source.
|
||||
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_journal_line_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_old_entry_id uuid;
|
||||
v_new_entry_id uuid;
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
||||
v_old_entry_id := OLD.journal_entry_id;
|
||||
END IF;
|
||||
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
||||
v_new_entry_id := NEW.journal_entry_id;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.journal_entries entry
|
||||
JOIN public.company_migration_resets reset
|
||||
ON reset.source_company_id = entry.company_id
|
||||
WHERE entry.id IN (v_old_entry_id, v_new_entry_id)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Archived migration reset source records are immutable';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.block_migration_reset_source_journal_line_mutation()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- ROT/RUT request items are company-scoped through their parent and therefore
|
||||
-- need the same parent lookup pattern as journal-entry lines.
|
||||
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_rot_rut_item_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_old_request_id uuid;
|
||||
v_new_request_id uuid;
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
||||
v_old_request_id := OLD.request_id;
|
||||
END IF;
|
||||
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
||||
v_new_request_id := NEW.request_id;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.rot_rut_payout_requests request
|
||||
JOIN public.company_migration_resets reset
|
||||
ON reset.source_company_id = request.company_id
|
||||
WHERE request.id IN (v_old_request_id, v_new_request_id)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Archived migration reset source records are immutable';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER rot_rut_payout_request_items_block_migration_reset_source_mutation
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON public.rot_rut_payout_request_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.block_migration_reset_source_rot_rut_item_mutation();
|
||||
|
||||
REVOKE ALL ON FUNCTION public.block_migration_reset_source_rot_rut_item_mutation()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,147 @@
|
||||
-- A VAT draft locked for signing can be signed outside Accounted after the
|
||||
-- lock succeeds. Historical direct-lock requests persisted their state in
|
||||
-- extension_data but did not always create a Skatteverket audit row. Treat any
|
||||
-- persisted VAT workflow state as authority interaction evidence: a saved
|
||||
-- draft must be removed through the product before self-service reset, and a
|
||||
-- locked state must never be cleared merely to make the company eligible.
|
||||
|
||||
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
RENAME TO company_migration_reset_snapshot_before_20260818224000;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_snapshot jsonb;
|
||||
v_blockers jsonb;
|
||||
v_authority_submissions integer;
|
||||
v_unlogged_vat_workflows integer;
|
||||
BEGIN
|
||||
v_snapshot := public.company_migration_reset_snapshot_before_20260818224000(
|
||||
p_company_id
|
||||
);
|
||||
|
||||
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
|
||||
RETURN v_snapshot;
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
|
||||
INTO v_authority_submissions
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
|
||||
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
|
||||
|
||||
-- extension_data.value is a JSONB string for the Skatteverket extension,
|
||||
-- but the key itself is sufficient evidence that a VAT draft workflow was
|
||||
-- started. Avoid double-counting periods already represented by a successful
|
||||
-- lock/submit audit row while still failing closed for historical direct
|
||||
-- lock calls that predate complete audit coverage.
|
||||
SELECT count(*) INTO v_unlogged_vat_workflows
|
||||
FROM public.extension_data state
|
||||
WHERE state.company_id = p_company_id
|
||||
AND state.extension_id = 'skatteverket'
|
||||
AND state.key LIKE 'submission\_%' ESCAPE '\'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.skatteverket_api_audit_log audit
|
||||
WHERE audit.company_id = p_company_id
|
||||
AND audit.outcome = 'ok'
|
||||
AND audit.endpoint IN ('declaration/lock', 'declaration/submit')
|
||||
AND (
|
||||
audit.redovisningsperiod IS NULL
|
||||
OR audit.redovisningsperiod = substring(state.key FROM 12)
|
||||
)
|
||||
);
|
||||
|
||||
v_authority_submissions :=
|
||||
v_authority_submissions + v_unlogged_vat_workflows;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
|
||||
INTO v_blockers
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers')
|
||||
WITH ORDINALITY AS existing(blocker, position)
|
||||
WHERE existing.blocker ->> 'code' <> 'authority_submission_detected';
|
||||
|
||||
IF v_authority_submissions > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'authority_submission_detected',
|
||||
'count', v_authority_submissions
|
||||
));
|
||||
END IF;
|
||||
|
||||
RETURN v_snapshot || jsonb_build_object(
|
||||
'eligible', jsonb_array_length(v_blockers) = 0,
|
||||
'blockers', v_blockers
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
|
||||
'Internal fail-closed reset snapshot. VAT extension workflow state is authority interaction evidence.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818224000(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- An eligible source has no VAT submission_* rows. This narrow guard closes
|
||||
-- an in-flight or elevated insert after the source is archived without making
|
||||
-- unrelated extension runtime state interfere with account anonymization.
|
||||
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_vat_state_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_old_company_id uuid;
|
||||
v_new_company_id uuid;
|
||||
v_old_is_vat_state boolean := false;
|
||||
v_new_is_vat_state boolean := false;
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
||||
v_old_company_id := OLD.company_id;
|
||||
v_old_is_vat_state :=
|
||||
OLD.extension_id = 'skatteverket'
|
||||
AND OLD.key LIKE 'submission\_%' ESCAPE '\';
|
||||
END IF;
|
||||
|
||||
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
||||
v_new_company_id := NEW.company_id;
|
||||
v_new_is_vat_state :=
|
||||
NEW.extension_id = 'skatteverket'
|
||||
AND NEW.key LIKE 'submission\_%' ESCAPE '\';
|
||||
END IF;
|
||||
|
||||
IF (v_old_is_vat_state AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = v_old_company_id
|
||||
))
|
||||
OR (v_new_is_vat_state AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = v_new_company_id
|
||||
)) THEN
|
||||
RAISE EXCEPTION 'Archived migration reset source VAT state is immutable';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER extension_data_block_migration_reset_source_vat_state
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON public.extension_data
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.block_migration_reset_source_vat_state_mutation();
|
||||
|
||||
REVOKE ALL ON FUNCTION public.block_migration_reset_source_vat_state_mutation()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,194 @@
|
||||
-- AGI uploads and ROT/RUT payout files cross the boundary into an external
|
||||
-- signing flow before Accounted can observe a completed filing. The local
|
||||
-- state can therefore lag behind Skatteverket. Treat every such staging state
|
||||
-- as authority interaction evidence instead of assuming that a missing
|
||||
-- receipt or a locally generated status proves the filing never happened.
|
||||
|
||||
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
RENAME TO company_migration_reset_snapshot_before_20260818231500;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_snapshot jsonb;
|
||||
v_blockers jsonb;
|
||||
v_authority_submissions integer;
|
||||
v_external_staging_workflows integer;
|
||||
BEGIN
|
||||
v_snapshot := public.company_migration_reset_snapshot_before_20260818231500(
|
||||
p_company_id
|
||||
);
|
||||
|
||||
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
|
||||
RETURN v_snapshot;
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
|
||||
INTO v_authority_submissions
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
|
||||
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
|
||||
|
||||
-- An AGI underlag in Eget utrymme can be signed in Mina Sidor while the
|
||||
-- declaration still says pending_signature. The extension cache is also
|
||||
-- evidence because it is written before the declaration status update and
|
||||
-- can survive a partial application failure. Normalize the period so the
|
||||
-- declaration row and its cache count as one workflow.
|
||||
--
|
||||
-- A generated ROT/RUT payout file is returned for manual upload and signing.
|
||||
-- Accounted cannot prove that a locally generated or cancelled request was
|
||||
-- never submitted externally, so every request state is retention-protected.
|
||||
SELECT count(*)
|
||||
INTO v_external_staging_workflows
|
||||
FROM (
|
||||
SELECT
|
||||
'agi:' || declaration.period_year::text
|
||||
|| lpad(declaration.period_month::text, 2, '0') AS workflow
|
||||
FROM public.agi_declarations declaration
|
||||
WHERE declaration.company_id = p_company_id
|
||||
AND declaration.status = 'pending_signature'
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
'agi:' || regexp_replace(
|
||||
substring(state.key FROM char_length('agi_submission_') + 1),
|
||||
'[^0-9]',
|
||||
'',
|
||||
'g'
|
||||
) AS workflow
|
||||
FROM public.extension_data state
|
||||
WHERE state.company_id = p_company_id
|
||||
AND state.extension_id = 'skatteverket'
|
||||
AND state.key LIKE 'agi\_submission\_%' ESCAPE '\'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.agi_declarations declaration
|
||||
WHERE declaration.company_id = p_company_id
|
||||
AND declaration.period_year::text
|
||||
|| lpad(declaration.period_month::text, 2, '0')
|
||||
= regexp_replace(
|
||||
substring(state.key FROM char_length('agi_submission_') + 1),
|
||||
'[^0-9]',
|
||||
'',
|
||||
'g'
|
||||
)
|
||||
AND (
|
||||
declaration.submitted_at IS NOT NULL
|
||||
OR declaration.status IN ('submitted', 'accepted', 'rejected')
|
||||
)
|
||||
)
|
||||
|
||||
UNION
|
||||
|
||||
SELECT 'rot_rut:' || request.id::text AS workflow
|
||||
FROM public.rot_rut_payout_requests request
|
||||
WHERE request.company_id = p_company_id
|
||||
AND NOT (
|
||||
request.submitted_at IS NOT NULL
|
||||
OR request.status IN ('submitted', 'paid', 'partially_paid', 'rejected')
|
||||
)
|
||||
) staging;
|
||||
|
||||
v_authority_submissions :=
|
||||
v_authority_submissions + v_external_staging_workflows;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
|
||||
INTO v_blockers
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers')
|
||||
WITH ORDINALITY AS existing(blocker, position)
|
||||
WHERE existing.blocker ->> 'code' <> 'authority_submission_detected';
|
||||
|
||||
IF v_authority_submissions > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'authority_submission_detected',
|
||||
'count', v_authority_submissions
|
||||
));
|
||||
END IF;
|
||||
|
||||
RETURN v_snapshot || jsonb_build_object(
|
||||
'eligible', jsonb_array_length(v_blockers) = 0,
|
||||
'blockers', v_blockers
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
|
||||
'Internal fail-closed reset snapshot. External filing staging state is authority interaction evidence.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818231500(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- The retained source already rejects writes to agi_declarations and
|
||||
-- rot_rut_payout_requests. Extend the narrow extension_data guard so an
|
||||
-- in-flight or elevated writer cannot attach a new AGI upload state after the
|
||||
-- company has been archived.
|
||||
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_vat_state_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_old_company_id uuid;
|
||||
v_new_company_id uuid;
|
||||
v_old_is_authority_state boolean := false;
|
||||
v_new_is_authority_state boolean := false;
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
||||
v_old_company_id := OLD.company_id;
|
||||
v_old_is_authority_state :=
|
||||
OLD.extension_id = 'skatteverket'
|
||||
AND (
|
||||
OLD.key LIKE 'submission\_%' ESCAPE '\'
|
||||
OR OLD.key LIKE 'agi\_submission\_%' ESCAPE '\'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
||||
v_new_company_id := NEW.company_id;
|
||||
v_new_is_authority_state :=
|
||||
NEW.extension_id = 'skatteverket'
|
||||
AND (
|
||||
NEW.key LIKE 'submission\_%' ESCAPE '\'
|
||||
OR NEW.key LIKE 'agi\_submission\_%' ESCAPE '\'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF (v_old_is_authority_state AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = v_old_company_id
|
||||
))
|
||||
OR (v_new_is_authority_state AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = v_new_company_id
|
||||
)) THEN
|
||||
RAISE EXCEPTION 'Archived migration reset source authority workflow state is immutable';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TRIGGER extension_data_block_migration_reset_source_vat_state
|
||||
ON public.extension_data
|
||||
RENAME TO extension_data_block_migration_reset_source_authority_state;
|
||||
|
||||
COMMENT ON FUNCTION public.block_migration_reset_source_vat_state_mutation() IS
|
||||
'Rejects VAT and AGI authority workflow state on a retained migration reset source.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.block_migration_reset_source_vat_state_mutation()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,69 @@ export interface CompanyMember {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const COMPANY_MIGRATION_RESET_COUNT_KEYS = [
|
||||
'journal_entries',
|
||||
'journal_entry_lines',
|
||||
'committed_import_entries',
|
||||
'transactions',
|
||||
'fiscal_periods',
|
||||
'documents',
|
||||
'voucher_sequences',
|
||||
'sie_imports',
|
||||
'bank_file_imports',
|
||||
'skattekonto_file_imports',
|
||||
'customers',
|
||||
'suppliers',
|
||||
'invoices',
|
||||
'supplier_invoices',
|
||||
'bank_connections',
|
||||
] as const
|
||||
|
||||
export type CompanyMigrationResetCountKey =
|
||||
(typeof COMPANY_MIGRATION_RESET_COUNT_KEYS)[number]
|
||||
|
||||
export type CompanyMigrationResetBlockerCode =
|
||||
| 'company_not_found'
|
||||
| 'company_already_archived'
|
||||
| 'migration_window_expired'
|
||||
| 'sandbox_company'
|
||||
| 'locked_or_closed_periods'
|
||||
| 'journal_entries_exist'
|
||||
| 'non_import_committed_entries'
|
||||
| 'voucher_sequence_state_exists'
|
||||
| 'invoice_records_exist'
|
||||
| 'authority_submission_detected'
|
||||
| 'live_bank_connections'
|
||||
| 'imports_in_progress'
|
||||
| 'active_integrations_or_schedules'
|
||||
| 'background_work_in_progress'
|
||||
|
||||
export interface CompanyMigrationResetBlocker {
|
||||
code: CompanyMigrationResetBlockerCode
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface CompanyMigrationResetEligibility {
|
||||
eligible: boolean
|
||||
display_name: string
|
||||
created_at: string
|
||||
window_ends_at: string
|
||||
counts: Record<CompanyMigrationResetCountKey, number>
|
||||
blockers: CompanyMigrationResetBlocker[]
|
||||
}
|
||||
|
||||
export interface CompanyMigrationResetRpcResult {
|
||||
ok: boolean
|
||||
code?: string
|
||||
details?: unknown
|
||||
eligibility?: CompanyMigrationResetEligibility
|
||||
reset_id?: string
|
||||
source_company_id?: string
|
||||
replacement_company_id?: string
|
||||
archived_at?: string
|
||||
counts?: CompanyMigrationResetEligibility['counts']
|
||||
}
|
||||
|
||||
// User preferences (cross-company)
|
||||
export interface UserPreferences {
|
||||
id: string
|
||||
@@ -1612,6 +1675,14 @@ export interface ApiResponse<T> {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ArchiveEstimate {
|
||||
total_bytes: number
|
||||
document_bytes: number
|
||||
document_count: number
|
||||
size_limit_bytes: number
|
||||
within_limit: boolean
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
count: number
|
||||
|
||||
Reference in New Issue
Block a user