diff --git a/components/reports/VatAlreadyBookedBanner.tsx b/components/reports/VatAlreadyBookedBanner.tsx
new file mode 100644
index 00000000..27f73c6e
--- /dev/null
+++ b/components/reports/VatAlreadyBookedBanner.tsx
@@ -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 (
+
+
+
+
+ Momsen för perioden är redan bokförd:{' '}
+
+ verifikat {voucher}
+
+ {entry.entry_date ? ` (${formatDate(entry.entry_date)})` : ''}.
+
+
+ Att öppna sidan räknar bara om rutorna. Du behöver inte skapa ett nytt
+ verifikat.
+
+ {deadlineCompleted && (
+
+ Deadline för perioden är markerad som klar i kalendern.
+
+ )}
+
+
+ )
+}
diff --git a/components/reports/use-vat-settlement-proposal.ts b/components/reports/use-vat-settlement-proposal.ts
new file mode 100644
index 00000000..7ace86fe
--- /dev/null
+++ b/components/reports/use-vat-settlement-proposal.ts
@@ -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 }
+}
diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx
index b77ee6c3..a326912c 100644
--- a/components/reports/views/index.tsx
+++ b/components/reports/views/index.tsx
@@ -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 ? (
Kunde inte hämta verifikatförslaget.
-
@@ -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(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 } = {}) {
+ {settlement.booked && (
+
+ )}
+
{/* 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 && (
setSettlementRefreshKey((k) => k + 1)}
/>
setChosenStep(4)}>
diff --git a/lib/reports/__tests__/vat-settlement.test.ts b/lib/reports/__tests__/vat-settlement.test.ts
index 68012fc5..ade0c5bb 100644
--- a/lib/reports/__tests__/vat-settlement.test.ts
+++ b/lib/reports/__tests__/vat-settlement.test.ts
@@ -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()
+ })
+})
diff --git a/lib/reports/vat-settlement.ts b/lib/reports/vat-settlement.ts
index 03d15849..d7c7c5f0 100644
--- a/lib/reports/vat-settlement.ts
+++ b/lib/reports/vat-settlement.ts
@@ -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