From 7d56e0ec016a251abbac4713171a5361a0b94417 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:22:52 +0200 Subject: [PATCH] fix(mcp): use canonical VAT deadlines (#1679) * fix(mcp): use canonical VAT deadlines * fix(mcp): handle incomplete VAT settings * fix(mcp): block unknown VAT deadlines * fix(mcp): tighten VAT tool description * fix(mcp): fail closed on missing entity type * ci: retry timed-out preview * fix(vat): scope annual filing method requirement --- DECISIONS.md | 3 + app/api/sandbox/seed/__tests__/route.test.ts | 197 ++++++++++++++++++ .../seed/__tests__/vat-deadline.test.ts | 20 ++ app/api/sandbox/seed/route.ts | 11 +- app/api/sandbox/seed/vat-deadline.ts | 33 +++ .../vat-close-check-completeness.test.ts | 116 ++++++++++- .../vat-close-check-missing-underlag.test.ts | 9 +- .../__tests__/vat-close-check.test.ts | 98 +++++++-- .../__tests__/vat-report-compute.test.ts | 14 +- extensions/general/mcp-server/server.ts | 163 +++++++++------ lib/tax/deadline-config.ts | 153 ++++++++++---- lib/vat/__tests__/period-defaults.test.ts | 12 ++ lib/vat/period-defaults.ts | 40 ++-- 13 files changed, 724 insertions(+), 145 deletions(-) create mode 100644 app/api/sandbox/seed/__tests__/route.test.ts create mode 100644 app/api/sandbox/seed/__tests__/vat-deadline.test.ts create mode 100644 app/api/sandbox/seed/vat-deadline.ts diff --git a/DECISIONS.md b/DECISIONS.md index ad2e5b7e..63cd023c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1052,6 +1052,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-18] Shopify webshop_orders port: vat_breakdown is reconstructed from the ORDER-LEVEL taxLines (net = tax / rate, remainder as a 0%-bucket, refuse on missing rates or overshoot) instead of summing line items like the WooCommerce sync: Shopify's discountedTotalSet excludes cart-level discount allocations and lineItems is a paginated connection, so part-summing can silently produce a wrong per-rate net, while tax-per-rate and the charged total are authoritative order-level facts. Refund VAT is always prorated from the parent's mix (Shopify's Refund object exposes no per-rate tax without paging refundLineItems per refund). [2026-08-18] Shopify order feed keeps its paid-only qualification (PAID/PARTIALLY_REFUNDED/REFUNDED) after the webshop_orders port, unlike WooCommerce which also imports unpaid orders for the invoice flow: widening qualification is a product decision, out of scope for the port; unpaid orders re-surface via updatedAt when payment captures. The line-item snapshot is stored only when the parts reconstruct the charged total to the ore (else [] and the invoice conversion falls back to one aggregate line), and the bookkeeping-lock row filter was dropped: an Orders-page row behind the lock is an overview row, not permanent inbox noise, and booking is still blocked by the lock triggers (parity with WooCommerce). [2026-08-18] Skattekontoutdrag sum mismatch (opening + events != closing) demoted from a hard 400 to a preview confirm gate showing ingående/händelser/utgående/differens, mirroring the orgnr-mismatch gate: Sebastian's real export was refused on it (2026-08-18) with no way forward and no figures to diagnose; nothing is booked at import and dedup makes a later complete re-import safe, so refusing the file only blocked the rows that WERE readable. Parser also takes the earliest opening / latest closing across several marker pairs, reads a marker saldo from a trailing running-saldo column, and accepts U+2212 / plus-sign amounts; the route logs the figures (amounts and counts, never row text) so the next report is diagnosable from Vercel logs. Kept the hard reject only for zero readable rows. +[2026-08-18] Issue #1659 exposes one canonical per-period VAT deadline resolver from deadline-config and makes both the MCP close check and VAT period default consume it: monthly, quarterly, annual, over-40M, and January/August rules must not drift across parallel formulas again; the MCP adapter alone applies the same banking-day adjustment as generated tax deadlines. +[2026-08-18] PR #1679 reports deadline_unavailable instead of guessing when VAT settings are missing, and annual AB deadlines require the configured fiscal year to match the resolved report period: a missing or stale filing profile must not produce a plausible but legally wrong date. +[2026-08-18] PR #1679 uses company_settings.entity_type as the sole annual VAT deadline source in MCP close checks: falling back to companies could mask missing or inconsistent filing settings, so annual deadlines now fail closed instead. [2026-08-18] Defect record (compliance-swarm PI1.3, no risk_register.csv exists in repo so this log is the equivalent): Swish QR on invoice PDFs encoded the pre-deduction total on ROT/RUT invoices with the amount locked (editmask 0), risking customer overpayment by the full skattereduktion; impact window = since Swish QR + ROT/RUT coexisted; remediation PR #1685 = QR now encodes getAmountToPay().toPay on every send/download surface (dashboard send/preview-of-send/pdf, v1 send/pdf, MCP pending-operations commit, recurring, issue-and-book; the editor preview is tracked separately in #1686), v1 pdf/send projections gained the amount-path columns (deduction_total, ore_rounding), pinned by lib/api/v1/__tests__/invoice-columns.test.ts. [2026-08-18] Generic CSV mapping (#1671): description guess now excludes clock-time columns (Time/Tid/Klockslag by label, HH:MM by values) and knows Lunar's Title/Titel label; Lunar detect() sniffs comma/semicolon/tab and matches header CELLS exactly (date, title|text, amount, balance) instead of substrings, aligned with what parse() resolves on. NOT changed: the 2026-08-13 generic_csv exemption from the parsed-0-rows auto-detect fallback stays; lifting it would route an explicit "Annan CSV" pick into a dedicated parser and remove the manual escape hatch. Not verified against the customer's actual file (Gmail thread not readable in-session): the semicolon/tab widening is the plausible detection miss, a Swedish-localized Lunar header is not confirmed to exist and was not special-cased in the Lunar detector (the generic path now maps it correctly anyway). diff --git a/app/api/sandbox/seed/__tests__/route.test.ts b/app/api/sandbox/seed/__tests__/route.test.ts new file mode 100644 index 00000000..3349cc6d --- /dev/null +++ b/app/api/sandbox/seed/__tests__/route.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, afterAll, describe, expect, it, vi } from 'vitest' +import { NextResponse } from 'next/server' + +const mocks = vi.hoisted(() => ({ + requireAuth: vi.fn(), + getActiveCompanyId: vi.fn(), + checkRateLimit: vi.fn(), + ensureSandboxAgentProfile: vi.fn(), + markEntriesNoDocRequired: vi.fn(), +})) + +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: mocks.requireAuth })) +vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: mocks.getActiveCompanyId })) +vi.mock('@/lib/auth/rate-limit-http', () => ({ checkRateLimit: mocks.checkRateLimit })) +vi.mock('@/lib/sandbox/ensure-agent', () => ({ + ensureSandboxAgentProfile: mocks.ensureSandboxAgentProfile, +})) +vi.mock('@/lib/bookkeeping/no-doc-required', () => ({ + markEntriesNoDocRequired: mocks.markEntriesNoDocRequired, +})) +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})) +vi.mock('@/lib/bookkeeping/bas-reference', () => ({ + getBASReference: (accountNumber: string) => ({ + account_name: `Account ${accountNumber}`, + account_class: Number(accountNumber[0]), + account_group: accountNumber.slice(0, 2), + account_type: 'asset', + normal_balance: 'debit', + sru_code: null, + k2_excluded: false, + }), +})) +vi.mock('@/lib/salary/personnummer', () => ({ + encryptPersonnummer: (value: string) => `encrypted:${value}`, +})) +vi.mock('../customers', () => ({ + buildSandboxCustomers: () => [ + { name: 'Björk & Partner AB' }, + { name: 'Schmidt GmbH' }, + { name: 'Anna Lindström' }, + ], +})) +vi.mock('../pending-operations', () => ({ buildSandboxPendingOperations: () => [] })) +vi.mock('../articles', () => ({ buildSandboxArticles: () => [] })) +vi.mock('../ledger-history', () => ({ + SANDBOX_LEDGER_ACCOUNT_NUMBERS: [], + buildSandboxLedgerHistory: () => ({ entries: [], linesByEntryIndex: [] }), +})) +vi.mock('../salary-vouchers', () => ({ + SANDBOX_SALARY_ACCOUNT_NUMBERS: [], + buildSandboxSalaryVouchers: () => [], +})) +vi.mock('../salary', () => ({ + SANDBOX_RUN_TOTALS: { + total_gross: 0, + total_tax: 0, + total_net: 0, + total_avgifter: 0, + total_vacation_accrual: 0, + }, + SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER: 0, + buildSandboxEmployees: () => [ + { last_name: 'Andersson' }, + { last_name: 'Berg' }, + ], + mapSandboxEmployeeIds: (rows: Array<{ id: string }>) => ({ + annaEmployeeId: rows[0].id, + erikEmployeeId: rows[1].id, + }), + buildSandboxSalaryRuns: () => [ + { status: 'booked' }, + { status: 'draft' }, + ], + buildSandboxSalaryRunEmployees: ({ annaEmployeeId, erikEmployeeId }: { + annaEmployeeId: string + erikEmployeeId: string + }) => [ + { employee_id: annaEmployeeId }, + { employee_id: erikEmployeeId }, + ], + buildSandboxSalaryLineItems: () => [], + resolveSandboxSalaryPeriods: () => ({ + booked: { paymentDate: '2026-04-25', year: 2026, month: 4 }, + }), +})) + +import { POST } from '../route' + +interface MockSupabaseResult { + supabase: Record + deadlineInserts: unknown[][] +} + +function createMockSupabase(): MockSupabaseResult { + let rowId = 0 + let voucherNumber = 0 + const deadlineInserts: unknown[][] = [] + + const from = vi.fn((table: string) => { + let insertPayload: unknown = undefined + const chain: Record = {} + + const insertedRows = () => { + if (insertPayload === undefined) return [] + const rows = Array.isArray(insertPayload) ? insertPayload : [insertPayload] + return rows.map(row => ({ + ...(row as Record), + id: `${table}-${++rowId}`, + })) + } + + chain.insert = (payload: unknown) => { + insertPayload = payload + if (table === 'deadlines') deadlineInserts.push(payload as unknown[]) + return chain + } + chain.update = () => chain + chain.select = () => chain + for (const method of ['eq', 'in', 'is', 'order', 'limit', 'gte', 'lte', 'not', 'or']) { + chain[method] = () => chain + } + chain.maybeSingle = async () => ({ data: null, error: null }) + chain.single = async () => ({ data: insertedRows()[0] ?? { id: `${table}-${++rowId}` }, error: null }) + chain.then = (resolve: (value: unknown) => void) => resolve({ + data: insertedRows(), + error: null, + }) + + return chain + }) + + const supabase = { + from, + rpc: vi.fn(async (fn: string) => ({ + data: fn === 'next_voucher_number' ? ++voucherNumber : null, + error: null, + })), + } + + return { supabase, deadlineInserts } +} + +function request(): Request { + return new Request('http://localhost:3000/api/sandbox/seed', { + method: 'POST', + headers: { 'x-forwarded-for': '192.0.2.15' }, + }) +} + +describe('POST /api/sandbox/seed', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0)) + mocks.checkRateLimit.mockResolvedValue({ ok: true }) + mocks.getActiveCompanyId.mockResolvedValue('company-1') + mocks.ensureSandboxAgentProfile.mockResolvedValue(undefined) + mocks.markEntriesNoDocRequired.mockResolvedValue(undefined) + }) + + afterAll(() => { + vi.useRealTimers() + }) + + it('returns 401 when authentication fails', async () => { + mocks.requireAuth.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(request()) + + expect(response.status).toBe(401) + }) + + it('inserts the canonical quarterly VAT deadline during a successful seed', async () => { + const { supabase, deadlineInserts } = createMockSupabase() + mocks.requireAuth.mockResolvedValue({ + error: null, + user: { id: 'user-1', is_anonymous: true }, + supabase, + }) + + const response = await POST(request()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ seeded: true }) + expect(deadlineInserts).toHaveLength(1) + expect(deadlineInserts[0][0]).toMatchObject({ + title: 'Momsdeklaration Q2 2026', + due_date: '2026-08-17', + tax_deadline_type: 'moms_quarterly', + tax_period: '2026-Q2', + }) + }) +}) diff --git a/app/api/sandbox/seed/__tests__/vat-deadline.test.ts b/app/api/sandbox/seed/__tests__/vat-deadline.test.ts new file mode 100644 index 00000000..26ea9e8c --- /dev/null +++ b/app/api/sandbox/seed/__tests__/vat-deadline.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { buildSandboxVatDeadline } from '../vat-deadline' + +describe('buildSandboxVatDeadline', () => { + it('uses the August 17 deadline for a Q2 sandbox', () => { + expect(buildSandboxVatDeadline(new Date(2026, 4, 15))).toEqual({ + title: 'Momsdeklaration Q2 2026', + dueDate: '2026-08-17', + period: '2026-Q2', + }) + }) + + it('rolls the Q4 deadline into February of the following year', () => { + expect(buildSandboxVatDeadline(new Date(2026, 10, 15))).toEqual({ + title: 'Momsdeklaration Q4 2026', + dueDate: '2027-02-12', + period: '2026-Q4', + }) + }) +}) diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index cf19a9f2..464e1147 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -13,6 +13,7 @@ import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required' import { buildSandboxCustomers } from './customers' import { buildSandboxPendingOperations } from './pending-operations' import { buildSandboxArticles } from './articles' +import { buildSandboxVatDeadline } from './vat-deadline' import { buildSandboxLedgerHistory, SANDBOX_LEDGER_ACCOUNT_NUMBERS, @@ -823,9 +824,7 @@ export async function POST(request: Request) { ) // 12. Create deadlines - const momsDeadline = new Date(today) - momsDeadline.setMonth(momsDeadline.getMonth() + 2) - momsDeadline.setDate(12) + const momsDeadline = buildSandboxVatDeadline(today) const { error: dlError } = await supabase .from('deadlines') @@ -833,15 +832,15 @@ export async function POST(request: Request) { { user_id: userId, company_id: companyId, - title: `Momsdeklaration Q1 ${currentYear}`, - due_date: toDateStr(momsDeadline), + title: momsDeadline.title, + due_date: momsDeadline.dueDate, deadline_type: 'tax', priority: 'important', // Current generator types: the bare 'moms'/'inkomstdeklaration' // types were retired and seeding them recreates legacy rows the // cleanup migration removed. tax_deadline_type: 'moms_quarterly', - tax_period: `${currentYear}-Q1`, + tax_period: momsDeadline.period, source: 'system', status: 'upcoming', linked_report_type: 'vat', diff --git a/app/api/sandbox/seed/vat-deadline.ts b/app/api/sandbox/seed/vat-deadline.ts new file mode 100644 index 00000000..a90c2dc3 --- /dev/null +++ b/app/api/sandbox/seed/vat-deadline.ts @@ -0,0 +1,33 @@ +import { getVatDeadlineForPeriod } from '@/lib/tax/deadline-config' +import { adjustDeadlineToNextBankingDay } from '@/lib/tax/swedish-holidays' + +export interface SandboxVatDeadline { + title: string + dueDate: string + period: string +} + +/** Build the current quarter's canonical, banking-day-adjusted sandbox deadline. */ +export function buildSandboxVatDeadline(today: Date): SandboxVatDeadline { + const year = today.getFullYear() + const quarter = Math.floor(today.getMonth() / 3) + 1 + const rawDeadline = getVatDeadlineForPeriod('quarterly', year, quarter, { + vat_taxable_base_over_40m: false, + }) + if (!rawDeadline) throw new Error('Could not resolve sandbox VAT deadline') + + const deadline = adjustDeadlineToNextBankingDay( + new Date(rawDeadline.year, rawDeadline.month, rawDeadline.day), + ) + const dueDate = [ + deadline.getFullYear(), + String(deadline.getMonth() + 1).padStart(2, '0'), + String(deadline.getDate()).padStart(2, '0'), + ].join('-') + + return { + title: `Momsdeklaration Q${quarter} ${year}`, + dueDate, + period: `${year}-Q${quarter}`, + } +} diff --git a/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts b/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts index 7d3d81f2..f143309d 100644 --- a/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts +++ b/extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts @@ -51,7 +51,15 @@ interface MockChartAccount { * fixture; everything else (transactions, supplier_invoices, company_settings) * comes back empty so no unrelated blocker fires. */ -function mockSupabase(lines: MockLine[], chartAccounts: MockChartAccount[] = []) { +function mockSupabase( + lines: MockLine[], + chartAccounts: MockChartAccount[] = [], + companySettings: Record | null = { + moms_period: 'monthly', + vat_taxable_base_over_40m: false, + }, + companyEntityType: 'aktiebolag' | 'enskild_firma' | null = null, +) { const entries = [ ...new Map( lines.map((l, i) => { @@ -83,7 +91,7 @@ function mockSupabase(lines: MockLine[], chartAccounts: MockChartAccount[] = []) const chain: Record = {} const settled = { data: rows, error: null, count: rows.length } chain.range = () => settled - chain.single = async () => ({ data: null, error: null }) + chain.single = async () => ({ data: rows[0] ?? null, error: null }) chain.maybeSingle = async () => ({ data: null, error: null }) chain.then = (resolve: (v: unknown) => void) => resolve(settled) for (const m of [ @@ -100,6 +108,12 @@ function mockSupabase(lines: MockLine[], chartAccounts: MockChartAccount[] = []) if (table === 'journal_entries') return makeChain(entries) if (table === 'journal_entry_lines') return makeChain(bareLines) if (table === 'chart_of_accounts') return makeChain(chartAccounts) + if (table === 'company_settings') { + return makeChain(companySettings ? [companySettings] : []) + } + if (table === 'companies') { + return makeChain(companyEntityType ? [{ entity_type: companyEntityType }] : []) + } return makeChain([]) }, // The missing-underlag blocker reads the verifikat_without_documents RPC, @@ -116,6 +130,104 @@ function mockSupabase(lines: MockLine[], chartAccounts: MockChartAccount[] = []) const PERIOD = { period_type: 'monthly', year: 2026, period: 1 } describe('gnubok_vat_close_check: declaration completeness', () => { + it('reads the over-40M setting and returns the following-month 26th deadline', async () => { + const result = await computeVatCloseCheck( + PERIOD, + 'company-1', + mockSupabase([], [], { + moms_period: 'monthly', + vat_taxable_base_over_40m: true, + }), + ) + + expect(result.payment.deadline).toBe('2026-02-26') + expect(result.payment.deadline_label).toBe('26 februari 2026') + }) + + it('surfaces an unavailable deadline instead of guessing when settings are missing', async () => { + const result = await computeVatCloseCheck( + PERIOD, + 'company-1', + mockSupabase([], [], null), + ) + + expect(result.payment.deadline).toBeNull() + expect(result.blockers).toContainEqual(expect.objectContaining({ + kind: 'deadline_unavailable', + severity: 'high', + })) + expect(result.ready_to_close).toBe(false) + }) + + it('does not fall back to the company row when annual settings omit entity type', async () => { + const result = await computeVatCloseCheck( + { period_type: 'yearly', year: 2026, period: 1 }, + 'company-1', + mockSupabase([], [], { + moms_period: 'yearly', + vat_taxable_base_over_40m: false, + fiscal_year_start_month: 1, + vat_has_eu_trade: false, + vat_filing_method: 'electronic', + }, 'aktiebolag'), + ) + + expect(result.payment.deadline).toBeNull() + expect(result.blockers).toContainEqual(expect.objectContaining({ + kind: 'deadline_unavailable', + severity: 'high', + })) + expect(result.ready_to_close).toBe(false) + }) + + it.each([ + ['enskild_firma', false, '2027-05-12'], + ['aktiebolag', true, '2027-02-26'], + ] as const)('does not require an annual filing method for %s with EU trade %s', async ( + entityType, + vatHasEuTrade, + expectedDeadline, + ) => { + const result = await computeVatCloseCheck( + { period_type: 'yearly', year: 2026, period: 1 }, + 'company-1', + mockSupabase([], [], { + moms_period: 'yearly', + vat_taxable_base_over_40m: false, + entity_type: entityType, + fiscal_year_start_month: 1, + vat_has_eu_trade: vatHasEuTrade, + vat_filing_method: null, + }), + ) + + expect(result.payment.deadline).toBe(expectedDeadline) + expect(result.blockers).not.toContainEqual(expect.objectContaining({ + kind: 'deadline_unavailable', + })) + }) + + it('requires an annual filing method for an AB without EU trade', async () => { + const result = await computeVatCloseCheck( + { period_type: 'yearly', year: 2026, period: 1 }, + 'company-1', + mockSupabase([], [], { + moms_period: 'yearly', + vat_taxable_base_over_40m: false, + entity_type: 'aktiebolag', + fiscal_year_start_month: 1, + vat_has_eu_trade: false, + vat_filing_method: null, + }), + ) + + expect(result.payment.deadline).toBeNull() + expect(result.blockers).toContainEqual(expect.objectContaining({ + kind: 'deadline_unavailable', + severity: 'high', + })) + }) + it('includes a null-rate 3011 with matching domestic VAT evidence (#1289)', async () => { const result = await computeVatCloseCheck( PERIOD, diff --git a/extensions/general/mcp-server/__tests__/vat-close-check-missing-underlag.test.ts b/extensions/general/mcp-server/__tests__/vat-close-check-missing-underlag.test.ts index 919e38fb..a3dd51e6 100644 --- a/extensions/general/mcp-server/__tests__/vat-close-check-missing-underlag.test.ts +++ b/extensions/general/mcp-server/__tests__/vat-close-check-missing-underlag.test.ts @@ -45,7 +45,7 @@ function mockSupabase(rpcResults: Record) { const chain: Record = {} const settled = { data: rows, error: null, count: rows.length } chain.range = () => settled - chain.single = async () => ({ data: null, error: null }) + chain.single = async () => ({ data: rows[0] ?? null, error: null }) chain.maybeSingle = async () => ({ data: null, error: null }) chain.then = (resolve: (v: unknown) => void) => resolve(settled) for (const m of [ @@ -66,7 +66,12 @@ function mockSupabase(rpcResults: Record) { }) return { - supabase: { from: () => makeChain([]), rpc } as never, + supabase: { + from: (table: string) => makeChain(table === 'company_settings' + ? [{ moms_period: 'monthly', vat_taxable_base_over_40m: false }] + : []), + rpc, + } as never, rpc, } } diff --git a/extensions/general/mcp-server/__tests__/vat-close-check.test.ts b/extensions/general/mcp-server/__tests__/vat-close-check.test.ts index 5bb54085..4c3cf601 100644 --- a/extensions/general/mcp-server/__tests__/vat-close-check.test.ts +++ b/extensions/general/mcp-server/__tests__/vat-close-check.test.ts @@ -55,29 +55,97 @@ describe('gnubok_vat_close_check', () => { }) describe('computeMomsDeadline', () => { - it('monthly: March 2026 → 12 April 2026', () => { - const d = computeMomsDeadline('monthly', 2026, 3) - expect(d?.date).toBe('2026-04-12') - expect(d?.label).toBe('12 april 2026') + const standardSettings = { + vat_taxable_base_over_40m: false, + entity_type: 'aktiebolag' as const, + fiscal_year_start_month: 1, + vat_has_eu_trade: false, + vat_filing_method: 'electronic' as const, + } + + it('monthly: June 2026 is due 17 August 2026', () => { + const d = computeMomsDeadline('monthly', 2026, 6, standardSettings) + expect(d?.date).toBe('2026-08-17') + expect(d?.label).toBe('17 augusti 2026') }) - it('monthly: December rolls into next year', () => { - const d = computeMomsDeadline('monthly', 2026, 12) - expect(d?.date).toBe('2027-01-12') + it('monthly: December 2026 is due 12 February 2027', () => { + const d = computeMomsDeadline('monthly', 2026, 12, standardSettings) + expect(d?.date).toBe('2027-02-12') }) - it('quarterly: Q1 2026 → 26 April 2026', () => { - const d = computeMomsDeadline('quarterly', 2026, 1) - expect(d?.date).toBe('2026-04-26') + it('monthly: filers above SEK 40 million use the 26th of the following month', () => { + const d = computeMomsDeadline('monthly', 2026, 1, { + ...standardSettings, + vat_taxable_base_over_40m: true, + }) + expect(d?.date).toBe('2026-02-26') + expect(d?.label).toBe('26 februari 2026') }) - it('quarterly: Q4 2026 → 26 January 2027', () => { - const d = computeMomsDeadline('quarterly', 2026, 4) - expect(d?.date).toBe('2027-01-26') + it('adjusts a raw 26 December deadline to the next banking day', () => { + const d = computeMomsDeadline('monthly', 2026, 11, { + ...standardSettings, + vat_taxable_base_over_40m: true, + }) + expect(d?.date).toBe('2026-12-28') }) - it('yearly: 2026 → 26 February 2027', () => { - const d = computeMomsDeadline('yearly', 2026, 1) + it.each([ + [1, '2026-05-12'], + [2, '2026-08-17'], + [3, '2026-11-12'], + [4, '2027-02-12'], + ])('quarterly: Q%s uses the canonical table', (quarter, expected) => { + expect(computeMomsDeadline('quarterly', 2026, quarter, standardSettings)?.date) + .toBe(expected) + }) + + it('yearly: an enskild firma with EU trade is due 26 February', () => { + const d = computeMomsDeadline('yearly', 2026, 1, { + ...standardSettings, + entity_type: 'enskild_firma', + vat_has_eu_trade: true, + }) expect(d?.date).toBe('2027-02-26') }) + + it('yearly: an enskild firma does not require a filing method', () => { + const d = computeMomsDeadline('yearly', 2026, 1, { + ...standardSettings, + entity_type: 'enskild_firma', + vat_filing_method: null, + }) + expect(d?.date).toBe('2027-05-12') + }) + + it('yearly: an AB with EU trade does not require a filing method', () => { + const d = computeMomsDeadline('yearly', 2026, 1, { + ...standardSettings, + vat_has_eu_trade: true, + vat_filing_method: null, + }) + expect(d?.date).toBe('2027-02-26') + }) + + it('yearly: an aktiebolag uses its canonical electronic filing deadline', () => { + const d = computeMomsDeadline('yearly', 2026, 1, standardSettings) + expect(d?.date).toBe('2027-08-17') + }) + + it('yearly: an AB without EU trade still requires a filing method', () => { + const d = computeMomsDeadline('yearly', 2026, 1, { + ...standardSettings, + vat_filing_method: null, + }) + expect(d).toBeNull() + }) + + it('yearly: does not guess a calendar year for an AB without fiscal settings', () => { + const d = computeMomsDeadline('yearly', 2026, 1, { + ...standardSettings, + fiscal_year_start_month: null, + }) + expect(d).toBeNull() + }) }) diff --git a/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts b/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts index 049c2176..30df4ccc 100644 --- a/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts +++ b/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts @@ -17,7 +17,10 @@ interface MockLine { journal_entries?: { source_type: string | null } } -function mockSupabaseWithLines(lines: MockLine[]) { +function mockSupabaseWithLines( + lines: MockLine[], + fiscalPeriod?: { period_start: string; period_end: string }, +) { // computeVatReport uses the two-step entry-lines fetch // (lib/bookkeeping/entry-lines.ts): journal_entries is queried first, then // journal_entry_lines by parent id, and the parent is reattached under @@ -46,6 +49,7 @@ function mockSupabaseWithLines(lines: MockLine[]) { const makeChain = (rows: unknown[]) => { const chain: Record unknown> = {} chain.range = () => ({ data: rows, error: null }) + chain.maybeSingle = async () => ({ data: rows[0] ?? null, error: null }) for (const m of ['order', 'lte', 'gte', 'neq', 'in', 'not', 'eq', 'select', 'limit', 'contains', 'filter']) { chain[m] = () => chain } @@ -59,6 +63,7 @@ function mockSupabaseWithLines(lines: MockLine[]) { from: (table: string) => { if (table === 'journal_entries') return makeChain(entries) if (table === 'chart_of_accounts') return makeChain([]) + if (table === 'fiscal_periods') return makeChain(fiscalPeriod ? [fiscalPeriod] : []) return makeChain(bareLines) }, } as never @@ -175,10 +180,15 @@ describe('computeVatReport', () => { const result = await computeVatReport( { period_type: 'yearly', year: 2026, period: 1 }, 'company-1', - mockSupabaseWithLines(lines) + mockSupabaseWithLines(lines, { + period_start: '2025-07-01', + period_end: '2026-06-30', + }) ) expect(result.rutor.ruta05).toBe(2100) + expect(result.period.start).toBe('2025-07-01') + expect(result.period.end).toBe('2026-06-30') }) it('excludes 3004 (momsfri) from ruta05: exempt sales must NOT be in the taxable base', async () => { diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 7e4b1360..3c1bace9 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -16,6 +16,11 @@ import { } from '@/lib/auth/api-keys' import { createLogger } from '@/lib/logger' import { roundOre, sumOre } from '@/lib/money' +import { + getVatDeadlineForPeriod, + type VatDeadlineCalculationSettings, +} from '@/lib/tax/deadline-config' +import { adjustDeadlineToNextBankingDay } from '@/lib/tax/swedish-holidays' import type { SupabaseClient } from '@supabase/supabase-js' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { applyAccountOverride } from '@/lib/bookkeeping/account-override' @@ -47,6 +52,7 @@ import { rutorFromTotals, rcInputTotalsFromDeclaration, calculateVatDeclaration, + resolvePeriodDates, } from '@/lib/reports/vat-declaration' import { fetchDynamicVatAccounts } from '@/lib/reports/vat-revenue-accounts' // The momsdeklaration completeness checks live in core (lib/reports) and are @@ -1529,23 +1535,13 @@ export async function computeVatReportWithRutor( if (periodType === 'monthly' && (period < 1 || period > 12)) throw new Error('period must be 1-12 for monthly') if (periodType === 'quarterly' && (period < 1 || period > 4)) throw new Error('period must be 1-4 for quarterly') - let startDate: string - let endDate: string - - if (periodType === 'monthly') { - startDate = `${year}-${String(period).padStart(2, '0')}-01` - const lastDay = new Date(year, period, 0).getDate() - endDate = `${year}-${String(period).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - } else if (periodType === 'quarterly') { - const startMonth = (period - 1) * 3 + 1 - const endMonth = period * 3 - startDate = `${year}-${String(startMonth).padStart(2, '0')}-01` - const lastDay = new Date(year, endMonth, 0).getDate() - endDate = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - } else { - startDate = `${year}-01-01` - endDate = `${year}-12-31` - } + const { start: startDate, end: endDate } = await resolvePeriodDates( + supabase, + companyId, + periodType as 'monthly' | 'quarterly' | 'yearly', + year, + period, + ) // Two-step fetch (lib/bookkeeping/entry-lines.ts) rather than a // `journal_entries!inner` embed: PostgREST compiles that embed into a @@ -1795,6 +1791,7 @@ interface VatCloseBlocker { | 'missing_high_value_receipts' | 'reverse_charge_input_missing' | 'declaration_incomplete' + | 'deadline_unavailable' severity: 'high' | 'medium' | 'low' count: number message: string @@ -1924,47 +1921,27 @@ interface VatCloseCheckResult { summary: string } -/** Compute the Skatteverket momsdeklaration deadline for a period. - * - monthly: due on the 12th of (period-end-month + 1) - * - quarterly: 26th of the month after quarter-end (Q4 → 26 Jan next year) - * - yearly: 26 Feb of next year - */ +/** Adapt the canonical VAT deadline configuration to the MCP wire shape. */ export function computeMomsDeadline( periodType: 'monthly' | 'quarterly' | 'yearly', year: number, - period: number + period: number, + settings: VatDeadlineCalculationSettings, ): { date: string; label: string } | null { - if (periodType === 'monthly') { - // period 1-12; deadline = 12th of next month - const deadlineMonth = period === 12 ? 1 : period + 1 - const deadlineYear = period === 12 ? year + 1 : year - return { - date: `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-12`, - label: `12 ${monthName(deadlineMonth)} ${deadlineYear}`, - } + const instance = getVatDeadlineForPeriod(periodType, year, period, settings) + if (!instance) return null + + const adjusted = adjustDeadlineToNextBankingDay( + new Date(instance.year, instance.month, instance.day), + ) + const deadlineYear = adjusted.getFullYear() + const deadlineMonth = adjusted.getMonth() + 1 + const deadlineDay = adjusted.getDate() + + return { + date: `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-${String(deadlineDay).padStart(2, '0')}`, + label: `${deadlineDay} ${monthName(deadlineMonth)} ${deadlineYear}`, } - if (periodType === 'quarterly') { - // Q1→26 apr, Q2→26 jul, Q3→26 okt, Q4→26 jan next year - const monthByQuarter: Record = { - 1: { m: 4, yOffset: 0 }, - 2: { m: 7, yOffset: 0 }, - 3: { m: 10, yOffset: 0 }, - 4: { m: 1, yOffset: 1 }, - } - const cfg = monthByQuarter[period] - if (!cfg) return null - return { - date: `${year + cfg.yOffset}-${String(cfg.m).padStart(2, '0')}-26`, - label: `26 ${monthName(cfg.m)} ${year + cfg.yOffset}`, - } - } - if (periodType === 'yearly') { - return { - date: `${year + 1}-02-26`, - label: `26 februari ${year + 1}`, - } - } - return null } function monthName(m: number): string { @@ -2142,21 +2119,72 @@ export async function computeVatCloseCheck( await computeVatReportWithRutor(args, companyId, supabase) const { start, end, type: periodType, year, period } = vatReport.period - // 2) Company settings: moms_period drives deadline labelling - const { data: settings } = await supabase + // 2) Company settings: deadline inputs come from the same fields used by + // lib/tax/deadline-config.ts. The over-40M flag changes monthly filers + // from the 12th/17th M+2 schedule to the 26th M+1 schedule. + const { data: settings, error: settingsError } = await supabase .from('company_settings') - .select('moms_period') + .select('moms_period, vat_taxable_base_over_40m, entity_type, fiscal_year_start_month, vat_has_eu_trade, vat_filing_method') .eq('company_id', companyId) .single() const momsPeriod = (settings?.moms_period as 'monthly' | 'quarterly' | 'yearly' | null) ?? null - + const entityType = settings?.entity_type === 'aktiebolag' || settings?.entity_type === 'enskild_firma' + ? settings.entity_type + : null // 3) Deadline: based on the *requested* period type, not company setting, // so the model gets the right deadline even when querying ad-hoc periods. - const deadline = computeMomsDeadline( - periodType as 'monthly' | 'quarterly' | 'yearly', - Number(year), - Number(period) - ) + // Never turn missing settings into a plausible statutory date. Monthly + // deadlines need the turnover threshold; annual deadlines additionally + // need the filing profile and a configured fiscal year matching the + // report range. Quarterly dates are independent of company settings. + let deadline: { date: string; label: string } | null = null + if (periodType === 'quarterly') { + deadline = computeMomsDeadline('quarterly', Number(year), Number(period), { + vat_taxable_base_over_40m: false, + }) + } else if ( + periodType === 'monthly' + && typeof settings?.vat_taxable_base_over_40m === 'boolean' + ) { + deadline = computeMomsDeadline('monthly', Number(year), Number(period), { + vat_taxable_base_over_40m: settings.vat_taxable_base_over_40m, + }) + } else if (periodType === 'yearly' && settings && entityType) { + const configuredStartMonth = typeof settings.fiscal_year_start_month === 'number' + && settings.fiscal_year_start_month >= 1 + && settings.fiscal_year_start_month <= 12 + ? settings.fiscal_year_start_month + : null + const reportEndMonth = Number(end.slice(5, 7)) + const reportStartMonth = reportEndMonth === 12 ? 1 : reportEndMonth + 1 + const fiscalYearMatches = entityType === 'enskild_firma' + ? reportEndMonth === 12 + : configuredStartMonth === reportStartMonth + const filingMethodRequired = entityType === 'aktiebolag' && settings.vat_has_eu_trade === false + const filingProfileComplete = typeof settings.vat_has_eu_trade === 'boolean' + && (!filingMethodRequired + || settings.vat_filing_method === 'electronic' + || settings.vat_filing_method === 'paper') + + if (fiscalYearMatches && filingProfileComplete) { + const deadlineSettings: VatDeadlineCalculationSettings = { + vat_taxable_base_over_40m: settings.vat_taxable_base_over_40m === true, + entity_type: entityType, + fiscal_year_start_month: reportStartMonth, + vat_has_eu_trade: settings.vat_has_eu_trade, + vat_filing_method: settings.vat_filing_method, + } + deadline = computeMomsDeadline('yearly', Number(year), Number(period), deadlineSettings) + } + } + if (!deadline) { + log.warn('VAT deadline unavailable', { + companyId, + periodType, + hasSettings: Boolean(settings), + settingsErrorCode: settingsError?.code, + }) + } // 4) Blocker scans: run in parallel const [uncategorizedRes, unapprovedRes, recon, missingUnderlag] = await Promise.all([ @@ -2184,6 +2212,15 @@ export async function computeVatCloseCheck( ]) const blockers: VatCloseBlocker[] = [] + if (!deadline) { + blockers.push({ + kind: 'deadline_unavailable', + severity: 'high', + count: 1, + message: 'Momsens inlämningsdatum kunde inte fastställas säkert', + hint: 'Kontrollera momsinställningar, deklarationssätt och räkenskapsperiod innan deklarationen lämnas in.', + }) + } const uncategorizedCount = uncategorizedRes.count ?? 0 if (uncategorizedCount > 0) { blockers.push({ @@ -5464,7 +5501,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_vat_close_check', title: 'VAT Close Check (Momsdeklaration)', - description: "Answer 'can I close VAT?' in one call. Returns SKV 4700 rutor, blockers (uncategorized, unapproved supplier invoices, reconciliation diff, missing receipts) plus declaration_checks: the momsdeklaration completeness gate the web filing UI uses. ready_to_close covers both.", + description: "Answer 'can I close VAT?' in one call. Returns SKV 4700 rutor, bookkeeping blockers (including unavailable deadlines), and declaration_checks from the same momsdeklaration completeness gate used by the web filing UI. ready_to_close covers both.", inputSchema: { type: 'object', additionalProperties: false, diff --git a/lib/tax/deadline-config.ts b/lib/tax/deadline-config.ts index faff064f..0b01726b 100644 --- a/lib/tax/deadline-config.ts +++ b/lib/tax/deadline-config.ts @@ -83,6 +83,21 @@ export interface DeadlineInstance { taxAssessmentNoticeId?: string } +export interface VatDeadlineCalculationSettings { + vat_taxable_base_over_40m: boolean + entity_type?: EntityType | null + fiscal_year_start_month?: number | null + vat_has_eu_trade?: boolean | null + vat_filing_method?: TaxFilingMethod | null +} + +interface AnnualVatDeadlineSettings { + entity_type: 'aktiebolag' | 'enskild_firma' + fiscal_year_start_month: number + vat_has_eu_trade: boolean + vat_filing_method?: TaxFilingMethod | null +} + /** * Day-of-month of the nth Swedish banking day in a month (1-based n). * Used for Intrastat, whose SCB reporting dates follow the ~10th working @@ -111,7 +126,7 @@ function getFiscalYearLabel(fiscalYearEndMonth: number, fiscalYearEndYear: numbe function getAnnualVatDeadline( fiscalYearEndMonth: number, fiscalYearEndYear: number, - settings: CompanySettingsForDeadlines, + settings: AnnualVatDeadlineSettings, ): { day: number; month: number; year: number } { // Enskild firma (calendar year only, BFL 3 kap.): without EU trade the // annual momsdeklaration follows the income tax return (12 May); with EU @@ -146,25 +161,100 @@ function getAnnualVatDeadline( return { day: paper ? 12 : 17, month: paper ? 6 : 7, year: fiscalYearEndYear + 1 } } +/** + * Resolve the raw statutory VAT filing date for one reporting period. + * Banking-day adjustment is deliberately left to callers, matching the other + * deadline configs whose generated instances are adjusted by the generator. + */ +export function getVatDeadlineForPeriod( + periodType: 'monthly' | 'quarterly' | 'yearly', + year: number, + period: number, + settings: VatDeadlineCalculationSettings, +): DeadlineInstance | null { + if (periodType === 'monthly') { + if (period < 1 || period > 12) return null + + const month = period - 1 + const monthOffset = settings.vat_taxable_base_over_40m ? 1 : 2 + const deadlineMonth = (month + monthOffset) % 12 + const deadlineYear = year + Math.floor((month + monthOffset) / 12) + const day = settings.vat_taxable_base_over_40m + ? 26 + : (deadlineMonth === 0 || deadlineMonth === 7 ? 17 : 12) + + return { + day, + month: deadlineMonth, + year: deadlineYear, + period: `${year}-${String(period).padStart(2, '0')}`, + periodLabel: getMonthLabel(month, year), + } + } + + if (periodType === 'quarterly') { + if (period < 1 || period > 4) return null + + return [ + { day: 12, month: 4, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` }, + { day: 17, month: 7, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` }, + { day: 12, month: 10, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` }, + { day: 12, month: 1, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` }, + ][period - 1] + } + + if (period !== 1) return null + if (settings.entity_type !== 'aktiebolag' && settings.entity_type !== 'enskild_firma') { + return null + } + if (typeof settings.vat_has_eu_trade !== 'boolean') return null + if ( + settings.entity_type === 'aktiebolag' + && settings.vat_has_eu_trade === false + && settings.vat_filing_method !== 'electronic' + && settings.vat_filing_method !== 'paper' + ) { + return null + } + + const configuredFiscalYearStartMonth = settings.fiscal_year_start_month != null + && settings.fiscal_year_start_month >= 1 + && settings.fiscal_year_start_month <= 12 + ? settings.fiscal_year_start_month + : null + if (settings.entity_type === 'aktiebolag' && configuredFiscalYearStartMonth === null) { + return null + } + const fiscalYearStartMonth = settings.entity_type === 'enskild_firma' + ? 1 + : configuredFiscalYearStartMonth! + const fiscalYearEndMonth = settings.entity_type === 'enskild_firma' + ? 12 + : (fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1) + const deadline = getAnnualVatDeadline(fiscalYearEndMonth, year, { + entity_type: settings.entity_type, + fiscal_year_start_month: fiscalYearStartMonth, + vat_has_eu_trade: settings.vat_has_eu_trade, + vat_filing_method: settings.vat_filing_method, + }) + const fiscalYearLabel = getFiscalYearLabel(fiscalYearEndMonth, year) + + return { + ...deadline, + period: fiscalYearLabel, + periodLabel: fiscalYearLabel, + } +} + function generateAnnualVatDates( deadlineYear: number, settings: CompanySettingsForDeadlines, ): DeadlineInstance[] { - const fiscalYearEndMonth = settings.entity_type === 'enskild_firma' - ? 12 - : (settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1) const results: DeadlineInstance[] = [] for (const fiscalYearEndYear of [deadlineYear - 1, deadlineYear]) { - const deadline = getAnnualVatDeadline(fiscalYearEndMonth, fiscalYearEndYear, settings) - if (deadline.year !== deadlineYear) continue - - const period = getFiscalYearLabel(fiscalYearEndMonth, fiscalYearEndYear) - results.push({ - ...deadline, - period, - periodLabel: period, - }) + const instance = getVatDeadlineForPeriod('yearly', fiscalYearEndYear, 1, settings) + if (instance?.year === deadlineYear) results.push(instance) } return results @@ -182,27 +272,10 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [ condition: (s) => s.vat_registered && s.moms_period === 'monthly', priority: 'important', linkedReportType: 'vat', - generateDates: (year, settings) => { - const instances: DeadlineInstance[] = [] - for (let month = 0; month < 12; month++) { - const monthOffset = settings.vat_taxable_base_over_40m ? 1 : 2 - const deadlineMonth = (month + monthOffset) % 12 - const deadlineYear = year + Math.floor((month + monthOffset) / 12) - // Above SEK 40M the 26th applies year-round; 26 December is annandag - // jul, and the banking-day adjustment yields Skatteverket's 27th. - const day = settings.vat_taxable_base_over_40m - ? 26 - : (deadlineMonth === 0 || deadlineMonth === 7 ? 17 : 12) - instances.push({ - day, - month: deadlineMonth, - year: deadlineYear, - period: `${year}-${String(month + 1).padStart(2, '0')}`, - periodLabel: getMonthLabel(month, year), - }) - } - return instances - }, + generateDates: (year, settings) => Array.from( + { length: 12 }, + (_, month) => getVatDeadlineForPeriod('monthly', year, month + 1, settings)!, + ), }, // Momsdeklaration (quarterly) @@ -213,14 +286,10 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [ condition: (s) => s.vat_registered && s.moms_period === 'quarterly', priority: 'important', linkedReportType: 'vat', - generateDates: (year) => { - return [ - { day: 12, month: 4, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` }, - { day: 17, month: 7, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` }, - { day: 12, month: 10, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` }, - { day: 12, month: 1, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` }, - ] - }, + generateDates: (year, settings) => Array.from( + { length: 4 }, + (_, quarter) => getVatDeadlineForPeriod('quarterly', year, quarter + 1, settings)!, + ), }, // Momsdeklaration (yearly) diff --git a/lib/vat/__tests__/period-defaults.test.ts b/lib/vat/__tests__/period-defaults.test.ts index 3ecb1ab4..510e9ccb 100644 --- a/lib/vat/__tests__/period-defaults.test.ts +++ b/lib/vat/__tests__/period-defaults.test.ts @@ -47,6 +47,18 @@ describe('mostRecentEndedVatPeriod', () => { }) }) + it('keeps the M-2 period through an adjusted banking-day deadline', () => { + // 17 January 2026 is Saturday, so the deadline moves to Monday 19th. + expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 0, 19))).toEqual({ + year: 2025, + period: 11, + }) + expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 0, 20))).toEqual({ + year: 2025, + period: 12, + }) + }) + it('over-40M filers (month M due the 26th of M+1) always get M-1', () => { expect( mostRecentEndedVatPeriod('monthly', new Date(2026, 7, 6), { over40m: true }), diff --git a/lib/vat/period-defaults.ts b/lib/vat/period-defaults.ts index 748a4998..95e3456b 100644 --- a/lib/vat/period-defaults.ts +++ b/lib/vat/period-defaults.ts @@ -14,12 +14,22 @@ * most recently ended month the due one year-round. */ +import { getVatDeadlineForPeriod } from '@/lib/tax/deadline-config' +import { adjustDeadlineToNextBankingDay } from '@/lib/tax/swedish-holidays' + export interface VatPeriodDefault { year: number /** 1-12 for monthly, 1-4 for quarterly. */ period: number } +/** Return the monthly VAT period a fixed number of months before a month. */ +function previousMonth(year: number, month: number, monthsBack: number): VatPeriodDefault { + const date = new Date(year, month - 1 - monthsBack, 1) + return { year: date.getFullYear(), period: date.getMonth() + 1 } +} + +/** Select the latest ended VAT period that is currently relevant for filing. */ export function mostRecentEndedVatPeriod( periodType: 'monthly' | 'quarterly', today: Date = new Date(), @@ -29,19 +39,23 @@ export function mostRecentEndedVatPeriod( const month = today.getMonth() + 1 if (periodType === 'monthly') { - let m = month - 1 - let y = year - if (!opts.over40m) { - // Deadline day of the CURRENT month for the M-2 declaration: 17 in - // January and August, else 12 (deadline-config huvudregel). - const deadlineDay = month === 1 || month === 8 ? 17 : 12 - if (today.getDate() <= deadlineDay) m -= 1 - } - while (m < 1) { - m += 12 - y -= 1 - } - return { year: y, period: m } + const mostRecentEnded = previousMonth(year, month, 1) + if (opts.over40m) return mostRecentEnded + + const periodDueThisMonth = previousMonth(year, month, 2) + const deadline = getVatDeadlineForPeriod( + 'monthly', + periodDueThisMonth.year, + periodDueThisMonth.period, + { vat_taxable_base_over_40m: false }, + ) + const adjustedDeadline = deadline + ? adjustDeadlineToNextBankingDay(new Date(deadline.year, deadline.month, deadline.day)) + : null + const currentDate = new Date(year, month - 1, today.getDate()) + return adjustedDeadline && currentDate <= adjustedDeadline + ? periodDueThisMonth + : mostRecentEnded } const quarter = Math.ceil(month / 3)