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:
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user