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
This commit is contained in:
Mattsson
2026-08-18 17:22:52 +02:00
committed by GitHub
parent d0640e0968
commit 7d56e0ec01
13 changed files with 724 additions and 145 deletions
+3
View File
@@ -1052,6 +1052,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
@@ -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<string, unknown>
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<string, unknown> = {}
const insertedRows = () => {
if (insertPayload === undefined) return []
const rows = Array.isArray(insertPayload) ? insertPayload : [insertPayload]
return rows.map(row => ({
...(row as Record<string, unknown>),
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',
})
})
})
@@ -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',
})
})
})
+5 -6
View File
@@ -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',
+33
View File
@@ -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}`,
}
}
@@ -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<string, unknown> | 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<string, unknown> = {}
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,
@@ -45,7 +45,7 @@ function mockSupabase(rpcResults: Record<string, unknown>) {
const chain: Record<string, unknown> = {}
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<string, unknown>) {
})
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,
}
}
@@ -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()
})
})
@@ -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<string, () => 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 () => {
+100 -63
View File
@@ -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<number, { m: number; yOffset: number }> = {
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,
+111 -42
View File
@@ -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)
+12
View File
@@ -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 }),
+27 -13
View File
@@ -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)