diff --git a/DECISIONS.md b/DECISIONS.md index 8e2b319d..12058523 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -58,3 +58,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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). diff --git a/app/api/reports/vat-declaration/settlement-proposal/__tests__/route.test.ts b/app/api/reports/vat-declaration/settlement-proposal/__tests__/route.test.ts new file mode 100644 index 00000000..1f64eb43 --- /dev/null +++ b/app/api/reports/vat-declaration/settlement-proposal/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/reports/vat-declaration/settlement-proposal/route.ts b/app/api/reports/vat-declaration/settlement-proposal/route.ts new file mode 100644 index 00000000..1846d60c --- /dev/null +++ b/app/api/reports/vat-declaration/settlement-proposal/route.ts @@ -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' }, + }) + } + }, +) diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index d91fa20f..232342cd 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -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: () => , +}) 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 ( + + + Bokför momsrapporten + + +

+ Skapa ett verifikat som nollställer periodens momskonton och bokför + momsen att betala eller få tillbaka på redovisningskontot. Du granskar + förslaget och kan ändra raderna innan verifikatet bokförs. +

+ + {booked && ( +
+ +

+ Momsen för perioden är redan bokförd:{' '} + + verifikat {formatVoucher(booked)} ({formatDate(booked.entry_date)}) + + . Annullera det verifikatet först om perioden behöver bokföras om. +

+
+ )} + {draft && ( +
+ +

+ Det finns redan ett{' '} + + utkast för momsen i perioden + + . +

+
+ )} + + {failed ? ( +
+

Kunde inte hämta verifikatförslaget.

+ +
+ ) : proposal?.is_empty ? ( +

Ingen moms att bokföra för perioden.

+ ) : ( + + )} +
+ + {proposal && ( + + e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + Bokför momsrapport + +

+ Förslaget bygger på momsrapporten för {proposal.period_label}. Justera + datum, konton eller belopp vid behov och bokför sedan verifikatet. +

+ {dialogOpen && ( + { + setDialogOpen(false) + setRefreshKey((k) => k + 1) + }} + /> + )} +
+
+ )} +
+ ) +} + export function VatDeclarationView() { const currentYear = new Date().getFullYear() const currentMonth = new Date().getMonth() + 1 @@ -1607,6 +1795,13 @@ export function VatDeclarationView() { + + terminal chain.lte = () => chain chain.gte = () => chain + chain.neq = () => chain chain.in = () => chain chain.eq = () => chain chain.select = () => chain diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 60fe80e8..3cc96eec 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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) diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index 23b960e6..61c61bd3 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }> function makeBuilder() { const b: Record = {} - 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 }) diff --git a/lib/reports/__tests__/vat-settlement.test.ts b/lib/reports/__tests__/vat-settlement.test.ts new file mode 100644 index 00000000..1c82bc8e --- /dev/null +++ b/lib/reports/__tests__/vat-settlement.test.ts @@ -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> + /** Existing vat_settlement entries in the period. */ + existing?: Array> + /** 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 = {} + 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') + }) +}) diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 513f2be4..b2a8b3bf 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -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> { + 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() + 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 +): 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() - 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 = { diff --git a/lib/reports/vat-settlement.ts b/lib/reports/vat-settlement.ts new file mode 100644 index 00000000..b54aec25 --- /dev/null +++ b/lib/reports/vat-settlement.ts @@ -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 { + // 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[], + } +}