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

* feat: add safe company migration reset

* fix: harden company reset eligibility

* fix: close company reset compliance gaps

* test: fix migration reset pg-real probes

* fix: preserve migration archive access

* docs: explain migration numbering continuity

* fix: block reset with VAT workflow state

* fix: block externally staged reset data

* fix: address migration reset review findings

* fix: clear stale migration archive estimate

* fix: retry migration archive estimates
This commit is contained in:
Mattsson
2026-08-19 12:04:24 +02:00
committed by GitHub
parent b07a4a4bca
commit 3a1b842e4a
24 changed files with 5017 additions and 57 deletions
+112 -51
View File
@@ -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
}