feat(vat): book the momsrapport as an editable settlement verifikat (#980) (#983)

* feat(vat): book the momsrapport as an editable settlement verifikat (#980)

Adds a "Bokfor momsrapporten" card under the VAT declaration that builds
an editable verifikat proposal from the report and books it through the
ordinary journal entry form:

- lib/reports/vat-settlement.ts: proposal builder. Clears each 26xx
  account at exact ore, books the net on 2650 (att betala) or 1650 (att
  aterfa) at the filed whole-krona amount (buildFiledAmounts, oretal
  faller bort per SFL 22 kap 1 par), balances the gap on 3740. Surfaces
  existing vat_settlement entries in the period so the UI can warn
  before a double booking.
- GET /api/reports/vat-declaration/settlement-proposal: same period
  params as the sibling report routes.
- VatBookingCard (reports view): fetches the proposal, warns when the
  period already has a posted settlement or draft, and opens the
  JournalEntryForm (bare, prefilled, source_type vat_settlement) in a
  dialog so every line is editable before committing. Booking uses the
  existing engine path: balance validation, period locks, voucher
  series per source type.
- vat_settlement entries are excluded from the declaration projection
  (calculateVatDeclaration via new shared fetchVatAccountTotals, and
  the MCP computeVatReport for parity): a pure-projection report would
  otherwise read zero, and a later Skatteverket submission would file
  zeros, the moment the settlement is booked.

No migration needed: the vat_settlement source type shipped in
20260708100000.

Closes #980

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vat): block re-booking a settled period, fail loud on lookup errors (CodeRabbit)

The proposal is not delta-aware (it re-clears the FULL period), so a
second booking while a posted settlement exists would corrupt the 26xx
balances: disable "Skapa verifikat" until that verifikat is annulled
(storno restores the balances). And since the existing-settlement
lookup now gates that button, a swallowed query error would silently
re-enable it: throw instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-11 20:16:57 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 5d7127a38a
commit 2774e01258
10 changed files with 937 additions and 54 deletions
+195
View File
@@ -30,6 +30,10 @@ import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { useCompanySettings } from '@/components/settings/useSettings'
import dynamic from 'next/dynamic'
import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { VatSettlementProposal } from '@/lib/reports/vat-settlement'
// Recharts is ~180KB: defer the chart components so report tables (the
// regulated content) render without waiting for the charting bundle.
@@ -46,6 +50,12 @@ const IncomeExpenseChart = dynamic(
() => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart),
{ ssr: false, loading: chartFallback },
)
// The full journal entry editor is heavy (BAS catalogue, comboboxes, review
// dialogs): defer it until the user opens the momsverifikat dialog.
const JournalEntryForm = dynamic(() => import('@/components/bookkeeping/JournalEntryForm'), {
ssr: false,
loading: () => <Skeleton className="h-64 w-full" />,
})
import { useReportRowExpansion } from '@/components/reports/ReportRowExpansion'
import type {
ReportSourceLine,
@@ -1105,6 +1115,184 @@ function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: s
)
}
/**
* "Bokför momsrapport" (issue #980): builds an editable verifikat proposal
* from the momsrapport (clearing the period's 26xx accounts to 2650/1650,
* öre gap on 3740) and books it through the ordinary journal entry form, so
* every line can be adjusted before committing. The proposal comes from
* /api/reports/vat-declaration/settlement-proposal; booking goes through
* POST /api/bookkeeping/journal-entries with source_type 'vat_settlement',
* which the declaration projection excludes, so the report above keeps
* showing the declared figures after booking.
*/
function VatBookingCard({
periodType,
year,
period,
fiscalPeriodId,
}: {
periodType: VatPeriodType
year: number
period: number
fiscalPeriodId?: string
}) {
const { canWrite } = useCanWrite()
const [dialogOpen, setDialogOpen] = useState(false)
const [refreshKey, setRefreshKey] = useState(0)
// Fetch outcome tagged with the key it was requested under; proposal/failed
// are derived by comparing that tag with the current key, so the effect
// never sets state synchronously (same pattern as VatDeclarationView).
const [result, setResult] = useState<{
key: string
proposal?: VatSettlementProposal
failed?: boolean
} | null>(null)
const fetchKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}:${refreshKey}`
useEffect(() => {
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
let cancelled = false
fetch(`/api/reports/vat-declaration/settlement-proposal?${params.toString()}`)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
if (!res.ok || !json?.data) setResult({ key: fetchKey, failed: true })
else setResult({ key: fetchKey, proposal: json.data })
})
.catch(() => {
if (!cancelled) setResult({ key: fetchKey, failed: true })
})
return () => {
cancelled = true
}
}, [fetchKey, periodType, year, period, fiscalPeriodId])
const upToDate = result !== null && result.key === fetchKey
const proposal = upToDate ? (result.proposal ?? null) : null
const failed = upToDate && !!result.failed
const booked = proposal?.existing_entries.find((e) => e.status === 'posted')
const draft = booked ? undefined : proposal?.existing_entries.find((e) => e.status === 'draft')
// FormLine amounts are input strings; the proposal's numbers are already
// öre-rounded server-side, so this is display formatting, not money math.
const initialLines: FormLine[] = (proposal?.lines ?? []).map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount > 0 ? l.debit_amount.toFixed(2) : '',
credit_amount: l.credit_amount > 0 ? l.credit_amount.toFixed(2) : '',
line_description: l.line_description ?? '',
}))
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Bokför momsrapporten</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Skapa ett verifikat som nollställer periodens momskonton och bokför
momsen att betala eller få tillbaka på redovisningskontot. Du granskar
förslaget och kan ändra raderna innan verifikatet bokförs.
</p>
{booked && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p>
Momsen för perioden är redan bokförd:{' '}
<Link
href={`/bookkeeping/${booked.id}`}
className="underline underline-offset-2 hover:text-foreground"
>
verifikat {formatVoucher(booked)} ({formatDate(booked.entry_date)})
</Link>
. Annullera det verifikatet först om perioden behöver bokföras om.
</p>
</div>
)}
{draft && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p>
Det finns redan ett{' '}
<Link
href={`/bookkeeping/${draft.id}`}
className="underline underline-offset-2 hover:text-foreground"
>
utkast för momsen i perioden
</Link>
.
</p>
</div>
)}
{failed ? (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-destructive">Kunde inte hämta verifikatförslaget.</p>
<Button variant="outline" size="sm" onClick={() => setRefreshKey((k) => k + 1)}>
Försök igen
</Button>
</div>
) : proposal?.is_empty ? (
<p className="text-sm text-muted-foreground">Ingen moms att bokföra för perioden.</p>
) : (
<Button
size="sm"
// A posted settlement blocks re-booking: the proposal re-clears the
// FULL period (it is not delta-aware), so booking twice would
// corrupt the 26xx balances. Annulling the verifikat restores them
// and re-enables the button.
disabled={!proposal || !canWrite || !!booked}
onClick={() => setDialogOpen(true)}
>
Skapa verifikat
</Button>
)}
</CardContent>
{proposal && (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// A reviewed-but-unbooked proposal must survive an accidental
// backdrop click or stray Escape (same rationale as
// NewJournalEntryDialog): closing is explicit via the header X.
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Bokför momsrapport</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Förslaget bygger på momsrapporten för {proposal.period_label}. Justera
datum, konton eller belopp vid behov och bokför sedan verifikatet.
</p>
{dialogOpen && (
<JournalEntryForm
bare
sourceType="vat_settlement"
initialDate={proposal.entry_date}
initialDescription={proposal.description}
initialLines={initialLines}
onCreated={() => {
setDialogOpen(false)
setRefreshKey((k) => k + 1)
}}
/>
)}
</DialogContent>
</Dialog>
)}
</Card>
)
}
export function VatDeclarationView() {
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
@@ -1607,6 +1795,13 @@ export function VatDeclarationView() {
</CardContent>
</Card>
<VatBookingCard
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
/>
<VatManualFilingCard
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}