* 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:
co-authored by
Claude Fable 5
parent
5d7127a38a
commit
2774e01258
@@ -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()}`}
|
||||
|
||||
Reference in New Issue
Block a user