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:
@@ -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 }
|
||||
}
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user