feat(vat): book the momsrapport as an editable settlement verifikat (#980) (#983)

* 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:
Jakob Wennberg
2026-07-11 20:16:57 +02:00
committed by GitHub
parent 5d7127a38a
commit 2774e01258
10 changed files with 937 additions and 54 deletions
+2
View File
@@ -58,3 +58,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-09] common.delete changed "Radera" to "Ta bort": grep proved the key has zero live call sites (every delete dialog uses feature-namespace keys), so this only affects future uses; convention going forward is Ta bort = detach/remove, Radera = irreversible destruction (kept in AccountDangerZone/CompanyDangerZone keys).
[2026-07-09] InvoiceEditor customer-card description kept only for the self-billing branch (issuer_card_description adds real info: who issues the invoice); the plain-invoice branch dropped its description as a title paraphrase per design.md forbidden patterns.
[2026-07-09] SalaryCalendar absence-type rainbow palette (red/amber/emerald/blue/indigo pills) left as-is in the UI consistency pass: those colors encode absence categories (data), not status chrome, and swapping them for the 3 semantic tokens would collapse 5 distinguishable categories; needs a proper categorical-palette decision instead of a mechanical fix.
[2026-07-10] Momsverifikat from momsrapport (#980): the proposal clears each 26xx account at exact öre but books the 2650/1650 net at the FILED whole-krona amount (buildFiledAmounts, öretal faller bort) with the gap on 3740, so redovisningskontot always matches the skattekonto movement; and vat_settlement entries are excluded from the VAT report projection (web calculateVatDeclaration + MCP computeVatReport) because a pure-projection report would otherwise read zero (and Skatteverket submission would file zeros) the moment the settlement is booked.
[2026-07-10] VatBookingCard hard-disables "Skapa verifikat" while a POSTED vat_settlement exists in the period (CodeRabbit finding, accepted over the initial warn-but-allow): the proposal is not delta-aware (it re-clears the FULL period), so booking twice corrupts 26xx balances; the sanctioned redo path is annullera (storno restores the balances and re-enables the button). Already-booked detection is by source_type + entry_date within the period, so redating the entry outside the period escapes the gate: accepted v1 limitation. Card copy is hardcoded Swedish per the file's existing momsdeklaration convention (i18n.md).
@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
const mockSupabase = {
auth: { getUser: vi.fn() },
from: vi.fn(),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/reports/vat-settlement', () => ({
buildVatSettlementProposal: vi.fn(),
}))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { buildVatSettlementProposal } from '@/lib/reports/vat-settlement'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function makeProposal() {
return {
period: { type: 'quarterly', year: 2026, period: 1, start: '2026-01-01', end: '2026-03-31' },
period_label: 'Kvartal 1 2026',
entry_date: '2026-03-31',
description: 'Momsredovisning Kvartal 1 2026',
lines: [
{ account_number: '2611', debit_amount: 2500.75, credit_amount: 0 },
{ account_number: '2641', debit_amount: 0, credit_amount: 1000.5 },
{
account_number: '2650', debit_amount: 0, credit_amount: 1500,
line_description: 'Moms att betala',
},
{
account_number: '3740', debit_amount: 0, credit_amount: 0.25,
line_description: 'Öres- och kronutjämning',
},
],
filed_net: 1500,
rounding_amount: 0.25,
is_empty: false,
existing_entries: [],
}
}
describe('GET /api/reports/vat-declaration/settlement-proposal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
vi.mocked(buildVatSettlementProposal).mockResolvedValue(makeProposal() as never)
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = new Request(
'http://localhost/api/reports/vat-declaration/settlement-proposal?periodType=quarterly&year=2026&period=1',
)
const res = await GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(401)
expect(buildVatSettlementProposal).not.toHaveBeenCalled()
})
it('returns 400 when period params are missing', async () => {
const req = new Request('http://localhost/api/reports/vat-declaration/settlement-proposal')
const res = await GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(400)
expect(buildVatSettlementProposal).not.toHaveBeenCalled()
})
it('returns 400 for an invalid period type', async () => {
const req = new Request(
'http://localhost/api/reports/vat-declaration/settlement-proposal?periodType=weekly&year=2026&period=1',
)
const res = await GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(400)
})
it('returns 400 for an out-of-range period', async () => {
const req = new Request(
'http://localhost/api/reports/vat-declaration/settlement-proposal?periodType=quarterly&year=2026&period=5',
)
const res = await GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(400)
expect(buildVatSettlementProposal).not.toHaveBeenCalled()
})
it('happy path: returns the proposal', async () => {
const req = new Request(
'http://localhost/api/reports/vat-declaration/settlement-proposal?periodType=quarterly&year=2026&period=1',
)
const res = await GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.data.filed_net).toBe(1500)
expect(json.data.lines).toHaveLength(4)
expect(buildVatSettlementProposal).toHaveBeenCalledWith(
mockSupabase, 'company-1', 'quarterly', 2026, 1, { fiscalPeriodId: undefined },
)
})
it('forwards the fiscal period for yearly VAT', async () => {
const req = new Request(
'http://localhost/api/reports/vat-declaration/settlement-proposal?periodType=yearly&year=2026&period=1&fiscal_period_id=fp-1',
)
const res = await GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(200)
expect(buildVatSettlementProposal).toHaveBeenCalledWith(
mockSupabase, 'company-1', 'yearly', 2026, 1, { fiscalPeriodId: 'fp-1' },
)
})
it('returns 500 when the builder fails', async () => {
vi.mocked(buildVatSettlementProposal).mockRejectedValue(new Error('boom'))
const req = new Request(
'http://localhost/api/reports/vat-declaration/settlement-proposal?periodType=quarterly&year=2026&period=1',
)
const res = await GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error.code).toBe('VAT_REPORT_GENERATION_FAILED')
})
})
@@ -0,0 +1,83 @@
import { NextResponse } from 'next/server'
import { buildVatSettlementProposal } from '@/lib/reports/vat-settlement'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { VatPeriodType } from '@/types'
/**
* GET /api/reports/vat-declaration/settlement-proposal
*
* Builds the momsredovisning verifikat proposal for a VAT period (issue #980):
* the editable lines that clear the period's 26xx accounts to 2650/1650. The
* proposal is computed from the same ledger projection as the momsrapport;
* booking happens separately through POST /api/bookkeeping/journal-entries
* with source_type 'vat_settlement' once the user has reviewed the lines.
*
* Query parameters (same shape as /api/reports/vat-declaration):
* periodType: 'monthly' | 'quarterly' | 'yearly'
* year: number (e.g., 2026)
* period: number (1-12 monthly, 1-4 quarterly, 1 yearly)
* fiscal_period_id: optional; yearly only (räkenskapsår bounds)
*/
export const GET = withRouteContext(
'report.vat_settlement_proposal',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const periodType = searchParams.get('periodType') as VatPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined
if (!periodType || !yearStr || !periodStr) {
return errorResponseFromCode('VAT_REPORT_MISSING_PARAMS', log, { requestId })
}
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD_TYPE', log, {
requestId,
details: { received: periodType },
})
}
const year = parseInt(yearStr, 10)
const period = parseInt(periodStr, 10)
if (isNaN(year) || year < 2000 || year > 2100) {
return errorResponseFromCode('VAT_REPORT_INVALID_YEAR', log, {
requestId,
details: { received: yearStr },
})
}
if (
isNaN(period) ||
(periodType === 'monthly' && (period < 1 || period > 12)) ||
(periodType === 'quarterly' && (period < 1 || period > 4)) ||
(periodType === 'yearly' && period !== 1)
) {
return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD', log, {
requestId,
details: { periodType, received: periodStr },
})
}
try {
const proposal = await buildVatSettlementProposal(
supabase, companyId!, periodType, year, period, { fiscalPeriodId },
)
return NextResponse.json({ data: proposal })
} catch (err) {
log.error('vat settlement proposal failed', err as Error, {
periodType,
year,
period,
})
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
+195
View File
@@ -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 tillbaka 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 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()}`}
@@ -26,6 +26,7 @@ function mockSupabaseWithLines(lines: MockLine[]) {
chain.range = () => terminal
chain.lte = () => chain
chain.gte = () => chain
chain.neq = () => chain
chain.in = () => chain
chain.eq = () => chain
chain.select = () => chain
+4
View File
@@ -1081,6 +1081,10 @@ export async function computeVatReport(
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
// Momsredovisning entries (the settlement verifikat clearing 26xx to
// 2650/1650) would zero the rutor once booked; exclude them so this
// report matches lib/reports/vat-declaration.ts (fetchVatAccountTotals).
.neq('journal_entries.source_type', 'vat_settlement')
.gte('journal_entries.entry_date', startDate)
.lte('journal_entries.entry_date', endDate)
.range(from, to)
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) {
for (const m of ['select', 'eq', 'neq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -0,0 +1,226 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { buildVatSettlementProposal } from '../vat-settlement'
// ============================================================
// Mock: results routed by table + select shape (the builder runs its two
// ledger queries and the existing-entries lookup concurrently, so a
// sequential result queue would be order-fragile).
// ============================================================
interface MockData {
/** journal_entries rows for the entry-scope query (fetchEntryLines step 1). */
entries?: Array<{ id: string }>
/** journal_entry_lines rows (fetchEntryLines step 2). */
lines?: Array<Record<string, unknown>>
/** Existing vat_settlement entries in the period. */
existing?: Array<Record<string, unknown>>
/** Error returned by the existing-settlement lookup. */
existingError?: { message: string }
/** fiscal_periods row for yearly (helårsmoms) bounds. */
fiscalPeriod?: { period_start: string; period_end: string } | null
}
let neqCalls: Array<[string, unknown]>
function makeClient(data: MockData) {
neqCalls = []
return {
from: vi.fn().mockImplementation((table: string) => {
let selectStr = ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: Record<string, any> = {}
b.select = vi.fn().mockImplementation((s: string) => {
selectStr = s
return b
})
for (const m of ['eq', 'in', 'gte', 'lte', 'order', 'range', 'limit']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.neq = vi.fn().mockImplementation((col: string, val: unknown) => {
neqCalls.push([col, val])
return b
})
b.maybeSingle = vi.fn().mockResolvedValue({ data: data.fiscalPeriod ?? null, error: null })
b.then = (resolve: (v: unknown) => void) => {
if (table === 'journal_entry_lines') return resolve({ data: data.lines ?? [], error: null })
// journal_entries serves two queries: the entry scope for the ledger
// totals (select 'id') and the existing-settlement lookup (selects
// voucher columns).
if (selectStr.includes('voucher_series')) {
return resolve(
data.existingError
? { data: null, error: data.existingError }
: { data: data.existing ?? [], error: null },
)
}
return resolve({ data: data.entries ?? [], error: null })
}
return b
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
let lineId = 0
function vatLine(account: string, debit: number, credit: number) {
lineId += 1
return {
id: `l${lineId}`,
journal_entry_id: 'e1',
account_number: account,
debit_amount: debit,
credit_amount: credit,
}
}
beforeEach(() => {
vi.clearAllMocks()
lineId = 0
})
describe('buildVatSettlementProposal', () => {
it('clears the 26xx accounts, books the filed whole-krona net on 2650 and the öre gap on 3740', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [
vatLine('2611', 0, 2500.75),
vatLine('2641', 1000.5, 0),
// Revenue feeds ruta05 but is never part of the settlement entry.
vatLine('3001', 0, 10003.0),
],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
expect(proposal.period).toEqual({
type: 'quarterly', year: 2026, period: 1, start: '2026-01-01', end: '2026-03-31',
})
expect(proposal.entry_date).toBe('2026-03-31')
expect(proposal.description).toBe('Momsredovisning Kvartal 1 2026')
expect(proposal.is_empty).toBe(false)
// Filed net = trunc(2500.75) - trunc(1000.50) = 1500 (öretal faller bort)
expect(proposal.filed_net).toBe(1500)
expect(proposal.rounding_amount).toBe(0.25)
expect(proposal.lines).toEqual([
{ account_number: '2611', debit_amount: 2500.75, credit_amount: 0 },
{ account_number: '2641', debit_amount: 0, credit_amount: 1000.5 },
{
account_number: '2650', debit_amount: 0, credit_amount: 1500,
line_description: 'Moms att betala',
},
{
account_number: '3740', debit_amount: 0, credit_amount: 0.25,
line_description: 'Öres- och kronutjämning',
},
])
// The proposed entry always balances.
const debits = proposal.lines.reduce((s, l) => s + l.debit_amount, 0)
const credits = proposal.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(debits).toBeCloseTo(credits, 2)
// The projection must ignore already-booked settlements, or booking once
// would change the next proposal.
expect(neqCalls).toContainEqual(['source_type', 'vat_settlement'])
})
it('books a refund period as a 1650 (Momsfordran) debit', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [
vatLine('2611', 0, 100),
vatLine('2641', 400, 0),
],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'monthly', 2026, 6)
expect(proposal.filed_net).toBe(-300)
expect(proposal.rounding_amount).toBe(0)
expect(proposal.lines).toEqual([
{ account_number: '2611', debit_amount: 100, credit_amount: 0 },
{ account_number: '2641', debit_amount: 0, credit_amount: 400 },
{
account_number: '1650', debit_amount: 300, credit_amount: 0,
line_description: 'Moms att återfå',
},
])
})
it('clears an account sitting on the wrong side (credit-note-heavy period)', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
// Output VAT with a net DEBIT balance: credit notes exceeded sales.
lines: [vatLine('2611', 50, 0)],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'monthly', 2026, 2)
expect(proposal.filed_net).toBe(-50)
expect(proposal.lines).toEqual([
{ account_number: '2611', debit_amount: 0, credit_amount: 50 },
{
account_number: '1650', debit_amount: 50, credit_amount: 0,
line_description: 'Moms att återfå',
},
])
})
it('is empty when the period has no VAT-account activity (revenue alone does not settle)', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [vatLine('3001', 0, 1000)],
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 2)
expect(proposal.is_empty).toBe(true)
expect(proposal.lines).toEqual([])
expect(proposal.filed_net).toBe(0)
})
it('uses the räkenskapsår bounds for yearly VAT when a fiscal period is supplied', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [vatLine('2611', 0, 100), vatLine('2641', 25, 0)],
fiscalPeriod: { period_start: '2025-07-01', period_end: '2026-06-30' },
})
const proposal = await buildVatSettlementProposal(
supabase, 'company-1', 'yearly', 2026, 1, { fiscalPeriodId: 'fp-1' },
)
expect(proposal.period.start).toBe('2025-07-01')
expect(proposal.period.end).toBe('2026-06-30')
expect(proposal.entry_date).toBe('2026-06-30')
expect(proposal.description).toBe('Momsredovisning Helår 2026')
})
it('surfaces existing vat_settlement entries in the period', async () => {
const existing = [{
id: 'je-1', status: 'posted', entry_date: '2026-03-31',
voucher_series: 'M', voucher_number: 3,
}]
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [vatLine('2611', 0, 100)],
existing,
})
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
expect(proposal.existing_entries).toEqual(existing)
})
it('throws when the existing-settlement lookup fails (the UI gate depends on it)', async () => {
const supabase = makeClient({
entries: [{ id: 'e1' }],
lines: [vatLine('2611', 0, 100)],
existingError: { message: 'boom' },
})
await expect(
buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1),
).rejects.toThrow('existing vat_settlement lookup failed: boom')
})
})
+88 -53
View File
@@ -216,7 +216,7 @@ function round(value: number): number {
* can't be resolved we fall back to the calendar span so behaviour degrades
* gracefully instead of erroring.
*/
async function resolvePeriodDates(
export async function resolvePeriodDates(
supabase: SupabaseClient,
companyId: string,
periodType: VatPeriodType,
@@ -238,6 +238,90 @@ async function resolvePeriodDates(
return calculatePeriodDates(periodType, year, period)
}
/**
* Fetch and aggregate debit/credit totals per VAT-relevant account
* (ACCOUNT_RUTA) for a period. Shared by the declaration calculation and the
* settlement proposal (lib/reports/vat-settlement.ts) so the two can never
* disagree on which ledger lines count.
*
* Momsredovisning entries (source_type 'vat_settlement': the verifikat that
* clears the 26xx accounts to 2650/1650) are excluded. They are bookkeeping
* about the declaration, not VAT-bearing business activity; including them
* would zero out the rutor the moment the settlement is booked, turning the
* report, its exports, and a later Skatteverket submission into an empty
* declaration.
*/
export async function fetchVatAccountTotals(
supabase: SupabaseClient,
companyId: string,
start: string,
end: string
): Promise<Map<string, { debit: number; credit: number }>> {
const lines = await fetchEntryLines<{
account_number: string
debit_amount: number
credit_amount: number
}>({
supabase,
lineColumns: 'account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.neq('source_type', 'vat_settlement')
.gte('entry_date', start)
.lte('entry_date', end),
filterLines: (q: EntryLinesQuery) => q.in('account_number', VAT_ACCOUNTS),
})
const totals = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
t.debit += Number(line.debit_amount) || 0
t.credit += Number(line.credit_amount) || 0
totals.set(line.account_number, t)
}
return totals
}
/**
* Map aggregated per-account totals to the momsdeklaration boxes, including
* the recomputed ruta 49 net (FK009). Pure projection over ACCOUNT_RUTA.
*/
export function rutorFromTotals(
totals: Map<string, { debit: number; credit: number }>
): VatDeclarationRutor {
const rutor: VatDeclarationRutor = {
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
ruta30: 0, ruta31: 0, ruta32: 0,
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0,
ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
ruta48: 0, ruta49: 0,
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
}
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
const t = totals.get(account)
if (!t) continue
const balance = mapping.side === 'credit'
? t.credit - t.debit
: t.debit - t.credit
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
// FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
rutor.ruta49 = round(
rutor.ruta10 + rutor.ruta11 + rutor.ruta12 +
rutor.ruta30 + rutor.ruta31 + rutor.ruta32 +
rutor.ruta60 + rutor.ruta61 + rutor.ruta62 -
rutor.ruta48
)
return rutor
}
/**
* Calculate VAT declaration from the general ledger.
*
@@ -265,60 +349,11 @@ export async function calculateVatDeclaration(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
const lines = await fetchEntryLines<{
account_number: string
debit_amount: number
credit_amount: number
}>({
supabase,
lineColumns: 'account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', start)
.lte('entry_date', end),
filterLines: (q: EntryLinesQuery) => q.in('account_number', VAT_ACCOUNTS),
})
// Aggregate debit/credit totals per account
const totals = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
t.debit += Number(line.debit_amount) || 0
t.credit += Number(line.credit_amount) || 0
totals.set(line.account_number, t)
}
// Fetch and aggregate posted VAT-account activity for the period
const totals = await fetchVatAccountTotals(supabase, companyId, start, end)
// Map account balances to momsdeklaration boxes
const rutor: VatDeclarationRutor = {
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
ruta30: 0, ruta31: 0, ruta32: 0,
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0,
ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
ruta48: 0, ruta49: 0,
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
}
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
const t = totals.get(account)
if (!t) continue
const balance = mapping.side === 'credit'
? t.credit - t.debit
: t.debit - t.credit
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
// FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
rutor.ruta49 = round(
rutor.ruta10 + rutor.ruta11 + rutor.ruta12 +
rutor.ruta30 + rutor.ruta31 + rutor.ruta32 +
rutor.ruta60 + rutor.ruta61 + rutor.ruta62 -
rutor.ruta48
)
const rutor = rutorFromTotals(totals)
// Compute per-rate base amounts from individual revenue accounts
const revenueByRate = {
+197
View File
@@ -0,0 +1,197 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import {
fetchVatAccountTotals,
formatPeriodLabel,
resolvePeriodDates,
rutorFromTotals,
VAT_INPUT_ACCOUNTS,
VAT_OUTPUT_ACCOUNTS,
} from './vat-declaration'
import { buildFiledAmounts } from './vat-manual-filing'
import type { VatPeriodType } from '@/types'
/**
* Momsredovisning settlement proposal (issue #980): the verifikat that closes
* a VAT period by clearing every 26xx account the momsrapport reads from into
* the redovisningskonto.
*
* Shape of the proposed entry (standard Swedish momsomföring, booked on the
* period's last day):
* - each output-VAT account (261x/262x/263x incl. reverse charge + import)
* is debited by its period balance, each input-VAT account (264x) is
* credited, at exact öre so the accounts land on zero for the period;
* - the net goes to 2650 (Redovisningskonto för moms, credit = att betala)
* or 1650 (Momsfordran, debit = att återfå) at the WHOLE-KRONA amount the
* declaration is filed with (buildFiledAmounts: öretal faller bort per
* SFL 22 kap 1 §), so 2650/1650 always matches the skattekonto movement;
* - the öre gap between the exact clearing lines and the filed net is
* balanced on 3740 (Öres- och kronutjämning).
*
* This is a PROPOSAL: the user reviews and edits the lines in the journal
* entry form before committing, and the entry books through the normal
* engine (balance validation, period locks, voucher numbering) with
* source_type 'vat_settlement'. That source type is excluded from the
* declaration projection (see fetchVatAccountTotals), so booking the
* settlement never changes the report it was created from.
*/
/** Redovisningskonto för moms: net VAT to pay (credit). */
export const VAT_SETTLEMENT_ACCOUNT = '2650'
/** Momsfordran: net VAT refund (debit). */
export const VAT_REFUND_ACCOUNT = '1650'
/** Öres- och kronutjämning: absorbs the filed whole-krona truncation gap. */
export const VAT_ROUNDING_ACCOUNT = '3740'
export interface VatSettlementProposalLine {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}
/** A vat_settlement entry already booked (or drafted) inside the period. */
export interface VatSettlementExistingEntry {
id: string
status: string
entry_date: string
voucher_series: string | null
voucher_number: number | null
}
export interface VatSettlementProposal {
period: {
type: VatPeriodType
year: number
period: number
start: string
end: string
}
/** Swedish period label, e.g. "Kvartal 1 2026" (Skatteverket-bound wording). */
period_label: string
/** Proposed entry date: the period's last day. */
entry_date: string
/** Proposed verifikationstext, e.g. "Momsredovisning Kvartal 1 2026". */
description: string
lines: VatSettlementProposalLine[]
/** Ruta 49 as filed (whole kronor, signed: positive = att betala). */
filed_net: number
/** Signed öre gap balanced on 3740 (positive = credited, negative = debited). */
rounding_amount: number
/** True when the period has no VAT activity to clear. */
is_empty: boolean
existing_entries: VatSettlementExistingEntry[]
}
/**
* Build the settlement verifikat proposal for a VAT period. Reads the same
* aggregated ledger totals as the momsrapport (fetchVatAccountTotals), so the
* proposal always ties out with the report on screen and the filed eSKD/PDF
* amounts.
*/
export async function buildVatSettlementProposal(
supabase: SupabaseClient,
companyId: string,
periodType: VatPeriodType,
year: number,
period: number,
options: { fiscalPeriodId?: string } = {}
): Promise<VatSettlementProposal> {
// Yearly (helårsmoms) resolves to the räkenskapsår bounds when a fiscal
// period is supplied: same resolution as the declaration itself.
const { start, end } = await resolvePeriodDates(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
const [totals, existingResult] = await Promise.all([
fetchVatAccountTotals(supabase, companyId, start, end),
supabase
.from('journal_entries')
.select('id, status, entry_date, voucher_series, voucher_number')
.eq('company_id', companyId)
.eq('source_type', 'vat_settlement')
.in('status', ['draft', 'posted'])
.gte('entry_date', start)
.lte('entry_date', end)
.order('entry_date', { ascending: false })
.limit(5),
])
// The existing-settlement lookup gates the UI's "already booked" warning
// and its create button; a swallowed error here would silently re-enable
// booking a period that already has a settlement, so fail loud instead.
if (existingResult.error) {
throw new Error(
`existing vat_settlement lookup failed: ${existingResult.error.message}`
)
}
const rutor = rutorFromTotals(totals)
const { net: filedNet } = buildFiledAmounts(rutor)
// Clear every 26xx account the declaration reads from, at exact öre, so the
// accounts land on zero for the period. A positive (credit) balance clears
// with a debit and vice versa: the same formula handles credit-note-heavy
// periods where an account sits on the "wrong" side.
const clearingAccounts = [...new Set([...VAT_OUTPUT_ACCOUNTS, ...VAT_INPUT_ACCOUNTS])].sort()
const lines: VatSettlementProposalLine[] = []
for (const account of clearingAccounts) {
const t = totals.get(account)
if (!t) continue
const balance = roundOre(t.credit - t.debit)
if (balance > 0) {
lines.push({ account_number: account, debit_amount: balance, credit_amount: 0 })
} else if (balance < 0) {
lines.push({ account_number: account, debit_amount: 0, credit_amount: -balance })
}
}
if (lines.length > 0) {
if (filedNet > 0) {
lines.push({
account_number: VAT_SETTLEMENT_ACCOUNT,
debit_amount: 0,
credit_amount: filedNet,
line_description: 'Moms att betala',
})
} else if (filedNet < 0) {
lines.push({
account_number: VAT_REFUND_ACCOUNT,
debit_amount: -filedNet,
credit_amount: 0,
line_description: 'Moms att återfå',
})
}
}
// Balance the öre/krona gap left by the whole-krona filed net on 3740.
let roundingAmount = 0
if (lines.length > 0) {
const gap = roundOre(
lines.reduce((sum, l) => sum + l.debit_amount - l.credit_amount, 0)
)
if (gap !== 0) {
roundingAmount = gap
lines.push({
account_number: VAT_ROUNDING_ACCOUNT,
debit_amount: gap < 0 ? -gap : 0,
credit_amount: gap > 0 ? gap : 0,
line_description: 'Öres- och kronutjämning',
})
}
}
const periodLabel = formatPeriodLabel(periodType, year, period)
return {
period: { type: periodType, year, period, start, end },
period_label: periodLabel,
entry_date: end,
description: `Momsredovisning ${periodLabel}`,
lines,
filed_net: filedNet,
rounding_amount: roundingAmount,
is_empty: lines.length === 0,
existing_entries: (existingResult.data ?? []) as VatSettlementExistingEntry[],
}
}