feat(deadlines): årsstämma replaces bokslut + moms_yearly auto-complete + EU-sales suggestion (#1059)
* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal The f_skatt deadline was gated on the F-skatt approval flag (DB default true), giving nearly every company 12 monthly payment reminders for a tax Skatteverket may not have debited at all (64% of all system deadline rows, one lifetime completion). Approval carries no recurring obligation; the monthly duty is payment of debiterad preliminarskatt and exists only while the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.). - Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already collected at onboarding, previously unread) and retitle it as a payment. - Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.). - Declare the prod-only preliminary_tax_monthly column in a migration so installs built purely from migrations stop failing tax-settings saves. - Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses it durably (hard deletes were resurrected by the nightly backfill within 24h); generator, backfill, and every read surface respect it. - Prune upcoming f_skatt rows for companies with no debited amount. Closes part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): gate AGI on employer registration, stop completing AGI deadline at XML generation The arbetsgivardeklaration deadline was gated on pays_salaries, which is wrong in both directions: a registered employer must file AGI every month including nil months (SFL 26 kap. 3 par.), and companies actively running payroll with the flag off got no AGI reminders at all (each missed monthly filing risks a forseningsavgift). - New company_settings.employer_registered (nullable, no default) gates AGI and the storforetag skatteinbetalning row; pays_salaries remains a fallback for rows saved before the flag existed and keeps its UI meaning. - Migration backfills employer_registered=true from pays_salaries=true and from actual payroll activity (salary_runs). - New employer_seasonal flag: sasongsregistrerade file only for payment months plus a December nil declaration, so only the December-period row is generated. - Settings UI: registration + seasonal checkboxes (sv/en strings). - AGI XML generation no longer auto-completes the deadline as submitted: SFL 26 kap. deems the obligation satisfied only when the declaration has come in to Skatteverket. The Skatteverket extension's kvittens reconcile remains the confirming path; manual filers tick the deadline themselves. Part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): statutory arsstamma replaces bokslut, moms_yearly auto-complete, EU-sales suggestion - Replace the non-statutory 'bokslut' deadline (3 months after FY end, no legal basis, off-by-one month math for broken FYs) with the statutory arsstamma deadline: within 6 months of FY end per ABL 7 kap. 10 par., the corporate act that gates the arsredovisning filing chain. Migration deletes pending bokslut rows; the backfill cron generates arsstamma rows. - Complete moms_yearly on Skatteverket submission/kvittens: the yearly branch previously returned null with a stale comment claiming annual VAT has no deadline type, leaving yearly filers with an eternally open row. The fiscal-year tax_period label is derived from company settings. - Add /api/settings/eu-trade-signal + a tax-settings callout: companies with booked EU sales (3108/3308/3107, last 15 months) but EU-trade/PS flags off are prompted to confirm the periodisk sammanstallning obligation (SFL 35 kap., 1 250 kr late fee per report). Suggestion only, never auto-enables. Part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): include dismissed_at in DeadlineForm payload The Deadline type gained the required dismissed_at field; the form's submit payload literal must carry it for the Omit<Deadline, ...> shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): make system-deadline dismissal atomic Constrain the dismiss update to source='system' and verify a row was actually updated: a concurrent regeneration can delete the row between lookup and update, and the route must not report a phantom success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
da4d5a39ae
commit
05b954ac1d
@@ -213,3 +213,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-17] System-deadline delete = soft dismiss (dismissed_at) rather than a mute endpoint or hard delete: hard deletes were silently resurrected by the nightly backfill cron; dismissed rows satisfy the generator/backfill like completed rows.
|
||||
[2026-07-17] AGI deadline gate = employer_registered (nullable, pays_salaries fallback) with migration backfill from salary_runs: a registered employer owes monthly AGI incl. nil months (SFL 26 kap. 3 §); companies running payroll in-app are treated as employers (SFL 7 kap. 1 § obliges registration), erring toward a dismissible reminder over a missed statutory filing. Seasonal employers get only the December-period row.
|
||||
[2026-07-17] AGI XML generation no longer completes the arbetsgivardeklaration deadline: SFL 26 kap. deems the duty met only when the declaration reaches Skatteverket; kvittens reconcile remains the confirming path.
|
||||
[2026-07-17] Removed 'bokslut' deadline type (replaced by statutory 'arsstamma', ABL 7:10, 6 months): the 3-month milestone had no legal basis and its broken-FY date math was off by one month (May-start FY got 31 Aug; Nov-start rolled "Feb 31" into March). Completed bokslut rows kept for history; type removed from the union like the earlier 'moms'/'inkomstdeklaration' retirements.
|
||||
[2026-07-17] EU-trade/PS settings stay opt-in flags; a ledger-derived signal (postings on 3108/3308/3107, 15 months) only renders a suggestion callout in tax settings. Auto-flipping registration flags from ledger data would assert a Skatteverket registration we cannot know.
|
||||
|
||||
@@ -217,6 +217,25 @@ describe('VAT kvittenser cron', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('yearly picker params complete the moms_yearly deadline with the fiscal-year label', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
stubHappyTables({ ...LOCKED_STATE, periodType: 'yearly', period: 12 }),
|
||||
)
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-999' }),
|
||||
} as any)
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
// Calendar FY (company_settings unstubbed → default start month 1):
|
||||
// the moms_yearly tax_period is the plain year label.
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledWith(
|
||||
expect.anything(), 'comp-1', ['moms_yearly'], '2026', 'confirmed',
|
||||
)
|
||||
})
|
||||
|
||||
it('legacy state without picker params still flips status but skips the deadline', async () => {
|
||||
const { periodType: _pt, year: _y, period: _p, ...legacyState } = LOCKED_STATE
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables(legacyState))
|
||||
|
||||
@@ -192,20 +192,30 @@ export async function GET(request: Request) {
|
||||
// carries the picker params (written by the one-click chain; states
|
||||
// persisted by the older step-by-step routes lack them).
|
||||
if (state.periodType && state.year && state.period) {
|
||||
const taxPeriod =
|
||||
state.periodType === 'monthly'
|
||||
? `${state.year}-${String(state.period).padStart(2, '0')}`
|
||||
: state.periodType === 'quarterly'
|
||||
? `${state.year}-Q${state.period}`
|
||||
: null
|
||||
let taxPeriod: string | null = null
|
||||
let deadlineTypes: ('moms_monthly' | 'moms_quarterly' | 'moms_yearly')[] = [
|
||||
'moms_monthly',
|
||||
'moms_quarterly',
|
||||
]
|
||||
if (state.periodType === 'monthly') {
|
||||
taxPeriod = `${state.year}-${String(state.period).padStart(2, '0')}`
|
||||
} else if (state.periodType === 'quarterly') {
|
||||
taxPeriod = `${state.year}-Q${state.period}`
|
||||
} else if (state.periodType === 'yearly') {
|
||||
// moms_yearly rows carry the generator's fiscal-year label:
|
||||
// `YYYY` for calendar FYs, `YYYY-1/YYYY` for broken ones.
|
||||
const { data: fySettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('fiscal_year_start_month')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const startMonth = fySettings?.fiscal_year_start_month ?? 1
|
||||
const yearNum = Number(state.year)
|
||||
taxPeriod = startMonth === 1 ? `${yearNum}` : `${yearNum - 1}/${yearNum}`
|
||||
deadlineTypes = ['moms_yearly']
|
||||
}
|
||||
if (taxPeriod) {
|
||||
await completeTaxDeadline(
|
||||
supabase,
|
||||
companyId,
|
||||
['moms_monthly', 'moms_quarterly'],
|
||||
taxPeriod,
|
||||
'confirmed'
|
||||
)
|
||||
await completeTaxDeadline(supabase, companyId, deadlineTypes, taxPeriod, 'confirmed')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Tests for /api/settings/eu-trade-signal — ledger-derived EU sales signal.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const fetchEntryLinesMock = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/entry-lines', () => ({
|
||||
fetchEntryLines: (...args: unknown[]) => fetchEntryLinesMock(...args),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/settings/eu-trade-signal', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await GET(createMockRequest('/api/settings/eu-trade-signal'), {})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('reports EU sales when the ledger has postings on the PS accounts', async () => {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
fetchEntryLinesMock.mockResolvedValue([{ account_number: '3308' }])
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { has_eu_sales: boolean } }>(
|
||||
await GET(createMockRequest('/api/settings/eu-trade-signal'), {})
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.has_eu_sales).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no EU sales for an empty result', async () => {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
fetchEntryLinesMock.mockResolvedValue([])
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { has_eu_sales: boolean } }>(
|
||||
await GET(createMockRequest('/api/settings/eu-trade-signal'), {})
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.has_eu_sales).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
|
||||
/**
|
||||
* Accounts whose postings indicate EU B2B sales: 3108 goods, 3308 services,
|
||||
* 3107 triangulation. Same set the periodisk sammanställning report keys on.
|
||||
*/
|
||||
const EU_SALES_ACCOUNTS = ['3108', '3308', '3107']
|
||||
|
||||
/** How far back to look for EU sales postings. */
|
||||
const LOOKBACK_MONTHS = 15
|
||||
|
||||
/**
|
||||
* GET /api/settings/eu-trade-signal
|
||||
*
|
||||
* Derived suggestion signal for the tax settings page: companies with EU
|
||||
* sales in the ledger have a statutory periodisk sammanställning obligation
|
||||
* (SFL 35 kap.), but the driving settings flags are opt-in and easily left
|
||||
* off. The settings UI uses this to prompt the user to confirm EU trade and
|
||||
* PS registration; it never flips the flags itself.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'settings.eu_trade_signal',
|
||||
async (_request, { supabase, companyId }) => {
|
||||
const since = new Date()
|
||||
since.setMonth(since.getMonth() - LOOKBACK_MONTHS)
|
||||
const sinceStr = since.toISOString().split('T')[0]
|
||||
|
||||
const lines = await fetchEntryLines<{ account_number: string }>({
|
||||
supabase,
|
||||
lineColumns: 'account_number',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.gte('entry_date', sinceStr),
|
||||
filterLines: (q: EntryLinesQuery) => q.in('account_number', EU_SALES_ACCOUNTS),
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { has_eu_sales: lines.length > 0 } })
|
||||
},
|
||||
)
|
||||
@@ -10,9 +10,11 @@ import type { CompanySettings } from '@/types'
|
||||
|
||||
interface TaxSettingsFormProps {
|
||||
settings: CompanySettings
|
||||
/** Ledger-derived signal: EU sales postings exist (3108/3308/3107). */
|
||||
euSalesDetected?: boolean
|
||||
}
|
||||
|
||||
export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
|
||||
export function TaxSettingsForm({ settings, euSalesDetected = false }: TaxSettingsFormProps) {
|
||||
const t = useTranslations('settings_tax_form')
|
||||
const [vatRegistered, setVatRegistered] = useState(settings.vat_registered ?? false)
|
||||
const [fSkatt, setFSkatt] = useState(settings.f_skatt ?? true)
|
||||
@@ -96,6 +98,15 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{euSalesDetected && vatRegistered && (!hasEuTrade || !psEnabled) && (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<p className="text-sm">{t('eu_trade_suggestion_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('eu_trade_suggestion_help')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vatRegistered && (
|
||||
<div className="space-y-4 pl-7">
|
||||
<div className="max-w-xs space-y-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
|
||||
@@ -22,6 +22,25 @@ export function TaxSettingsContent() {
|
||||
|
||||
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
|
||||
// Derived EU-sales signal: postings on 3108/3308/3107 imply a periodisk
|
||||
// sammanställning obligation the opt-in flags may not reflect. Suggestion
|
||||
// only; the user confirms via the ordinary checkboxes.
|
||||
const [euSalesDetected, setEuSalesDetected] = useState(false)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/settings/eu-trade-signal')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((json) => {
|
||||
if (!cancelled && json?.data?.has_eu_sales) setEuSalesDetected(true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort signal: a failed fetch just hides the suggestion.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Skatteverket OAuth callback: the connect flow returns to /settings/tax with
|
||||
// a status query param (returnTo set in SkatteverketConnectPanel).
|
||||
useEffect(() => {
|
||||
@@ -108,7 +127,7 @@ export function TaxSettingsContent() {
|
||||
{showSkatteverket && <SkatteverketConnectPanel />}
|
||||
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-0">
|
||||
<TaxSettingsForm settings={settings} />
|
||||
<TaxSettingsForm settings={settings} euSalesDetected={euSalesDetected} />
|
||||
</SettingsFormWrapper>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2278,8 +2278,8 @@ function parseQueryParams(
|
||||
|
||||
/**
|
||||
* Build the deadline generator's tax_period string (`YYYY-MM` monthly,
|
||||
* `YYYY-QN` quarterly) from the picker params. Annual VAT has no system
|
||||
* deadline type, so yearly returns null and the deadline hook is a no-op.
|
||||
* `YYYY-QN` quarterly) from the picker params. Yearly periods use the
|
||||
* fiscal-year label and need company settings; see yearlyVatTaxPeriod.
|
||||
*/
|
||||
function vatTaxPeriod(periodType: VatPeriodType, year: number, period: number): string | null {
|
||||
if (periodType === 'monthly') return `${year}-${String(period).padStart(2, '0')}`
|
||||
@@ -2287,11 +2287,28 @@ function vatTaxPeriod(periodType: VatPeriodType, year: number, period: number):
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The moms_yearly row's tax_period is the generator's fiscal-year label:
|
||||
* `YYYY` for calendar fiscal years and `YYYY-1/YYYY` for broken ones (year
|
||||
* = the FY-end year). Derived from company settings because the picker only
|
||||
* carries the year.
|
||||
*/
|
||||
async function yearlyVatTaxPeriod(ctx: ExtensionContext, year: number): Promise<string> {
|
||||
const { data } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('fiscal_year_start_month')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
const startMonth = data?.fiscal_year_start_month ?? 1
|
||||
return startMonth === 1 ? `${year}` : `${year - 1}/${year}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the moms deadline for the period identified by the request's
|
||||
* optional periodType/year/period query params. Both moms deadline types are
|
||||
* passed: company settings decide which one exists, the other is a no-op.
|
||||
* Best-effort by design (completeTaxDeadline never throws).
|
||||
* optional periodType/year/period query params. Both monthly and quarterly
|
||||
* types are passed for sub-annual periods: company settings decide which one
|
||||
* exists, the other is a no-op. Best-effort by design (completeTaxDeadline
|
||||
* never throws).
|
||||
*/
|
||||
async function completeVatDeadlineFromRequest(
|
||||
request: Request,
|
||||
@@ -2305,12 +2322,15 @@ async function completeVatDeadlineFromRequest(
|
||||
if (!periodType || !Number.isFinite(year) || !Number.isFinite(period) || !year || !period) {
|
||||
return
|
||||
}
|
||||
const taxPeriod = vatTaxPeriod(periodType, year, period)
|
||||
const taxPeriod =
|
||||
periodType === 'yearly'
|
||||
? await yearlyVatTaxPeriod(ctx, year)
|
||||
: vatTaxPeriod(periodType, year, period)
|
||||
if (!taxPeriod) return
|
||||
await completeTaxDeadline(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
['moms_monthly', 'moms_quarterly'],
|
||||
periodType === 'yearly' ? ['moms_yearly'] : ['moms_monthly', 'moms_quarterly'],
|
||||
taxPeriod,
|
||||
newStatus
|
||||
)
|
||||
|
||||
@@ -248,11 +248,13 @@ describe('Enum schemas', () => {
|
||||
const types = [
|
||||
'moms_monthly', 'moms_quarterly', 'moms_yearly', 'f_skatt',
|
||||
'arbetsgivardeklaration', 'inkomstdeklaration_ef', 'inkomstdeklaration_ab',
|
||||
'arsredovisning', 'periodisk_sammanstallning', 'bokslut',
|
||||
'arsredovisning', 'arsstamma', 'periodisk_sammanstallning',
|
||||
]
|
||||
for (const t of types) {
|
||||
expect(TaxDeadlineTypeSchema.safeParse(t).success).toBe(true)
|
||||
}
|
||||
// Retired: replaced by the statutory arsstamma deadline (ABL 7:10).
|
||||
expect(TaxDeadlineTypeSchema.safeParse('bokslut').success).toBe(false)
|
||||
})
|
||||
|
||||
it('NormalBalanceSchema and MappingRuleTypeSchema', () => {
|
||||
|
||||
+1
-1
@@ -260,8 +260,8 @@ export const TaxDeadlineTypeSchema = z.enum([
|
||||
'inkomstdeklaration_ef',
|
||||
'inkomstdeklaration_ab',
|
||||
'arsredovisning',
|
||||
'arsstamma',
|
||||
'periodisk_sammanstallning',
|
||||
'bokslut',
|
||||
])
|
||||
|
||||
export const DeadlineSourceSchema = z.enum(['system', 'user'])
|
||||
|
||||
@@ -182,8 +182,8 @@ function getSwedishTaxTypeLabel(type: string): string {
|
||||
inkomstdeklaration_ef: 'Inkomstdeklaration EF',
|
||||
inkomstdeklaration_ab: 'Inkomstdeklaration AB',
|
||||
arsredovisning: 'Årsredovisning',
|
||||
arsstamma: 'Årsstämma',
|
||||
periodisk_sammanstallning: 'Periodisk sammanställning',
|
||||
bokslut: 'Bokslut',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
@@ -296,6 +296,38 @@ describe('inkomstdeklaration_ab: digital filing deadlines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('arsstamma: 6 months after FY end (ABL 7:10)', () => {
|
||||
const config = getConfig('arsstamma')
|
||||
|
||||
it('applies only to aktiebolag', () => {
|
||||
expect(config.condition(makeSettings())).toBe(true)
|
||||
expect(config.condition(makeSettings({ entity_type: 'enskild_firma' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('FY end Dec (calendar year) → Jun 30 next year', () => {
|
||||
const dates = config.generateDates(2026, makeSettings({ fiscal_year_start_month: 1 }))
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 30, month: 5, year: 2026, period: '2025' })
|
||||
})
|
||||
|
||||
it('FY end Apr → Oct 31 same year, broken-FY period label', () => {
|
||||
const dates = config.generateDates(2026, makeSettings({ fiscal_year_start_month: 5 }))
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 31, month: 9, year: 2026, period: '2025/2026' })
|
||||
})
|
||||
|
||||
it('uses the last day of the deadline month (handles Feb)', () => {
|
||||
// FY end Aug 2026 → +6 months = Feb 2027
|
||||
const dates = config.generateDates(2027, makeSettings({ fiscal_year_start_month: 9 }))
|
||||
expect(dates.length).toBe(1)
|
||||
expect(dates[0]).toMatchObject({ day: 28, month: 1, year: 2027 })
|
||||
})
|
||||
|
||||
it('no bokslut deadline type exists anymore', () => {
|
||||
expect(TAX_DEADLINE_CONFIGS.find((c) => (c.type as string) === 'bokslut')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('arsredovisning: 7 months after FY end (ÅRL 8:3)', () => {
|
||||
const config = getConfig('arsredovisning')
|
||||
|
||||
|
||||
+30
-16
@@ -441,28 +441,42 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// Bokslut (AB) - 31 mars for calendar year
|
||||
// Årsstämma (AB): within 6 months of FY end per ABL 7 kap. 10 §. Replaces
|
||||
// the former non-statutory 'bokslut' milestone (3 months had no legal
|
||||
// basis). The stämma gates the årsredovisning chain: the AR is presented
|
||||
// and adopted there, and the Bolagsverket filing (arsredovisning row,
|
||||
// 7 months) requires the adopted AR.
|
||||
{
|
||||
type: 'bokslut',
|
||||
titleTemplate: 'Bokslut räkenskapsår {periodLabel}',
|
||||
description: 'Bokslut för aktiebolag',
|
||||
type: 'arsstamma',
|
||||
titleTemplate: 'Årsstämma räkenskapsår {periodLabel}',
|
||||
description: 'Årsstämma för aktiebolag (senast sex månader efter räkenskapsårets utgång)',
|
||||
condition: (s) => s.entity_type === 'aktiebolag',
|
||||
priority: 'important',
|
||||
linkedReportType: null,
|
||||
generateDates: (year, settings) => {
|
||||
// For calendar year, December 31 is fiscal year end, deadline March 31
|
||||
if (settings.fiscal_year_start_month === 1) {
|
||||
return [
|
||||
{ day: 31, month: 2, year, period: `${year - 1}`, periodLabel: `${year - 1}` }, // March
|
||||
]
|
||||
// FY end month (1-indexed)
|
||||
const fyEndMonth = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
|
||||
|
||||
// Last day of (FY end month + 6). Swedish fiscal years always end on
|
||||
// the last day of a calendar month (BFL 3 kap.), so this equals the
|
||||
// statutory six-month limit.
|
||||
const results: DeadlineInstance[] = []
|
||||
for (const endYr of [year - 1, year]) {
|
||||
const dlMonth0 = ((fyEndMonth - 1) + 6) % 12
|
||||
const dlYear = (fyEndMonth - 1) + 6 >= 12 ? endYr + 1 : endYr
|
||||
if (dlYear === year) {
|
||||
const lastDay = new Date(dlYear, dlMonth0 + 1, 0).getDate()
|
||||
const periodLabel = fyEndMonth === 12 ? `${endYr}` : `${endYr - 1}/${endYr}`
|
||||
results.push({
|
||||
day: lastDay,
|
||||
month: dlMonth0,
|
||||
year: dlYear,
|
||||
period: periodLabel,
|
||||
periodLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
// For non-calendar fiscal years, 3 months after year end
|
||||
const fiscalYearEnd = settings.fiscal_year_start_month - 1
|
||||
const deadlineMonth = (fiscalYearEnd + 3) % 12
|
||||
const deadlineYear = deadlineMonth < fiscalYearEnd ? year + 1 : year
|
||||
return [
|
||||
{ day: 31, month: deadlineMonth, year: deadlineYear, period: `${year - 1}/${year}`, periodLabel: `${year - 1}/${year}` },
|
||||
]
|
||||
return results
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1780,6 +1780,8 @@
|
||||
"pays_salaries_help": "Controls the salary module in the menu. Paying out salary requires employer registration with Skatteverket.",
|
||||
"employer_registered_label": "Registered as employer",
|
||||
"employer_registered_help": "Registered employers must file an employer declaration (AGI) every month, including months without salary payments (nil declaration). Controls AGI deadlines.",
|
||||
"eu_trade_suggestion_title": "Your bookkeeping contains EU sales",
|
||||
"eu_trade_suggestion_help": "There are booked EU sales (accounts 3108, 3308 or 3107) in the last 15 months, but EU trade or the EU sales list is not enabled below. The EU sales list (periodisk sammanställning) must be filed monthly or quarterly when selling to businesses in other EU countries; the late fee is 1,250 kr per report.",
|
||||
"employer_seasonal_label": "Seasonal employer (säsongsregistrerad)",
|
||||
"employer_seasonal_help": "Files the employer declaration only for months with salary payments, plus December if no salary was paid during the year.",
|
||||
"preliminary_tax_heading": "Preliminary tax",
|
||||
|
||||
@@ -1780,6 +1780,8 @@
|
||||
"pays_salaries_help": "Styr lönemodulen i menyn. Att betala ut lön kräver arbetsgivarregistrering hos Skatteverket.",
|
||||
"employer_registered_label": "Registrerad som arbetsgivare",
|
||||
"employer_registered_help": "Registrerade arbetsgivare ska lämna arbetsgivardeklaration varje månad, även månader utan löneutbetalning (nolldeklaration). Styr AGI-deadlines.",
|
||||
"eu_trade_suggestion_title": "Bokföringen innehåller EU-försäljning",
|
||||
"eu_trade_suggestion_help": "Det finns bokförda EU-försäljningar (konto 3108, 3308 eller 3107) de senaste 15 månaderna, men EU-handel eller periodisk sammanställning är inte aktiverad nedan. Periodisk sammanställning ska lämnas varje månad eller kvartal vid försäljning till företag i andra EU-länder; förseningsavgiften är 1 250 kr per rapport.",
|
||||
"employer_seasonal_label": "Säsongsregistrerad arbetsgivare",
|
||||
"employer_seasonal_help": "Lämnar arbetsgivardeklaration bara för månader med löneutbetalning, samt för december om ingen lön betalats under året.",
|
||||
"preliminary_tax_heading": "Preliminärskatt",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Remove the non-statutory 'bokslut' deadline (issue #1028).
|
||||
--
|
||||
-- The "3 months after FY end" bokslut deadline had no legal basis (the
|
||||
-- statutory anchors for AB are the arsstamma within 6 months, ABL 7 kap.
|
||||
-- 10 §, and the Bolagsverket filing within 7 months, ARL 8 kap.), and its
|
||||
-- non-calendar-FY date math was off by one month (a May-start FY produced
|
||||
-- 31 Aug instead of 31 Jul; a Nov-start FY produced "Feb 31" which rolled
|
||||
-- into March). The generator now produces an 'arsstamma' deadline instead;
|
||||
-- the daily backfill cron creates those rows on its next run.
|
||||
--
|
||||
-- Completed rows are kept for history; pending rows are template noise.
|
||||
DELETE FROM public.deadlines
|
||||
WHERE source = 'system'
|
||||
AND tax_deadline_type = 'bokslut'
|
||||
AND is_completed = false;
|
||||
+3
-3
@@ -2174,8 +2174,8 @@ export type TaxDeadlineType =
|
||||
| 'inkomstdeklaration_ef'
|
||||
| 'inkomstdeklaration_ab'
|
||||
| 'arsredovisning'
|
||||
| 'arsstamma'
|
||||
| 'periodisk_sammanstallning'
|
||||
| 'bokslut'
|
||||
|
||||
// Deadline status workflow
|
||||
export type DeadlineStatus =
|
||||
@@ -2352,8 +2352,8 @@ export const TAX_DEADLINE_TYPE_LABELS: Record<TaxDeadlineType, string> = {
|
||||
inkomstdeklaration_ef: 'Inkomstdeklaration EF',
|
||||
inkomstdeklaration_ab: 'Inkomstdeklaration AB',
|
||||
arsredovisning: 'Årsredovisning',
|
||||
periodisk_sammanstallning: 'Periodisk sammanställning',
|
||||
bokslut: 'Bokslut'
|
||||
arsstamma: 'Årsstämma',
|
||||
periodisk_sammanstallning: 'Periodisk sammanställning'
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user