feat(vat): show already-booked banner when opening momsdeklaration (#1703)

The settlement check only ran on step 3, so Granska recalculated boxes with no signal that a vat_settlement (or momsomforing) already existed. Load the proposal with the report and reuse that detection for a top banner plus the stepper.

Signed-off-by: Daniel Stenborg <daniel@stenborg.se>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Daniel Stenborg
2026-08-19 20:08:31 +02:00
committed by GitHub
parent eb0df1722b
commit 06e554f3cc
5 changed files with 297 additions and 68 deletions
@@ -0,0 +1,53 @@
'use client'
import Link from 'next/link'
import { CheckCircle2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { VatSettlementExistingEntry } from '@/lib/reports/vat-settlement'
/**
* Top-of-page signal that this momsperiod already has a posted settlement.
* Detection is the same as steg 3 (tagged vat_settlement or shape-detected
* momsomföring). Read-only: does not claim Skatteverket submission.
*/
export function VatAlreadyBookedBanner({
entry,
deadlineCompleted,
}: {
entry: VatSettlementExistingEntry
/** Calendar deadline marked klar — not the same as SKV kvittens. */
deadlineCompleted?: boolean
}) {
const voucher = formatVoucher(entry)
return (
<div
role="status"
className="mx-auto flex w-full max-w-3xl items-start gap-3 rounded-lg border border-success/40 bg-success/5 px-4 py-3 text-[13px] leading-6"
>
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" aria-hidden="true" />
<div className="space-y-1">
<p>
Momsen för perioden är redan bokförd:{' '}
<Link
href={`/bookkeeping/${entry.id}`}
className="underline underline-offset-2 hover:text-foreground"
>
verifikat {voucher}
</Link>
{entry.entry_date ? ` (${formatDate(entry.entry_date)})` : ''}.
</p>
<p className="text-muted-foreground">
Att öppna sidan räknar bara om rutorna. Du behöver inte skapa ett nytt
verifikat.
</p>
{deadlineCompleted && (
<p className="text-muted-foreground">
Deadline för perioden är markerad som klar i kalendern.
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,72 @@
'use client'
import { useEffect, useState } from 'react'
import type { VatPeriodType } from '@/types'
import type { VatSettlementProposal } from '@/lib/reports/vat-settlement'
import {
findDraftVatSettlement,
findPostedVatSettlement,
vatSettlementBookingStatus,
} from '@/lib/reports/vat-settlement'
/**
* Loads the settlement proposal for the open momsperiod so Granska can show
* the already-booked banner without waiting for steg 3. Same endpoint as
* VatBookingCard; tagged by fetch key so a period switch never flashes the
* previous period's voucher.
*/
export function useVatSettlementProposal(opts: {
periodType: VatPeriodType | null
year: number
period: number
fiscalPeriodId?: string
enabled: boolean
refreshKey?: number
}) {
const { periodType, year, period, fiscalPeriodId, enabled, refreshKey = 0 } = opts
const fetchKey =
enabled && periodType
? `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}:${refreshKey}`
: null
const [result, setResult] = useState<{
key: string
proposal?: VatSettlementProposal
failed?: boolean
} | null>(null)
useEffect(() => {
if (!fetchKey || !periodType) return
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 = findPostedVatSettlement(proposal?.existing_entries)
const draft = findDraftVatSettlement(proposal?.existing_entries)
const bookingStatus = proposal
? vatSettlementBookingStatus(proposal.existing_entries)
: null
return { upToDate, proposal, failed, booked, draft, bookingStatus }
}
+79 -67
View File
@@ -42,7 +42,13 @@ 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'
import {
vatDeadlineTaxPeriod,
type VatSettlementExistingEntry,
type VatSettlementProposal,
} from '@/lib/reports/vat-settlement'
import { VatAlreadyBookedBanner } from '@/components/reports/VatAlreadyBookedBanner'
import { useVatSettlementProposal } from '@/components/reports/use-vat-settlement-proposal'
// Recharts is ~180KB: defer the chart components so report tables (the
// regulated content) render without waiting for the charting bundle.
@@ -1124,74 +1130,30 @@ function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: s
* showing the declared figures after booking.
*/
function VatBookingCard({
periodType,
year,
period,
fiscalPeriodId,
checksBlocked,
onStatus,
proposal,
failed,
upToDate,
booked,
draft,
onRetry,
}: {
periodType: VatPeriodType
year: number
period: number
fiscalPeriodId?: string
/**
* True when the local pre-flight checks found ERRORs. Booking stays
* possible (the RC-basis fixes only touch 44xx/45xx pairs, never the 26xx
* accounts the settlement clears), but the user should know before filing.
*/
checksBlocked?: boolean
/** Lets the surrounding stepper mirror the booking state on its dot. */
onStatus?: (status: 'booked' | 'draft' | 'none') => void
/** Settlement proposal loaded by the parent so Granska can reuse it. */
proposal: VatSettlementProposal | null
failed: boolean
upToDate: boolean
booked?: VatSettlementExistingEntry
draft?: VatSettlementExistingEntry
onRetry: () => void
}) {
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')
const bookingStatus = booked ? 'booked' : draft ? 'draft' : 'none'
useEffect(() => {
if (upToDate && proposal) onStatus?.(bookingStatus)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [upToDate, bookingStatus])
// FormLine amounts are input strings; the proposal's numbers are already
// öre-rounded server-side, so this is display formatting, not money math.
@@ -1251,7 +1213,7 @@ function VatBookingCard({
{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" onClick={() => setRefreshKey((k) => k + 1)}>
<Button variant="outline" onClick={onRetry}>
Försök igen
</Button>
</div>
@@ -1306,7 +1268,7 @@ function VatBookingCard({
initialLines={initialLines}
onCreated={() => {
setDialogOpen(false)
setRefreshKey((k) => k + 1)
onRetry()
}}
/>
)}
@@ -1541,7 +1503,8 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
// (errors land on Kontrollera, otherwise Granska). A period switch resets
// to automatic so stale step choices never survive a context change.
const [chosenStep, setChosenStep] = useState<number | null>(null)
const [bookingStatus, setBookingStatus] = useState<'booked' | 'draft' | 'none' | null>(null)
const [settlementRefreshKey, setSettlementRefreshKey] = useState(0)
const [deadlineResult, setDeadlineResult] = useState<{ key: string; completed: boolean } | null>(null)
// Per-verifikat RC-basis scan, fetched here (not only inside VatChecksCard)
// because the filing gate lives here and the worklist unmounts as soon as
// the user leaves steg 1. Tagged with the PERIOD it was requested for (see
@@ -1643,11 +1606,52 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
? null
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}`
const settlement = useVatSettlementProposal({
periodType,
year,
period,
fiscalPeriodId: isYearly ? fiscalPeriodId : undefined,
enabled: fetchKey != null,
refreshKey: settlementRefreshKey,
})
const bookingStatus = settlement.upToDate ? settlement.bookingStatus : null
const taxPeriodKey = periodType ? vatDeadlineTaxPeriod(periodType, year, period) : null
const deadlineCompleted =
!!settlement.booked &&
taxPeriodKey != null &&
deadlineResult?.key === taxPeriodKey &&
deadlineResult.completed
useEffect(() => {
setChosenStep(null)
setBookingStatus(null)
}, [periodType, year, period, fiscalPeriodId])
useEffect(() => {
if (!settlement.booked || !taxPeriodKey) return
const key = taxPeriodKey
let cancelled = false
fetch('/api/deadlines?status=completed')
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
const rows = Array.isArray(json?.data) ? json.data : []
const match = rows.some(
(d: { tax_period?: string | null; tax_deadline_type?: string | null }) =>
d.tax_period === key &&
(d.tax_deadline_type === 'moms_monthly' ||
d.tax_deadline_type === 'moms_quarterly' ||
d.tax_deadline_type === 'moms_yearly'),
)
setDeadlineResult({ key, completed: match })
})
.catch(() => {
if (!cancelled) setDeadlineResult({ key, completed: false })
})
return () => {
cancelled = true
}
}, [settlement.booked, taxPeriodKey])
useEffect(() => {
if (!fetchKey || periodType === null) return
const params = new URLSearchParams({
@@ -1983,6 +1987,13 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
<div
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
>
{settlement.booked && (
<VatAlreadyBookedBanner
entry={settlement.booked}
deadlineCompleted={deadlineCompleted}
/>
)}
{/* Stegen (concept): the filing pipeline as a horizontal stepper —
kontrollera, granska, bokför, lämna in — showing one step's
content at a time. Errors land on step 1, otherwise Granska. */}
@@ -2213,12 +2224,13 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
{activeStep === 3 && (
<section className="mx-auto max-w-3xl space-y-3">
<VatBookingCard
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
checksBlocked={checksBlocked}
onStatus={setBookingStatus}
proposal={settlement.proposal}
failed={settlement.failed}
upToDate={settlement.upToDate}
booked={settlement.booked}
draft={settlement.draft}
onRetry={() => setSettlementRefreshKey((k) => k + 1)}
/>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setChosenStep(4)}>
+51 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { buildVatSettlementProposal } from '../vat-settlement'
import {
buildVatSettlementProposal,
findDraftVatSettlement,
findPostedVatSettlement,
vatDeadlineTaxPeriod,
vatSettlementBookingStatus,
type VatSettlementExistingEntry,
} from '../vat-settlement'
// ============================================================
// Mock: fetchVatAccountTotals now goes through the
@@ -304,3 +311,46 @@ describe('buildVatSettlementProposal', () => {
).rejects.toThrow('existing vat_settlement lookup failed: boom')
})
})
describe('vat settlement UI gate helpers', () => {
const posted: VatSettlementExistingEntry = {
id: 'je-posted',
status: 'posted',
entry_date: '2026-06-30',
source_type: 'vat_settlement',
voucher_series: 'A',
voucher_number: 83,
}
const draft: VatSettlementExistingEntry = {
id: 'je-draft',
status: 'draft',
entry_date: '2026-06-30',
source_type: 'vat_settlement',
voucher_series: 'A',
voucher_number: 84,
}
it('picks the posted settlement over a draft', () => {
expect(findPostedVatSettlement([draft, posted])).toEqual(posted)
expect(findDraftVatSettlement([draft, posted])).toBeUndefined()
expect(vatSettlementBookingStatus([draft, posted])).toBe('booked')
})
it('surfaces a draft only when nothing is posted', () => {
expect(findPostedVatSettlement([draft])).toBeUndefined()
expect(findDraftVatSettlement([draft])).toEqual(draft)
expect(vatSettlementBookingStatus([draft])).toBe('draft')
})
it('is none when the period has no settlement', () => {
expect(findPostedVatSettlement([])).toBeUndefined()
expect(vatSettlementBookingStatus([])).toBe('none')
expect(vatSettlementBookingStatus(undefined)).toBe('none')
})
it('formats the calendar tax_period for monthly and quarterly VAT', () => {
expect(vatDeadlineTaxPeriod('monthly', 2026, 6)).toBe('2026-06')
expect(vatDeadlineTaxPeriod('quarterly', 2026, 2)).toBe('2026-Q2')
expect(vatDeadlineTaxPeriod('yearly', 2026, 1)).toBeNull()
})
})
+42
View File
@@ -88,6 +88,48 @@ export interface VatSettlementProposal {
existing_entries: VatSettlementExistingEntry[]
}
/**
* Posted settlement that gates re-booking. Same rule as VatBookingCard:
* tagged `vat_settlement` and shape-detected momsomföring both land in
* `existing_entries`; the first posted row is the one the UI links to.
*/
export function findPostedVatSettlement(
entries: VatSettlementExistingEntry[] | undefined,
): VatSettlementExistingEntry | undefined {
return entries?.find((e) => e.status === 'posted')
}
/** Draft settlement, ignored when a posted one already exists. */
export function findDraftVatSettlement(
entries: VatSettlementExistingEntry[] | undefined,
): VatSettlementExistingEntry | undefined {
if (findPostedVatSettlement(entries)) return undefined
return entries?.find((e) => e.status === 'draft')
}
export function vatSettlementBookingStatus(
entries: VatSettlementExistingEntry[] | undefined,
): 'booked' | 'draft' | 'none' {
if (findPostedVatSettlement(entries)) return 'booked'
if (findDraftVatSettlement(entries)) return 'draft'
return 'none'
}
/**
* `deadlines.tax_period` key for a VAT picker. Yearly (helårsmoms) is omitted:
* that row uses a fiscal-year label that needs company settings, and guessing
* calendar `YYYY` would mis-label broken räkenskapsår.
*/
export function vatDeadlineTaxPeriod(
periodType: VatPeriodType,
year: number,
period: number,
): string | null {
if (periodType === 'monthly') return `${year}-${String(period).padStart(2, '0')}`
if (periodType === 'quarterly') return `${year}-Q${period}`
return null
}
/**
* Build the settlement verifikat proposal for a VAT period. Reads the same
* aggregated ledger totals as the momsrapport (fetchVatAccountTotals), so the