Files
accounted/components/settings/BackupDownloadForm.tsx
T
Jakob Wennberg c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00

361 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { Cloud, Download, Info, Loader2 } from 'lucide-react'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import type { FiscalPeriod } from '@/types'
const CloudBackupPanel = getSettingsPanel('cloud-backup')
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
type Scope = 'all' | 'period'
interface EstimateResponse {
total_bytes: number
document_bytes: number
document_count: number
size_limit_bytes: number
within_limit: boolean
}
const LAST_DOWNLOAD_STORAGE_KEY = 'Accounted:last-backup-download'
export function BackupDownloadForm() {
const t = useTranslations('settings_backup_download')
const { toast } = useToast()
const { company } = useCompany()
const [scope, setScope] = useState<Scope>('all')
const [includeDocuments, setIncludeDocuments] = useState(true)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriodId, setSelectedPeriodId] = useState<string>('')
const [estimate, setEstimate] = useState<EstimateResponse | null>(null)
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [lastDownloadedAt, setLastDownloadedAt] = useState<string | null>(null)
const storageKey = useMemo(
() => (company ? `${LAST_DOWNLOAD_STORAGE_KEY}:${company.id}` : null),
[company]
)
useEffect(() => {
if (!storageKey) return
setLastDownloadedAt(window.localStorage.getItem(storageKey))
}, [storageKey])
useEffect(() => {
let cancelled = false
async function loadPeriods() {
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
if (cancelled) return
const sorted = (data || []) as FiscalPeriod[]
setPeriods(sorted)
if (sorted.length > 0 && !selectedPeriodId) {
setSelectedPeriodId(sorted[0].id)
}
} catch {
// silent: scope=all still works without periods loaded
}
}
loadPeriods()
return () => {
cancelled = true
}
// Intentionally run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const estimateUrl = useMemo(() => {
const params = new URLSearchParams({ estimate: '1', scope })
if (scope === 'period' && selectedPeriodId) {
params.set('period_id', selectedPeriodId)
}
if (!includeDocuments) {
params.set('include_documents', 'false')
}
return `/api/reports/full-archive?${params.toString()}`
}, [scope, selectedPeriodId, includeDocuments])
useEffect(() => {
if (scope === 'period' && !selectedPeriodId) {
setEstimate(null)
return
}
let cancelled = false
setIsLoadingEstimate(true)
setEstimate(null)
;(async () => {
try {
const res = await fetch(estimateUrl)
if (!res.ok) return
const { data } = (await res.json()) as { data: EstimateResponse }
if (!cancelled) setEstimate(data)
} catch {
// leave estimate null; we still let users attempt the download
} finally {
if (!cancelled) setIsLoadingEstimate(false)
}
})()
return () => {
cancelled = true
}
}, [estimateUrl, scope, selectedPeriodId])
const downloadUrl = useMemo(() => {
const params = new URLSearchParams({ scope })
if (scope === 'period' && selectedPeriodId) {
params.set('period_id', selectedPeriodId)
}
if (!includeDocuments) {
params.set('include_documents', 'false')
}
return `/api/reports/full-archive?${params.toString()}`
}, [scope, selectedPeriodId, includeDocuments])
const handleDownload = useCallback(async () => {
if (scope === 'period' && !selectedPeriodId) return
setIsDownloading(true)
try {
const res = await fetch(downloadUrl)
if (!res.ok) {
if (res.status === 413) {
const body = await res.json().catch(() => ({}))
const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null
toast({
title: t('toast_too_large_title'),
description: sizeMb
? t('toast_too_large_with_size', { size: sizeMb })
: t('toast_too_large_generic'),
variant: 'destructive',
})
return
}
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('error_create_archive'))
}
const blob = await res.blob()
const contentDisposition = res.headers.get('Content-Disposition') || ''
const match = contentDisposition.match(/filename="?([^";]+)"?/)
const filename = match?.[1] || 'arkiv.zip'
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
const now = new Date().toISOString()
if (storageKey) {
window.localStorage.setItem(storageKey, now)
setLastDownloadedAt(now)
}
toast({ title: t('toast_backup_created'), description: filename })
} catch (err) {
toast({
title: t('toast_backup_failed'),
description: err instanceof Error ? err.message : t('toast_try_again'),
variant: 'destructive',
})
} finally {
setIsDownloading(false)
}
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast, t])
const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments
const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!selectedPeriodId)
return (
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>{t('create_backup_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('scope_label')}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<ScopeRadio
checked={scope === 'all'}
onChange={() => setScope('all')}
label={t('scope_all_label')}
description={t('scope_all_desc')}
recommendedLabel={t('recommended')}
recommended
/>
<ScopeRadio
checked={scope === 'period'}
onChange={() => setScope('period')}
label={t('scope_period_label')}
description={t('scope_period_desc')}
recommendedLabel={t('recommended')}
/>
</div>
</div>
{scope === 'period' && (
<div className="space-y-2">
<Label htmlFor="backup-period">{t('fiscal_year_label')}</Label>
<select
id="backup-period"
value={selectedPeriodId}
onChange={(e) => setSelectedPeriodId(e.target.value)}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={periods.length === 0}
>
{periods.length === 0 && <option value="">{t('no_fiscal_years')}</option>}
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.period_start} {p.period_end}
</option>
))}
</select>
</div>
)}
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="include-documents">{t('include_docs_label')}</Label>
<p className="text-xs text-muted-foreground max-w-prose">
{t('include_docs_help')}
</p>
</div>
<Switch
id="include-documents"
checked={includeDocuments}
onCheckedChange={setIncludeDocuments}
/>
</div>
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="h-3.5 w-3.5" />
{isLoadingEstimate ? (
<span>{t('calculating_size')}</span>
) : estimate ? (
<span>
{t('estimated_size')} <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
{' '}({estimate.document_count} {estimate.document_count === 1 ? t('attachment_singular') : t('attachment_plural')})
</span>
) : (
<span>{t('size_will_calculate')}</span>
)}
</div>
{isOverLimit && (
<p className="mt-2 text-xs text-destructive">
{t('over_limit_message', { limit: formatBytes(estimate!.size_limit_bytes) })}
</p>
)}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button onClick={handleDownload} disabled={!canDownload}>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('creating_backup')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{t('create_and_download')}
</>
)}
</Button>
{lastDownloadedAt && (
<p className="text-xs text-muted-foreground">
{t('last_download')}: {formatDate(lastDownloadedAt)}
</p>
)}
</div>
</CardContent>
</Card>
{hasCloudBackup && CloudBackupPanel ? (
<CloudBackupPanel />
) : (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="h-4 w-4 text-muted-foreground" />
{t('cloud_sync_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground max-w-prose">
{t('cloud_sync_disabled_help')}
</p>
</CardContent>
</Card>
)}
</div>
)
}
interface ScopeRadioProps {
checked: boolean
onChange: () => void
label: string
description: string
recommended?: boolean
recommendedLabel: string
}
function ScopeRadio({ checked, onChange, label, description, recommended, recommendedLabel }: ScopeRadioProps) {
return (
<button
type="button"
onClick={onChange}
className={`flex-1 rounded-lg border-2 p-3 text-left transition-colors ${
checked ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'
}`}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{label}</span>
{recommended && (
<span className="text-[10px] font-medium uppercase tracking-wider text-primary">
{recommendedLabel}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
</button>
)
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} kB`
const mb = kb / 1024
if (mb < 1024) return `${mb.toFixed(1)} MB`
return `${(mb / 1024).toFixed(2)} GB`
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}