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:
co-authored by
Cursor
parent
eb0df1722b
commit
06e554f3cc
@@ -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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
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
|
// Recharts is ~180KB: defer the chart components so report tables (the
|
||||||
// regulated content) render without waiting for the charting bundle.
|
// 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.
|
* showing the declared figures after booking.
|
||||||
*/
|
*/
|
||||||
function VatBookingCard({
|
function VatBookingCard({
|
||||||
periodType,
|
|
||||||
year,
|
|
||||||
period,
|
|
||||||
fiscalPeriodId,
|
|
||||||
checksBlocked,
|
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
|
* True when the local pre-flight checks found ERRORs. Booking stays
|
||||||
* possible (the RC-basis fixes only touch 44xx/45xx pairs, never the 26xx
|
* possible (the RC-basis fixes only touch 44xx/45xx pairs, never the 26xx
|
||||||
* accounts the settlement clears), but the user should know before filing.
|
* accounts the settlement clears), but the user should know before filing.
|
||||||
*/
|
*/
|
||||||
checksBlocked?: boolean
|
checksBlocked?: boolean
|
||||||
/** Lets the surrounding stepper mirror the booking state on its dot. */
|
/** Settlement proposal loaded by the parent so Granska can reuse it. */
|
||||||
onStatus?: (status: 'booked' | 'draft' | 'none') => void
|
proposal: VatSettlementProposal | null
|
||||||
|
failed: boolean
|
||||||
|
upToDate: boolean
|
||||||
|
booked?: VatSettlementExistingEntry
|
||||||
|
draft?: VatSettlementExistingEntry
|
||||||
|
onRetry: () => void
|
||||||
}) {
|
}) {
|
||||||
const { canWrite } = useCanWrite()
|
const { canWrite } = useCanWrite()
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
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
|
// FormLine amounts are input strings; the proposal's numbers are already
|
||||||
// öre-rounded server-side, so this is display formatting, not money math.
|
// öre-rounded server-side, so this is display formatting, not money math.
|
||||||
@@ -1251,7 +1213,7 @@ function VatBookingCard({
|
|||||||
{failed ? (
|
{failed ? (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<p className="text-sm text-destructive">Kunde inte hämta verifikatförslaget.</p>
|
<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
|
Försök igen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1306,7 +1268,7 @@ function VatBookingCard({
|
|||||||
initialLines={initialLines}
|
initialLines={initialLines}
|
||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setDialogOpen(false)
|
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
|
// (errors land on Kontrollera, otherwise Granska). A period switch resets
|
||||||
// to automatic so stale step choices never survive a context change.
|
// to automatic so stale step choices never survive a context change.
|
||||||
const [chosenStep, setChosenStep] = useState<number | null>(null)
|
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)
|
// Per-verifikat RC-basis scan, fetched here (not only inside VatChecksCard)
|
||||||
// because the filing gate lives here and the worklist unmounts as soon as
|
// 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
|
// 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
|
? null
|
||||||
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}`
|
: `${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(() => {
|
useEffect(() => {
|
||||||
setChosenStep(null)
|
setChosenStep(null)
|
||||||
setBookingStatus(null)
|
|
||||||
}, [periodType, year, period, fiscalPeriodId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!fetchKey || periodType === null) return
|
if (!fetchKey || periodType === null) return
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -1983,6 +1987,13 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
|||||||
<div
|
<div
|
||||||
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
|
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 —
|
{/* Stegen (concept): the filing pipeline as a horizontal stepper —
|
||||||
kontrollera, granska, bokför, lämna in — showing one step's
|
kontrollera, granska, bokför, lämna in — showing one step's
|
||||||
content at a time. Errors land on step 1, otherwise Granska. */}
|
content at a time. Errors land on step 1, otherwise Granska. */}
|
||||||
@@ -2213,12 +2224,13 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
|||||||
{activeStep === 3 && (
|
{activeStep === 3 && (
|
||||||
<section className="mx-auto max-w-3xl space-y-3">
|
<section className="mx-auto max-w-3xl space-y-3">
|
||||||
<VatBookingCard
|
<VatBookingCard
|
||||||
periodType={periodType}
|
|
||||||
year={year}
|
|
||||||
period={period}
|
|
||||||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
|
||||||
checksBlocked={checksBlocked}
|
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">
|
<div className="flex justify-end">
|
||||||
<Button variant="outline" size="sm" onClick={() => setChosenStep(4)}>
|
<Button variant="outline" size="sm" onClick={() => setChosenStep(4)}>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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
|
// Mock: fetchVatAccountTotals now goes through the
|
||||||
@@ -304,3 +311,46 @@ describe('buildVatSettlementProposal', () => {
|
|||||||
).rejects.toThrow('existing vat_settlement lookup failed: boom')
|
).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[]
|
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
|
* Build the settlement verifikat proposal for a VAT period. Reads the same
|
||||||
* aggregated ledger totals as the momsrapport (fetchVatAccountTotals), so the
|
* aggregated ledger totals as the momsrapport (fetchVatAccountTotals), so the
|
||||||
|
|||||||
Reference in New Issue
Block a user