* fix(bookkeeping): harden correction account changes

* feat(tax): enhance tax deadline generation with new settings and filing methods

- Added new company settings: tax_turnover_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, and periodisk_sammanstallning_filing_method.
- Updated deadline generation logic to accommodate new settings affecting VAT and employer declaration deadlines.
- Implemented tests for new functionality, ensuring that completed obligations are preserved and not replaced by new pending rows.
- Introduced a cron job to backfill missing tax deadlines for companies with settings but no upcoming deadlines.
- Updated API routes for generating tax deadlines and handling cron jobs.
- Modified database schema to include new columns for tax filing profiles and constraints for filing methods.

* fix(invoices): record credit note reconciliation guard

* fix(tax): correct automatic deadline settings

* fix(tax): key AGI deadline to VAT taxable base and add storforetag payment deadline

The 26th filing day for the skattedeklaration (AGI and VAT together) hinges
on one statutory measure, a VAT taxable base above SEK 40 million (SFL 26
kap.), not a separate employer turnover. Drop employer_turnover_over_40m and
derive the AGI schedule from vat_registered plus vat_taxable_base_over_40m,
so a non-VAT-reporting employer is never shown the 26th when its binding
date is the 12th.

Also:
- add a skatteinbetalning deadline row (12th, 17 January) for storforetag,
  whose deducted tax and employer contributions are due before the 26th
  filing date
- normalize legally incoherent over-40m flag combinations to the earlier
  small-company schedule in a follow-up migration
- replace hardcoded 27 December dates with the banking-day adjustment
- extend the 40m help text to cover the SKV-decided early filing election
  and the payment-still-on-the-12th rule
- document the regeneration race repaired by the daily backfill cron

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(migrations): add AGI and VAT filing logic with employer column removal

* feat(settings): implement VAT registration logic and update related flags; enhance deadline handling

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-17 00:52:57 +02:00
committed by GitHub
parent 1443235cec
commit a5e37d3510
32 changed files with 1978 additions and 259 deletions
+20
View File
@@ -164,9 +164,29 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-15] Superseded the hard deletion part of the 2026-07-14 credit note draft decision: numbered credit note drafts are now retained as cancelled rows and reopened on retry so the KR series remains complete.
[2026-07-15] Customer personnummer uses application field encryption with masked API and UI output rather than a database-only cipher: the existing key custody and AES-256-GCM implementation can protect values before they reach Postgres, while ordinary reads never expose the full identifier.
[2026-07-15] Credit note creation uses a completion marker plus unique company guards instead of a large creation RPC: incomplete parents are never returned, concurrent requests converge, and all journal writes remain in the bookkeeping engine.
[2026-07-15] Correction account changes refresh a line description only when it is blank or matches the previous company or BAS account name: all company accounts, including inactive ones, provide provenance while custom voucher text stays intact.
[2026-07-16] Supabase MCP is project-scoped to the linked erp-base project and read-only by default: the repository treats local credentials as production access, and both Supabase guidance and repository rules require minimizing production mutation risk.
[2026-07-16] Tax deadline recovery runs daily and EU sales-list deadlines require explicit opt-in: missing deadline rows must self-heal, while VAT registration alone does not prove an EU reporting obligation.
[2026-07-16] Split the SEK 40 million deadline threshold into VAT taxable base and employer turnover settings: Skatteverket applies different measures to VAT and AGI, so one shared answer can produce a wrong statutory date.
[2026-07-16] Tax deadline recovery compares every expected type and period key with stored upcoming obligations: the presence of one deadline no longer hides a partial generation failure for the same company.
[2026-07-16] Retained the legacy tax_turnover_over_40m column after backfilling the split deadline thresholds: dropping it in the same migration could break an older application instance during a rolling deploy or rollback; new code does not read or write it.
[2026-07-16] Superseded the same-day threshold split: the 26th filing day for both VAT and AGI hinges on one statutory measure, the VAT taxable base over SEK 40 million (SFL 26 kap., confirmed against Skatteverket's guidance), so employer_turnover_over_40m is dropped and AGI follows vat_registered plus vat_taxable_base_over_40m. A separate employer flag let a non-VAT-reporting employer be shown the 26th when its binding date is the 12th.
[2026-07-16] The 20260716120353 threshold-split migration stays untouched and a follow-up migration drops the employer column: staging already recorded that version, so editing the file would desynchronize environments instead of converging them.
[2026-07-16] The over-40m normalization clears the VAT flag for legally incoherent stored combinations (not VAT-registered or not monthly) instead of guessing the intended schedule: every ambiguity resolves to the earlier small-company dates, which can never cause a late filing.
[2026-07-16] Storföretag get a dedicated skatteinbetalning deadline row (12th, 17 January): their skattedeklaration is filed the 26th but deducted tax and employer contributions must be on skattekontot by the 12th, and showing only the filing date hides the earlier payment obligation.
[2026-07-16] Deadline dates rely on the banking-day adjuster instead of hardcoding Skatteverket's 27 December: annandag jul adjustment produces the published date, and one mechanism keeps VAT and AGI December handling identical.
[2026-07-16] Closed #1000 (v1 match-supplier-invoice FX + cash-method settlement account): threaded the already-resolved paymentAccount into createSupplierInvoicePaymentEntry (was gated on isPureSek) and createSupplierInvoiceCashEntry (was passed undefined), and widened the findUnresolvableAccounts chart pre-validation from the pure-SEK accrual path to every !customLines branch, since every non-customLines branch now consumes the resolved account. The dashboard route needed no code change (its FX/cash branches were already threaded inside PR #985 itself); added branch-level regression tests on both routes instead. The entry generators keep their internal 1930 default: it is the documented no-link fallback, reached via resolveSettlementAccount returning 1930 for transactions without a cash_account_id.
[2026-07-16] Two bugs from one customer report (an AB). Bug 1 (acct 2893 showed 2393's "langfristig del" memo after an andringsverifikation): root cause = CorrectionEntryDialog never re-derived line_description on account change (JournalEntryForm does). Fixed forward via a pure helper (correction-line-description.ts) that refreshes the memo only when it is empty or still equals the prev account's name (preserves hand-typed memos). Chose NO prod data repair: the wrong memo sits on a POSTED verifikat (immutable per migration-017 trigger); it is cosmetic (account number + amounts correct, all reports key off the number); ~26 posted lines across 11 cos share this stale-echo pattern, all fix-forward only. Deferred the twin entry-level header fix (#1031). Bug 2 (auto tax-deadlines never appeared): root cause = generation only fired on a settings save where a TAX field CHANGED value (didTaxFieldsChange); settings are filled once at onboarding so re-saving generated nothing -> only 5/776 real cos had system deadlines. Chose count-based self-heal (regenerate when the company has 0 system deadlines) over always-regenerate, because generateTaxDeadlinesForUser deletes+reinserts and would reset is_completed/status on every unrelated save. Also wired the /deadlines empty-state to the existing (dead) /api/tax-deadlines/generate route, and fixed a 1000-row PostgREST cap in the annual cron. Backfilled 771 real cos with zero system deadlines via scripts/backfill-tax-deadlines.ts. Deferred moms_period=yearly config (#1030, 295 filers, largest VAT cohort): helarsmoms deadline (SFL 26 kap. 33-33b) depends on EU-trade status (no flag in CompanySettingsForDeadlines) and, for AB, the income-tax-return date.
[2026-07-15] Repaired the single legacy paid credit note blocking invoices_credit_note_not_paid validation by normalizing its invoice metadata to sent, clearing payment fields, setting zero payable remainder, and linking its existing balanced posted V44 reversal: the immutable voucher already exactly reversed V42 and was not edited or duplicated.
[2026-07-14] Issue #1016 (create_transactions never binds cash_account_id): fixed forward-only via an optional ledger_account hint on gnubok_create_transactions + commitCreateTransaction, resolved through a new ensureManualCashAccount find-or-create helper (lib/cash-accounts/service.ts). No migration: cash_accounts.bank_connection_id is already nullable and source='manual' already exists (the seeded 1930 is a manual row), so the handoff's premise that manual kassakonton need a schema change was wrong. Pre-creating a manual row does NOT race upsertFromPsd2 (the ingest.ts "never auto-create" worry): a later PSD2 connection promotes a manual holder in place, the intended flow (#916/#56). Hint restricted to ^19\d{2}$ (BAS kassa/bank group) so a transaction can't bind to a non-cash account. Scoped to the MCP create path per user decision; POST /api/cash-accounts + settings UI, relaxing ingest.ts's settlement_account auto-create, and historical backfill of cash_account_id=null rows (deferred to #1001) are follow-ups.
[2026-07-14] Wise (TransferWise) CSV import added as a bank-file format plugin (lib/import/bank-file/formats/wise.ts), flowing through the existing upload->preview->confirm->execute wizard. Decisions: (1) parser preserves NATIVE currency per row (multi-currency statement); SEK conversion is left to the downstream FX/booking pipeline (Riksbanken), not done in the parser, so nothing here converts. Booking in the account's own currency via the Wise API rate is a deferred/extended feature. (2) Direction IN/OUT drives the sign; the row is booked on the side that moved (target for IN, source for OUT). (3) Non-zero Wise fees become their OWN negative "Wise avgift" transaction row (source and target fee both handled) rather than being folded or dropped, so the balance ties out and the fee lands in the inbox to categorize (e.g. 6570). (4) Only COMPLETED rows import; pending/cancelled/refunded skipped. (5) external_id keys on the stable Wise ID (TRANSFER-.../PLAN_ORDER-...) carried in raw_line, with a -fee/-tgtfee suffix for fee rows, so re-importing dedups exactly instead of via row hash (generateExternalId gains a 'wise' branch mirroring camt053). Scoped to the format plugin per user decision; per-currency auto-routing to manual kassakonton (would pair with #1016) not done here.
[2026-07-16] Merge of the always-regenerate-on-tax-save branch with main's count-based self-heal keeps both triggers: a save containing tax-relevant fields regenerates (the generator now preserves completed rows, so main's is_completed-clobbering objection no longer applies), and a save without them backfills only when the company has zero system deadlines. The daily repair identity gains the due date (type:period:due_date): main's backfill created rows for 771 companies under the superseded schedules, and a type-and-period key alone would never flag their wrong dates for repair.
[2026-07-17] Renamed migration 20260716150000_agi_follows_vat_taxable_base.sql to 20260717070000: main merged an unrelated migration under the same version (20260716150000_company_settings_defer_invoice_booking, PR #1040) and the preview branch aborted on schema_migrations_pkey; the branch's version was never recorded remotely, so a rename converges instead of orphaning.
[2026-07-17] Rejected the PR-review claim that quarterly momsdeklaration is due the 26th of the month after the quarter: per SFL 26 kap. 26 § and Skatteverket's schedule, quarterly filers (only possible at or below SEK 40M) file by the 12th of the second month after quarter end (17 August for Q2); the 26th applies exclusively to over-40M monthly filers (26 kap. 30 §). The removed 26th dates were the wrong ones; the new 12 May / 17 Aug / 12 Nov / 12 Feb dates stand.
[2026-07-17] Confirmed 26 February for enskild firma annual VAT with EU trade (26 kap. 33-33a §§ SFL, Skatteverket helårsmoms schedule); without EU trade the return follows the income declaration date (12 May). Documented in a code comment instead of changing dates.
[2026-07-17] Storföretag skatteinbetalning uses the 17th only in January: the 17 August exception belongs to the below-40M schedule; above 40M the payment date is the 12th every month except January. Documented in deadline-config rather than adding an August exception.
[2026-07-17] Deadline repair identity split: incomplete rows match on type:period:due_date (stale dates must be repaired) but completed rows match on type:period alone, since the generator never replaces a filed obligation and a date-keyed completed row would re-flag the company on every cron run without converging. The generator's completed-row lookup floor also moved from today to a year before the earliest generated year so a filed obligation with a passed superseded date is not resurrected as pending.
[2026-07-17] Settings PUT keeps update(body) without a pick() allow-list: UpdateSettingsSchema is the write boundary (z.object strips unknown keys; company_id/onboarding_complete are not in the schema, org_number is deleted post-onboarding), so the mass-assignment finding does not apply.
[2026-07-17] Turning vat_registered off (or vat_has_eu_trade off) now coerces the dependent flags (vat_taxable_base_over_40m, vat_has_eu_trade, periodisk_sammanstallning_enabled) to false server-side instead of 400-ing on the stale stored combination; explicitly enabling PS without registration or EU trade still 400s. PS period/filing-method preferences are deliberately preserved while PS is disabled (they are inert until re-enabled).
[2026-07-17] Kept the cron's cross-tenant company_settings/deadlines scans on the service client: a daily all-company repair job is inherently cross-tenant, is cron-secret-gated, and per-company scoping would turn one paginated query into N queries; the per-company writes remain scoped by company_id inside the generator.
+41 -15
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DeadlineList } from '@/components/deadlines/DeadlineList'
@@ -19,6 +20,7 @@ const supabase = createClient()
export default function DeadlinesPage() {
const { company } = useCompany()
const companyId = company?.id
const t = useTranslations('deadlines')
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
@@ -28,31 +30,55 @@ export default function DeadlinesPage() {
const { toast } = useToast()
const fetchData = useCallback(async () => {
if (!company) return
if (!companyId) return
setIsLoading(true)
try {
const today = new Date().toISOString().split('T')[0]
const [deadlinesRes, customersRes, overdueRes] = await Promise.all([
supabase.from('deadlines').select('*, customer:customers(name)').eq('company_id', company.id).order('due_date', { ascending: true }),
supabase.from('customers').select('id, name').eq('company_id', company.id).order('name', { ascending: true }),
supabase.from('invoices').select('total_sek, total').eq('company_id', company.id).in('status', ['sent', 'unpaid']).lt('due_date', today),
// fetchAllRows: PostgREST silently caps plain selects at 1000 rows,
// which would truncate the deadline list and undercount overdue
// invoices for large companies. The secondary .order('id') gives the
// stable total order paging requires.
const [deadlineRows, customerRows, overdueRows] = await Promise.all([
fetchAllRows<Deadline>(({ from, to }) =>
supabase
.from('deadlines')
.select('*, customer:customers(name)')
.eq('company_id', companyId)
.order('due_date', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
),
fetchAllRows<{ id: string; name: string }>(({ from, to }) =>
supabase
.from('customers')
.select('id, name')
.eq('company_id', companyId)
.order('name', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
),
fetchAllRows<{ total_sek: number | null; total: number | null }>(({ from, to }) =>
supabase
.from('invoices')
.select('total_sek, total')
.eq('company_id', companyId)
.in('status', ['sent', 'unpaid'])
.lt('due_date', today)
.order('id', { ascending: true })
.range(from, to),
),
])
if (deadlinesRes.error) throw deadlinesRes.error
if (customersRes.error) throw customersRes.error
if (overdueRes.error) throw overdueRes.error
const overdueCount = overdueRes.data?.length || 0
const overdueTotal = (overdueRes.data || []).reduce(
const overdueTotal = overdueRows.reduce(
(sum, inv) => sum + (inv.total_sek || inv.total || 0),
0
)
setDeadlines(deadlinesRes.data || [])
setCustomers(customersRes.data || [])
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
setDeadlines(deadlineRows)
setCustomers(customerRows)
setOverdueInvoices({ count: overdueRows.length, total: overdueTotal })
} catch {
toast({
title: t('load_failed_title'),
@@ -62,7 +88,7 @@ export default function DeadlinesPage() {
} finally {
setIsLoading(false)
}
}, [toast, t])
}, [companyId, toast, t])
useEffect(() => {
fetchData()
+154 -7
View File
@@ -19,14 +19,21 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/tax/deadline-generator', () => ({
didTaxFieldsChange: vi.fn().mockReturnValue(false),
regenerateTaxDeadlinesForUser: vi.fn().mockResolvedValue(undefined),
shouldRegenerateTaxDeadlines: vi.fn(
(changed: boolean, count: number) => changed || count === 0,
),
const deadlineMocks = vi.hoisted(() => ({
regenerate: vi.fn().mockResolvedValue(undefined),
}))
// Mock only the function that writes to the database. The field detector,
// settings normalizer, and regeneration predicate stay real so these tests
// fail if a new tax-relevant field stops triggering regeneration.
vi.mock('@/lib/tax/deadline-generator', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/tax/deadline-generator')>()
return {
...actual,
regenerateTaxDeadlinesForUser: deadlineMocks.regenerate,
}
})
import { PUT } from '../route'
import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
@@ -87,6 +94,37 @@ describe('PUT /api/settings', () => {
expect(status).toBe(200)
expect(body.data.company_name).toBe('New Name')
expect(deadlineMocks.regenerate).not.toHaveBeenCalled()
})
it('regenerates deadlines when unchanged tax settings are saved', async () => {
const settings = {
company_id: 'company-1',
entity_type: 'aktiebolag',
moms_period: 'monthly',
f_skatt: true,
vat_registered: false,
pays_salaries: false,
fiscal_year_start_month: 1,
onboarding_complete: true,
}
enqueueMany([
{ data: settings },
{ data: { id: 's1', ...settings } },
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { f_skatt: true, vat_registered: false },
})
const response = await PUT(request, { params: Promise.resolve({}) })
expect(response.status).toBe(200)
expect(deadlineMocks.regenerate).toHaveBeenCalledWith(
supabase,
'company-1',
expect.objectContaining({ entity_type: 'aktiebolag', f_skatt: true }),
)
})
it('updates all three reminder thresholds', async () => {
@@ -149,9 +187,11 @@ describe('PUT /api/settings', () => {
{ data: null, count: 0 }, // no system deadlines -> self-heal generation
])
// A save with NO tax-relevant field: only the zero-count self-heal path
// can trigger regeneration here.
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { f_skatt: true },
body: { company_name: 'Self Heal AB' },
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
@@ -160,6 +200,65 @@ describe('PUT /api/settings', () => {
expect(vi.mocked(regenerateTaxDeadlinesForUser)).toHaveBeenCalledOnce()
})
it('clears VAT-dependent flags when VAT registration is turned off', async () => {
const settings = {
company_id: 'company-1',
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: 'SE556012579001',
moms_period: 'quarterly',
vat_taxable_base_over_40m: false,
vat_has_eu_trade: true,
periodisk_sammanstallning_enabled: true,
onboarding_complete: true,
}
enqueueMany([
{ data: settings },
{
data: {
...settings,
id: 's1',
vat_registered: false,
vat_has_eu_trade: false,
periodisk_sammanstallning_enabled: false,
},
},
])
// Without the coercion this request 400s: the stored PS flag stays
// effective while registration is being switched off.
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { vat_registered: false },
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(deadlineMocks.regenerate).toHaveBeenCalledOnce()
})
it('still rejects explicitly enabling the EU sales list without EU trade', async () => {
enqueue({
data: {
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: 'SE556012579001',
moms_period: 'quarterly',
vat_has_eu_trade: false,
onboarding_complete: true,
},
})
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { periodisk_sammanstallning_enabled: true },
})
const response = await PUT(request, { params: Promise.resolve({}) })
expect(response.status).toBe(400)
})
it('does not regenerate tax deadlines when the company already has some', async () => {
enqueueMany([
{ data: { entity_type: 'aktiebolag', onboarding_complete: true } }, // oldSettings
@@ -202,6 +301,54 @@ describe('PUT /api/settings', () => {
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects quarterly VAT when the VAT taxable base is above SEK 40 million', async () => {
enqueue({
data: {
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: 'SE556012579001',
moms_period: 'quarterly',
vat_taxable_base_over_40m: false,
onboarding_complete: true,
},
})
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { vat_taxable_base_over_40m: true },
})
const response = await PUT(request, { params: Promise.resolve({}) })
expect(response.status).toBe(400)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('allows EU-trade changes with quarterly VAT and regenerates deadlines', async () => {
const settings = {
company_id: 'company-1',
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: 'SE556012579001',
moms_period: 'quarterly',
vat_taxable_base_over_40m: false,
vat_has_eu_trade: true,
onboarding_complete: true,
}
enqueueMany([
{ data: { ...settings, vat_has_eu_trade: false } },
{ data: settings },
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { vat_has_eu_trade: true },
})
const response = await PUT(request, { params: Promise.resolve({}) })
expect(response.status).toBe(200)
expect(deadlineMocks.regenerate).toHaveBeenCalledOnce()
})
it('returns 404 when the settings row does not exist', async () => {
enqueueMany([
{ data: { onboarding_complete: false } },
+61 -24
View File
@@ -1,6 +1,12 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { didTaxFieldsChange, regenerateTaxDeadlinesForUser, shouldRegenerateTaxDeadlines } from '@/lib/tax/deadline-generator'
import {
DEADLINE_SETTINGS_SELECT,
hasTaxRelevantFields,
regenerateTaxDeadlinesForUser,
shouldRegenerateTaxDeadlines,
toDeadlineSettings,
} from '@/lib/tax/deadline-generator'
import { validateBody } from '@/lib/api/validate'
import { UpdateSettingsSchema } from '@/lib/api/schemas'
@@ -36,11 +42,11 @@ export const GET = withRouteContext(
export const PUT = withRouteContext(
'settings.update',
async (request, { supabase, companyId }) => {
async (request, { supabase, companyId, log }) => {
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
.select(`${DEADLINE_SETTINGS_SELECT}, vat_number, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3`)
.eq('company_id', companyId)
.single()
@@ -106,8 +112,23 @@ export const PUT = withRouteContext(
}
}
// Turning VAT registration off retires the VAT-dependent flags, and
// dropping EU trade retires the EU sales list: stale true values would
// otherwise block the save below or silently resurrect wrong deadlines
// when registration is re-enabled later. Same coherence rule as the
// 20260717070000 migration and the tax settings form.
if (body.vat_registered === false) {
body.vat_taxable_base_over_40m = false
body.vat_has_eu_trade = false
body.periodisk_sammanstallning_enabled = false
}
if (body.vat_has_eu_trade === false) {
body.periodisk_sammanstallning_enabled = false
}
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period
if (effectiveVatRegistered === true) {
const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number
if (!effectiveVatNumber) {
@@ -116,7 +137,6 @@ export const PUT = withRouteContext(
{ status: 400 }
)
}
const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period
if (!effectiveMomsPeriod) {
return NextResponse.json(
{ error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' },
@@ -125,6 +145,27 @@ export const PUT = withRouteContext(
}
}
const effectiveVatTaxableBaseOver40m =
body.vat_taxable_base_over_40m ?? oldSettings?.vat_taxable_base_over_40m ?? false
if (effectiveVatRegistered && effectiveVatTaxableBaseOver40m && effectiveMomsPeriod !== 'monthly') {
return NextResponse.json(
{ error: 'Företag med beskattningsunderlag över 40 miljoner kronor måste redovisa moms varje månad.' },
{ status: 400 },
)
}
const effectivePsEnabled =
body.periodisk_sammanstallning_enabled ??
oldSettings?.periodisk_sammanstallning_enabled ??
false
const effectiveEuTrade = body.vat_has_eu_trade ?? oldSettings?.vat_has_eu_trade ?? false
if (effectivePsEnabled && (!effectiveVatRegistered || !effectiveEuTrade)) {
return NextResponse.json(
{ error: 'Periodisk sammanställning kräver momsregistrering och EU-handel.' },
{ status: 400 },
)
}
const { data, error } = await supabase
.from('company_settings')
.update(body)
@@ -139,39 +180,35 @@ export const PUT = withRouteContext(
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Regenerate tax deadlines when a tax-relevant field changed OR when the
// company has no system-generated deadlines yet. The latter is the common
// case: tax settings are filled at onboarding, so a later save with no
// tax-field change never triggered generation and the deadlines page stayed
// empty even though the settings were "filled in". Backfilling when the set
// is empty is safe: there is no existing progress/status to clobber.
const taxFieldsChanged = Boolean(oldSettings && didTaxFieldsChange(oldSettings, data))
// Regenerate when the save touches tax-relevant fields: the statutory
// dates are derived from them, and re-running also repairs rows created
// by older schedule logic or lost to an earlier generation failure. The
// generator preserves completed rows, so filing progress survives.
// Additionally self-heal when the company has no system deadlines at all:
// tax settings are filled at onboarding, so an unrelated later save may be
// the first chance to backfill an empty set.
const taxFieldsInBody = hasTaxRelevantFields(body)
let existingSystemDeadlineCount = 0
if (!taxFieldsChanged) {
if (!taxFieldsInBody) {
const { count, error: countError } = await supabase
.from('deadlines')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('source', 'system')
.eq('deadline_type', 'tax')
// Fail safe: on a count error, assume deadlines already exist so we do
// NOT delete+regenerate on a transient failure (regeneration would reset
// is_completed/status). A non-zero placeholder keeps the self-heal off.
// the status of pending rows). A non-zero placeholder keeps the
// self-heal off.
existingSystemDeadlineCount = countError ? 1 : (count ?? 0)
}
if (shouldRegenerateTaxDeadlines(taxFieldsChanged, existingSystemDeadlineCount)) {
if (shouldRegenerateTaxDeadlines(taxFieldsInBody, existingSystemDeadlineCount)) {
try {
await regenerateTaxDeadlinesForUser(supabase, companyId, {
entity_type: data.entity_type,
moms_period: data.moms_period,
f_skatt: data.f_skatt,
vat_registered: data.vat_registered,
pays_salaries: data.pays_salaries ?? false,
fiscal_year_start_month: data.fiscal_year_start_month,
})
console.log('Tax deadlines regenerated after settings change')
await regenerateTaxDeadlinesForUser(supabase, companyId, toDeadlineSettings(data))
log.info('tax deadlines regenerated after settings change')
} catch (err) {
console.error('Failed to regenerate tax deadlines:', err)
log.error('failed to regenerate tax deadlines', err as Error)
// Don't fail the settings update if deadline generation fails
}
}
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
const mocks = vi.hoisted(() => ({
createClient: vi.fn(() => ({ kind: 'service-client' })),
annual: vi.fn(),
backfill: vi.fn(),
}))
vi.mock('@supabase/supabase-js', () => ({
createClient: mocks.createClient,
}))
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn(() => null),
}))
vi.mock('@/lib/tax/deadline-generator', () => ({
generateNewYearDeadlines: mocks.annual,
backfillMissingTaxDeadlines: mocks.backfill,
}))
import { verifyCronSecret } from '@/lib/auth/cron'
import { GET } from '../route'
const originalUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const originalServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
function cronRequest(): Request {
return new Request('http://localhost:3000/api/tax-deadlines/cron')
}
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-16T00:00:00.000Z'))
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://example.supabase.co'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
mocks.annual.mockResolvedValue({ usersProcessed: 0, totalCreated: 0 })
mocks.backfill.mockResolvedValue({
companiesScanned: 10,
companiesRepaired: 2,
totalCreated: 24,
})
})
afterEach(() => {
vi.useRealTimers()
process.env.NEXT_PUBLIC_SUPABASE_URL = originalUrl
process.env.SUPABASE_SERVICE_ROLE_KEY = originalServiceKey
})
describe('GET /api/tax-deadlines/cron', () => {
it('repairs companies with missing deadlines on the daily run', async () => {
const response = await GET(cronRequest())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
success: true,
isAnnualRun: false,
companiesScanned: 10,
companiesRepaired: 2,
totalCreated: 24,
})
expect(mocks.backfill).toHaveBeenCalledOnce()
expect(mocks.annual).not.toHaveBeenCalled()
})
it('extends every company horizon before running recovery on January 2', async () => {
vi.setSystemTime(new Date('2027-01-02T00:00:00.000Z'))
mocks.annual.mockResolvedValue({ usersProcessed: 8, totalCreated: 80 })
const response = await GET(cronRequest())
const body = await response.json()
expect(body).toMatchObject({
isAnnualRun: true,
usersProcessed: 8,
totalCreated: 104,
})
expect(mocks.annual).toHaveBeenCalledOnce()
expect(mocks.backfill).toHaveBeenCalledOnce()
})
it('returns 401 without creating a database client when cron auth fails', async () => {
vi.mocked(verifyCronSecret).mockReturnValueOnce(
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
)
const response = await GET(cronRequest())
expect(response.status).toBe(401)
expect(mocks.createClient).not.toHaveBeenCalled()
expect(mocks.backfill).not.toHaveBeenCalled()
})
})
+40 -8
View File
@@ -1,12 +1,14 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { generateNewYearDeadlines } from '@/lib/tax/deadline-generator'
import {
backfillMissingTaxDeadlines,
generateNewYearDeadlines,
} from '@/lib/tax/deadline-generator'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* GET /api/tax-deadlines/cron: annual on January 2nd 00:00.
* Generates the next year's tax deadlines for every company.
* GET /api/tax-deadlines/cron: daily recovery plus annual horizon extension.
*/
export const GET = withCronContext('cron.tax_deadlines', async (_request, ctx) => {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
@@ -20,16 +22,46 @@ export const GET = withCronContext('cron.tax_deadlines', async (_request, ctx) =
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const result = await generateNewYearDeadlines(supabase)
const now = new Date()
const isAnnualRun = now.getUTCMonth() === 0 && now.getUTCDate() === 2
// Each step logs its own failure before rethrowing so a partial failure
// names the failed step in the audit trail instead of surfacing as an
// anonymous cron error.
let annual = { usersProcessed: 0, totalCreated: 0 }
if (isAnnualRun) {
try {
annual = await generateNewYearDeadlines(supabase)
} catch (err) {
ctx.log.error('new-year deadline generation failed', err as Error)
throw err
}
}
let recovery
try {
recovery = await backfillMissingTaxDeadlines(supabase)
} catch (err) {
ctx.log.error('tax deadline backfill failed', err as Error)
throw err
}
const totalCreated = annual.totalCreated + recovery.totalCreated
ctx.log.info('tax deadlines cron summary', {
usersProcessed: result.usersProcessed,
totalCreated: result.totalCreated,
isAnnualRun,
usersProcessed: annual.usersProcessed,
companiesScanned: recovery.companiesScanned,
companiesRepaired: recovery.companiesRepaired,
totalCreated,
})
return NextResponse.json({
success: true,
usersProcessed: result.usersProcessed,
totalCreated: result.totalCreated,
isAnnualRun,
usersProcessed: annual.usersProcessed,
companiesScanned: recovery.companiesScanned,
companiesRepaired: recovery.companiesRepaired,
totalCreated,
})
})
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const mocks = vi.hoisted(() => ({
regenerate: vi.fn(),
}))
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 requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/tax/deadline-generator', () => ({
DEADLINE_SETTINGS_SELECT: 'company_id, entity_type',
regenerateTaxDeadlinesForUser: mocks.regenerate,
toDeadlineSettings: vi.fn((settings: Record<string, unknown>) => settings),
}))
import { POST } from '../route'
function request(): Request {
return createMockRequest('/api/tax-deadlines/generate', { method: 'POST' })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
mocks.regenerate.mockResolvedValue({ created: 12, deleted: 0 })
})
describe('POST /api/tax-deadlines/generate', () => {
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(request(), { params: Promise.resolve({}) })
expect(response.status).toBe(401)
expect(mocks.regenerate).not.toHaveBeenCalled()
})
it('returns 403 without write permission', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const response = await POST(request(), { params: Promise.resolve({}) })
expect(response.status).toBe(403)
expect(mocks.regenerate).not.toHaveBeenCalled()
})
it('returns 404 when company settings are missing', async () => {
enqueue({ data: null, error: { code: 'PGRST116', message: 'No rows returned' } })
const response = await POST(request(), { params: Promise.resolve({}) })
expect(response.status).toBe(404)
})
it('regenerates the current company deadlines', async () => {
enqueue({ data: { company_id: 'company-1', entity_type: 'aktiebolag' } })
const response = await POST(request(), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
success: boolean
created: number
deleted: number
}>(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true, created: 12, deleted: 0 })
expect(mocks.regenerate).toHaveBeenCalledWith(
supabase,
'company-1',
expect.objectContaining({ entity_type: 'aktiebolag' }),
)
})
it('returns 500 when generation fails', async () => {
enqueue({ data: { company_id: 'company-1', entity_type: 'aktiebolag' } })
mocks.regenerate.mockRejectedValueOnce(new Error('insert failed'))
const response = await POST(request(), { params: Promise.resolve({}) })
expect(response.status).toBe(500)
})
})
+11 -10
View File
@@ -1,6 +1,10 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
import {
DEADLINE_SETTINGS_SELECT,
regenerateTaxDeadlinesForUser,
toDeadlineSettings,
} from '@/lib/tax/deadline-generator'
/**
* POST /api/tax-deadlines/generate
@@ -14,7 +18,7 @@ export const POST = withRouteContext(
// Fetch company settings
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
.select(DEADLINE_SETTINGS_SELECT)
.eq('company_id', companyId)
.single()
@@ -26,14 +30,11 @@ export const POST = withRouteContext(
}
try {
const result = await regenerateTaxDeadlinesForUser(supabase, companyId, {
entity_type: settings.entity_type,
moms_period: settings.moms_period,
f_skatt: settings.f_skatt,
vat_registered: settings.vat_registered,
pays_salaries: settings.pays_salaries ?? false,
fiscal_year_start_month: settings.fiscal_year_start_month,
})
const result = await regenerateTaxDeadlinesForUser(
supabase,
companyId,
toDeadlineSettings(settings),
)
return NextResponse.json({
success: true,
+5 -3
View File
@@ -40,9 +40,10 @@ interface AccountComboboxProps {
// internal one. Lets a parent imperatively focus the field (e.g. auto-advance
// to the next konteringsrad's account on Enter: see JournalEntryForm.focusAccount).
inputRef?: React.RefCallback<HTMLInputElement>
disabled?: boolean
}
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef }: AccountComboboxProps) {
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef, disabled = false }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
@@ -247,11 +248,12 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
placeholder="Sök konto…"
className={`font-mono ${className ?? ''}`.trim()}
autoComplete="off"
disabled={disabled}
/>
{/* Dropdown */}
{isOpen && flatList.length > 0 && (
{isOpen && !disabled && flatList.length > 0 && (
<div
ref={listRef}
className="absolute z-50 top-full left-0 mt-1 min-w-[24rem] w-[max(100%,34rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
@@ -296,7 +298,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
)}
{/* Empty state */}
{isOpen && search.trim() && flatList.length === 0 && (
{isOpen && !disabled && search.trim() && flatList.length === 0 && (
<div className="absolute z-50 top-full left-0 mt-1 min-w-[24rem] w-[max(100%,34rem)] rounded-md border border-input bg-card shadow-md p-3">
<p className="text-sm text-muted-foreground">
Hittade inget konto som matchar.
@@ -1,7 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
@@ -21,9 +22,14 @@ import {
import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Plus, Trash2 } from 'lucide-react'
import { Loader2, Plus, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import {
changeCorrectionLineAccount,
getSelectableCorrectionCatalog,
} from '@/lib/bookkeeping/correction-line-account'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
interface CorrectionLine {
@@ -43,11 +49,27 @@ interface Props {
export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCorrected }: Props) {
const { toast } = useToast()
const router = useRouter()
const t = useTranslations('journal_detail')
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [lines, setLines] = useState<CorrectionLine[]>([])
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const activeAccounts = useMemo(
() => accounts.filter((account) => account.is_active),
[accounts],
)
const selectableCatalog = useMemo(
() => getSelectableCorrectionCatalog(accounts, catalog),
[accounts, catalog],
)
const accountNameSources = useMemo(
() => [...accounts, ...catalog],
[accounts, catalog],
)
const originalLines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
@@ -66,17 +88,26 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
// Pre-fill the verifikationstext with the same auto text the server
// would generate; only a user edit is sent along (see handleSubmit).
setDescription(autoCorrectionDescription(entry.description))
fetchAccounts()
void fetchAccounts()
}
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchAccounts() {
setAccountsStatus('loading')
try {
const res = await fetch('/api/bookkeeping/accounts')
const [res, basCatalog] = await Promise.all([
fetch('/api/bookkeeping/accounts?active=false'),
loadBasCatalog(),
])
if (!res.ok) throw new Error(`accounts ${res.status}`)
const { data } = await res.json()
setAccounts(data || [])
setCatalog(basCatalog)
setAccountsStatus('ready')
} catch {
// Accounts will be empty: user can still type account numbers manually
setAccounts([])
setCatalog([])
setAccountsStatus('error')
}
}
@@ -102,6 +133,14 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
)
}
const updateLineAccount = (index: number, accountNumber: string) => {
setLines((prev) => prev.map((line, lineIndex) => (
lineIndex === index
? changeCorrectionLineAccount(line, accountNumber, accountNameSources)
: line
)))
}
const addLine = () => {
setLines((prev) => [...prev, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }])
}
@@ -236,14 +275,30 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
</p>
</div>
{accountsStatus !== 'ready' && (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground">
<span className="flex items-center gap-2">
{accountsStatus === 'loading' && <Loader2 className="h-4 w-4 animate-spin" />}
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
</span>
{accountsStatus === 'error' && (
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
{t('accounts_retry')}
</Button>
)}
</div>
)}
<div className="space-y-2">
{lines.map((line, index) => (
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[1fr_1fr_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
<div className="grid grid-cols-[1fr_auto] sm:contents gap-2">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(v) => updateLine(index, 'account_number', v)}
accounts={activeAccounts}
catalog={selectableCatalog}
onChange={(v) => updateLineAccount(index, v)}
disabled={accountsStatus !== 'ready'}
/>
<Button
variant="ghost"
+140 -21
View File
@@ -17,6 +17,12 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
const [vatRegistered, setVatRegistered] = useState(settings.vat_registered ?? false)
const [fSkatt, setFSkatt] = useState(settings.f_skatt ?? true)
const [paysSalaries, setPaysSalaries] = useState(settings.pays_salaries ?? false)
const [momsPeriod, setMomsPeriod] = useState(settings.moms_period || '')
const [vatTaxableBaseOver40m, setVatTaxableBaseOver40m] = useState(
settings.vat_taxable_base_over_40m ?? false,
)
const [hasEuTrade, setHasEuTrade] = useState(settings.vat_has_eu_trade ?? false)
const [psEnabled, setPsEnabled] = useState(settings.periodisk_sammanstallning_enabled ?? false)
const isEnskildFirma = settings.entity_type === 'enskild_firma'
@@ -69,7 +75,11 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
<Checkbox
id="vat_registered"
checked={vatRegistered}
onCheckedChange={(v) => setVatRegistered(v === true)}
onCheckedChange={(value) => {
const checked = value === true
setVatRegistered(checked)
if (checked && vatTaxableBaseOver40m) setMomsPeriod('monthly')
}}
/>
<input type="hidden" name="vat_registered" value={vatRegistered ? 'true' : 'false'} />
<div className="space-y-1">
@@ -99,15 +109,20 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
<Label>{t('moms_period_label')}</Label>
<Select
name="moms_period"
defaultValue={settings.moms_period || undefined}
value={momsPeriod || undefined}
onValueChange={setMomsPeriod}
>
<SelectTrigger>
<SelectValue placeholder={t('select_period_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
<SelectItem value="yearly">{t('period_yearly')}</SelectItem>
<SelectItem value="quarterly" disabled={vatTaxableBaseOver40m}>
{t('period_quarterly')}
</SelectItem>
<SelectItem value="yearly" disabled={vatTaxableBaseOver40m}>
{t('period_yearly')}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
@@ -115,26 +130,130 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
</p>
</div>
<div className="max-w-xs space-y-2">
<Label>{t('periodisk_label')}</Label>
<Select
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
>
<SelectTrigger>
<SelectValue placeholder={t('select_period_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('periodisk_help')}
</p>
<div className="flex items-start space-x-3">
<Checkbox
id="vat_taxable_base_over_40m"
checked={vatTaxableBaseOver40m}
onCheckedChange={(value) => {
const checked = value === true
setVatTaxableBaseOver40m(checked)
if (checked) setMomsPeriod('monthly')
}}
/>
<input
type="hidden"
name="vat_taxable_base_over_40m"
value={vatTaxableBaseOver40m ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="vat_taxable_base_over_40m" className="cursor-pointer">
{t('vat_taxable_base_over_40m_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('vat_taxable_base_over_40m_help')}
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="vat_has_eu_trade"
checked={hasEuTrade}
onCheckedChange={(value) => {
const checked = value === true
setHasEuTrade(checked)
if (!checked) setPsEnabled(false)
}}
/>
<input type="hidden" name="vat_has_eu_trade" value={hasEuTrade ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="vat_has_eu_trade" className="cursor-pointer">
{t('vat_has_eu_trade_label')}
</Label>
<p className="text-xs text-muted-foreground">
{t('vat_has_eu_trade_help')}
</p>
</div>
</div>
{momsPeriod === 'yearly' && !hasEuTrade && !isEnskildFirma && (
<div className="max-w-xs space-y-2">
<Label>{t('vat_filing_method_label')}</Label>
<Select
name="vat_filing_method"
defaultValue={settings.vat_filing_method || 'electronic'}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="electronic">{t('filing_method_electronic')}</SelectItem>
<SelectItem value="paper">{t('filing_method_paper')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('vat_filing_method_help')}</p>
</div>
)}
{hasEuTrade && (
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="periodisk_sammanstallning_enabled"
checked={psEnabled}
onCheckedChange={(value) => setPsEnabled(value === true)}
/>
<input
type="hidden"
name="periodisk_sammanstallning_enabled"
value={psEnabled ? 'true' : 'false'}
/>
<div className="space-y-1">
<Label htmlFor="periodisk_sammanstallning_enabled" className="cursor-pointer">
{t('periodisk_enabled_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('periodisk_enabled_help')}</p>
</div>
</div>
{psEnabled && (
<div className="grid max-w-2xl grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('periodisk_label')}</Label>
<Select
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('periodisk_help')}</p>
</div>
<div className="space-y-2">
<Label>{t('periodisk_filing_method_label')}</Label>
<Select
name="periodisk_sammanstallning_filing_method"
defaultValue={settings.periodisk_sammanstallning_filing_method || 'electronic'}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="electronic">{t('filing_method_electronic')}</SelectItem>
<SelectItem value="paper">{t('filing_method_paper')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('periodisk_filing_method_help')}
</p>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</section>
@@ -47,19 +47,39 @@ export function TaxSettingsContent() {
function handleSave(formData: FormData) {
const vatRegistered = formData.get('vat_registered') === 'true'
const paysSalaries = formData.get('pays_salaries') === 'true'
const updates: Record<string, unknown> = {
f_skatt: formData.get('f_skatt') === 'true',
vat_registered: vatRegistered,
vat_number: vatRegistered ? ((formData.get('vat_number') as string) || null) : null,
moms_period: vatRegistered ? ((formData.get('moms_period') as string) || null) : null,
vat_taxable_base_over_40m:
vatRegistered && formData.get('vat_taxable_base_over_40m') === 'true',
vat_has_eu_trade: vatRegistered && formData.get('vat_has_eu_trade') === 'true',
// The filing-method and PS selects are conditionally rendered: when a
// control is unmounted its FormData key is absent, and defaulting would
// silently overwrite the saved preference. Fall back to the stored
// value first, then the default.
vat_filing_method:
(formData.get('vat_filing_method') as string) ||
settings?.vat_filing_method ||
'electronic',
periodisk_sammanstallning_enabled:
vatRegistered && formData.get('periodisk_sammanstallning_enabled') === 'true',
periodisk_sammanstallning_period:
(formData.get('periodisk_sammanstallning_period') as string) || 'monthly',
(formData.get('periodisk_sammanstallning_period') as string) ||
settings?.periodisk_sammanstallning_period ||
'monthly',
periodisk_sammanstallning_filing_method:
(formData.get('periodisk_sammanstallning_filing_method') as string) ||
settings?.periodisk_sammanstallning_filing_method ||
'electronic',
tax_contact_name: (formData.get('tax_contact_name') as string) || null,
tax_contact_phone: (formData.get('tax_contact_phone') as string) || null,
tax_contact_email: (formData.get('tax_contact_email') as string) || null,
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
pays_salaries: formData.get('pays_salaries') === 'true',
pays_salaries: paysSalaries,
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
}
return {
+14
View File
@@ -1358,6 +1358,20 @@ describe('UpdateSettingsSchema', () => {
expect(result.success).toBe(true)
})
it('validates tax deadline filing-profile fields', () => {
expect(UpdateSettingsSchema.safeParse({
vat_taxable_base_over_40m: true,
vat_has_eu_trade: true,
vat_filing_method: 'electronic',
periodisk_sammanstallning_enabled: true,
periodisk_sammanstallning_filing_method: 'paper',
}).success).toBe(true)
expect(UpdateSettingsSchema.safeParse({ vat_filing_method: 'fax' }).success).toBe(false)
expect(UpdateSettingsSchema.safeParse({
periodisk_sammanstallning_filing_method: 'fax',
}).success).toBe(false)
})
it('rejects invalid email', () => {
const result = UpdateSettingsSchema.safeParse({ email: 'not-email' })
expect(result.success).toBe(false)
+7
View File
@@ -244,6 +244,7 @@ export const TaxDeadlineTypeSchema = z.enum([
'moms_yearly',
'f_skatt',
'arbetsgivardeklaration',
'skatteinbetalning',
'inkomstdeklaration_ef',
'inkomstdeklaration_ab',
'arsredovisning',
@@ -256,6 +257,7 @@ export const DeadlineSourceSchema = z.enum(['system', 'user'])
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
export const PsPeriodTypeSchema = z.enum(['monthly', 'quarterly'])
export const TaxFilingMethodSchema = z.enum(['electronic', 'paper'])
export const DocumentUploadSourceSchema = z.enum([
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
@@ -1442,7 +1444,12 @@ export const UpdateSettingsSchema = z.object({
.nullable()
.optional(),
moms_period: MomsPeriodSchema.nullable().optional(),
vat_taxable_base_over_40m: z.boolean().optional(),
vat_has_eu_trade: z.boolean().optional(),
vat_filing_method: TaxFilingMethodSchema.optional(),
periodisk_sammanstallning_enabled: z.boolean().optional(),
periodisk_sammanstallning_period: PsPeriodTypeSchema.optional(),
periodisk_sammanstallning_filing_method: TaxFilingMethodSchema.optional(),
tax_contact_name: z.string().max(200).nullable().optional(),
tax_contact_phone: z.string().max(40).nullable().optional(),
tax_contact_email: z.string().email().nullable().optional().or(z.literal('')),
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import {
changeCorrectionLineAccount,
getSelectableCorrectionCatalog,
} from '../correction-line-account'
const accounts = [
{
account_number: '2393',
account_name: 'Lån från närstående personer, långfristig del',
},
{
account_number: '2893',
account_name: 'Skulder till närstående personer, kortfristig del',
},
]
describe('changeCorrectionLineAccount', () => {
it('replaces a stale account-derived description when the account changes', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån från närstående personer, långfristig del',
debit_amount: '',
credit_amount: '1000',
},
'2893',
accounts,
)
expect(result).toEqual({
account_number: '2893',
line_description: 'Skulder till närstående personer, kortfristig del',
debit_amount: '',
credit_amount: '1000',
})
})
it('preserves a user-authored line description', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån enligt avtal 2026-07-01',
},
'2893',
accounts,
)
expect(result.line_description).toBe('Lån enligt avtal 2026-07-01')
})
it('uses the BAS catalogue when the new account is not active', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån från närstående personer, långfristig del',
},
'2893',
accounts,
)
expect(result.line_description).toBe('Skulder till närstående personer, kortfristig del')
})
it('recognizes an account-derived description from a deactivated custom account', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2997',
line_description: 'Avräkning projektägare',
},
'2893',
[
{ account_number: '2997', account_name: 'Avräkning projektägare' },
accounts[1],
],
)
expect(result.line_description).toBe('Skulder till närstående personer, kortfristig del')
})
it('prefers the company account name over a duplicate BAS catalogue name', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån från närstående personer, långfristig del',
},
'2893',
[
accounts[0],
{ account_number: '2893', account_name: 'Avräkning ägare' },
accounts[1],
],
)
expect(result.line_description).toBe('Avräkning ägare')
})
it('fills a blank description from the selected account', () => {
const result = changeCorrectionLineAccount(
{ account_number: '2393', line_description: '' },
'2893',
accounts,
)
expect(result.line_description).toBe('Skulder till närstående personer, kortfristig del')
})
it('clears a derived description when the new account has no known name', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån från närstående personer, långfristig del',
},
'9999',
accounts,
)
expect(result.line_description).toBe('')
})
})
describe('getSelectableCorrectionCatalog', () => {
it('excludes deliberately deactivated company accounts from catalogue choices', () => {
const result = getSelectableCorrectionCatalog(
[
{ account_number: '2393', account_name: 'Långfristigt lån', is_active: true },
{ account_number: '2893', account_name: 'Kortfristigt lån', is_active: false },
],
accounts,
)
expect(result).toEqual([accounts[0]])
})
})
@@ -0,0 +1,67 @@
import { foldText } from './account-search'
interface NamedAccount {
account_number: string
account_name: string
}
interface CompanyAccount extends NamedAccount {
is_active: boolean
}
interface CorrectionLineAccountFields {
account_number: string
line_description: string
}
function findAccountName(accountNumber: string, accounts: NamedAccount[]): string | undefined {
return accounts.find((account) => account.account_number === accountNumber)?.account_name
}
function namesMatch(left: string, right: string): boolean {
return foldText(left.trim()) === foldText(right.trim())
}
/**
* Keep deliberately deactivated company accounts out of catalogue-backed
* picker results. The correction service will seed missing BAS accounts, but
* it intentionally refuses to reactivate accounts the company disabled.
*/
export function getSelectableCorrectionCatalog<T extends NamedAccount>(
companyAccounts: CompanyAccount[],
catalog: T[],
): T[] {
const inactiveNumbers = new Set(
companyAccounts
.filter((account) => !account.is_active)
.map((account) => account.account_number),
)
return catalog.filter((account) => !inactiveNumbers.has(account.account_number))
}
/**
* Change the account on a correction line without carrying a stale account
* name into the posted replacement entry. Descriptions that do not match the
* previous account name are user-authored voucher text and stay untouched.
*/
export function changeCorrectionLineAccount<T extends CorrectionLineAccountFields>(
line: T,
nextAccountNumber: string,
accounts: NamedAccount[],
): T {
const currentDescription = line.line_description.trim()
const previousNames = accounts
.filter((account) => account.account_number === line.account_number)
.map((account) => account.account_name)
const descriptionIsAccountName = currentDescription.length === 0
|| previousNames.some((name) => namesMatch(currentDescription, name))
return {
...line,
account_number: nextAccountNumber,
line_description: descriptionIsAccountName
? findAccountName(nextAccountNumber, accounts) ?? ''
: line.line_description,
}
}
+1
View File
@@ -178,6 +178,7 @@ function getSwedishTaxTypeLabel(type: string): string {
moms_yearly: 'Momsdeklaration (år)',
f_skatt: 'F-skatt',
arbetsgivardeklaration: 'Arbetsgivardeklaration',
skatteinbetalning: 'Skatteinbetalning (storföretag)',
inkomstdeklaration_ef: 'Inkomstdeklaration EF',
inkomstdeklaration_ab: 'Inkomstdeklaration AB',
arsredovisning: 'Årsredovisning',
+44
View File
@@ -8,6 +8,15 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
const deadlineMocks = vi.hoisted(() => ({
regenerate: vi.fn().mockResolvedValue({ created: 1, deleted: 0 }),
}))
vi.mock('@/lib/tax/deadline-generator', () => ({
regenerateTaxDeadlinesForUser: deadlineMocks.regenerate,
toDeadlineSettings: vi.fn((settings: Record<string, unknown>) => settings),
}))
// Keep the real CompanyContextError so instanceof checks in switchCompany
// see the same class the tests throw.
vi.mock('@/lib/company/context', async (importOriginal) => ({
@@ -83,6 +92,7 @@ function buildSupabase(opts: {
beforeEach(() => {
vi.clearAllMocks()
deadlineMocks.regenerate.mockResolvedValue({ created: 1, deleted: 0 })
})
describe('switchCompany', () => {
@@ -256,6 +266,40 @@ describe('createCompanyFromOnboarding: TIC snapshot persistence', () => {
const payload = snapshotUpdate!.args[0] as Record<string, unknown>
expect(payload.tic_snapshot).toEqual(ticLookup)
expect(payload.tic_snapshot_fetched_at).toBeDefined()
expect(deadlineMocks.regenerate).toHaveBeenCalledWith(
supabase,
'new-company-id',
expect.objectContaining({ entity_type: 'aktiebolag' }),
)
})
it('rolls back company creation when automatic deadlines cannot be created', async () => {
const { supabase, calls } = buildSupabase({
user: { id: 'user-1' },
rpcResults: {
create_company_with_owner: { data: 'new-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
deadlineMocks.regenerate.mockRejectedValueOnce(new Error('deadline insert failed'))
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Acme AB',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
expect(result).toEqual({ error: 'Kunde inte skapa skattedeadlines. Försök igen.' })
expect(calls).toContainEqual(expect.objectContaining({ table: 'companies', method: 'delete' }))
expect(mockSetActiveCompany).not.toHaveBeenCalled()
})
it('skips the snapshot update when no ticLookup is supplied (manual signup)', async () => {
+40 -7
View File
@@ -5,6 +5,11 @@ import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
import { revalidatePath } from 'next/cache'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { normalizeVatNumber, isValidSwedishVatNumber, deriveSwedishVatNumber } from '@/lib/vat/vat-number'
import {
regenerateTaxDeadlinesForUser,
toDeadlineSettings,
} from '@/lib/tax/deadline-generator'
import type { CompanySettingsForDeadlines } from '@/lib/tax/deadline-config'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
/**
@@ -122,14 +127,28 @@ async function createCompanyFromOnboardingImpl(params: {
return { error: 'Kunde inte skapa företag. Försök igen.' }
}
// Helper: roll back the company if a subsequent step fails. Deletes in FK order.
// Helper: roll back the company if a subsequent step fails. Deletes in FK
// order. Each delete is error-checked so a failed cleanup leaves a trace
// instead of silently stranding partial company data behind a generic
// "try again" message.
const rollback = async (reason: string, err: unknown) => {
console.error(`[createCompanyFromOnboarding] rolling back ${newCompanyId}: ${reason}`, err)
await supabase.from('company_settings').delete().eq('company_id', newCompanyId)
await supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)
await supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)
await supabase.from('company_members').delete().eq('company_id', newCompanyId)
await supabase.from('companies').delete().eq('id', newCompanyId)
const deletions: Array<[table: string, run: () => PromiseLike<{ error: unknown }>]> = [
['company_settings', () => supabase.from('company_settings').delete().eq('company_id', newCompanyId)],
['fiscal_periods', () => supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)],
['chart_of_accounts', () => supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)],
['company_members', () => supabase.from('company_members').delete().eq('company_id', newCompanyId)],
['companies', () => supabase.from('companies').delete().eq('id', newCompanyId)],
]
for (const [table, run] of deletions) {
const { error: deleteError } = await run()
if (deleteError) {
console.error(
`[createCompanyFromOnboarding] rollback delete failed for ${table} (company ${newCompanyId})`,
deleteError,
)
}
}
}
// Mirror the normalized org_number onto the companies row so future
@@ -237,7 +256,21 @@ async function createCompanyFromOnboardingImpl(params: {
return { error: 'Kunde inte skapa räkenskapsår. Försök igen.' }
}
// 5. Set as active company
// 5. Create the automatic tax deadlines while the onboarding data is still
// available. Treat this as part of company creation so a new company never
// starts in the broken state where valid settings exist without deadlines.
try {
await regenerateTaxDeadlinesForUser(
supabase,
newCompanyId,
toDeadlineSettings(settingsToSave as Partial<CompanySettingsForDeadlines>),
)
} catch (deadlineError) {
await rollback('tax deadline generation failed', deadlineError)
return { error: 'Kunde inte skapa skattedeadlines. Försök igen.' }
}
// 6. Set as active company
try {
await setActiveCompany(supabase, user.id, newCompanyId)
} catch (err) {
+141
View File
@@ -14,10 +14,151 @@ function makeSettings(overrides: Partial<CompanySettingsForDeadlines> = {}): Com
vat_registered: true,
pays_salaries: false,
fiscal_year_start_month: 1,
vat_taxable_base_over_40m: false,
vat_has_eu_trade: false,
vat_filing_method: 'electronic',
periodisk_sammanstallning_enabled: false,
periodisk_sammanstallning_period: 'monthly',
periodisk_sammanstallning_filing_method: 'electronic',
...overrides,
}
}
describe('VAT filing deadlines', () => {
it('uses the second following month for monthly filers at or below SEK 40 million', () => {
const dates = getConfig('moms_monthly').generateDates(2026, makeSettings({ moms_period: 'monthly' }))
expect(dates[0]).toMatchObject({ day: 12, month: 2, year: 2026, period: '2026-01' })
expect(dates[10]).toMatchObject({ day: 17, month: 0, year: 2027, period: '2026-11' })
})
it('uses the following month for monthly filers above SEK 40 million', () => {
const dates = getConfig('moms_monthly').generateDates(2026, makeSettings({
moms_period: 'monthly',
vat_taxable_base_over_40m: true,
}))
expect(dates[0]).toMatchObject({ day: 26, month: 1, year: 2026, period: '2026-01' })
// Raw date stays the 26th even in December; the banking-day adjustment
// in the generator moves annandag jul to Skatteverket's published 27th.
expect(dates[10]).toMatchObject({ day: 26, month: 11, year: 2026, period: '2026-11' })
})
it('uses May, August, November and February for quarterly VAT', () => {
const dates = getConfig('moms_quarterly').generateDates(2026, makeSettings())
expect(dates.map(({ day, month, year }) => ({ day, month, year }))).toEqual([
{ day: 12, month: 4, year: 2026 },
{ day: 17, month: 7, year: 2026 },
{ day: 12, month: 10, year: 2026 },
{ day: 12, month: 1, year: 2027 },
])
})
it('uses the entity, EU-trade and filing-method rules for yearly VAT', () => {
const config = getConfig('moms_yearly')
expect(config.generateDates(2027, makeSettings({
entity_type: 'enskild_firma',
moms_period: 'yearly',
}))[0]).toMatchObject({ day: 12, month: 4, year: 2027, period: '2026' })
expect(config.generateDates(2027, makeSettings({
entity_type: 'enskild_firma',
moms_period: 'yearly',
vat_has_eu_trade: true,
}))[0]).toMatchObject({ day: 26, month: 1, year: 2027, period: '2026' })
expect(config.generateDates(2027, makeSettings({
moms_period: 'yearly',
vat_filing_method: 'paper',
}))[0]).toMatchObject({ day: 12, month: 6, year: 2027, period: '2026' })
})
})
describe('monthly tax and employer deadlines', () => {
it('uses the 12th for F-tax except January and August', () => {
const dates = getConfig('f_skatt').generateDates(2026, makeSettings())
expect(dates[0].day).toBe(17)
expect(dates[1].day).toBe(12)
expect(dates[7].day).toBe(17)
})
it('uses the 26th for AGI when the VAT taxable base is above SEK 40 million', () => {
const dates = getConfig('arbetsgivardeklaration').generateDates(2026, makeSettings({
pays_salaries: true,
vat_taxable_base_over_40m: true,
}))
expect(dates.every((date) => date.day === 26)).toBe(true)
})
it('keeps the 12th for AGI when the employer does not report VAT', () => {
const dates = getConfig('arbetsgivardeklaration').generateDates(2026, makeSettings({
pays_salaries: true,
vat_registered: false,
vat_taxable_base_over_40m: true,
}))
expect(dates[1].day).toBe(12)
expect(dates[11].day).toBe(17) // December salaries are declared 17 January
})
})
describe('storföretag tax payment deadline', () => {
const config = getConfig('skatteinbetalning')
it('applies only to employers reporting VAT above SEK 40 million', () => {
expect(config.condition(makeSettings({ pays_salaries: true }))).toBe(false)
expect(config.condition(makeSettings({ vat_taxable_base_over_40m: true }))).toBe(false)
expect(config.condition(makeSettings({
pays_salaries: true,
vat_taxable_base_over_40m: true,
vat_registered: false,
}))).toBe(false)
expect(config.condition(makeSettings({
pays_salaries: true,
vat_taxable_base_over_40m: true,
}))).toBe(true)
})
it('is due the 12th of the following month, the 17th in January', () => {
const dates = config.generateDates(2026, makeSettings({
pays_salaries: true,
vat_taxable_base_over_40m: true,
}))
expect(dates).toHaveLength(12)
expect(dates[0]).toMatchObject({ day: 12, month: 1, year: 2026, period: '2026-01' })
expect(dates[6]).toMatchObject({ day: 12, month: 7, year: 2026, period: '2026-07' })
expect(dates[11]).toMatchObject({ day: 17, month: 0, year: 2027, period: '2026-12' })
})
})
describe('periodic EU sales list deadlines', () => {
const config = getConfig('periodisk_sammanstallning')
it('is only applicable when explicitly enabled', () => {
expect(config.condition(makeSettings())).toBe(false)
expect(config.condition(makeSettings({ periodisk_sammanstallning_enabled: true }))).toBe(true)
})
it('uses the 25th monthly for electronic filing', () => {
const dates = config.generateDates(2026, makeSettings({
periodisk_sammanstallning_enabled: true,
}))
expect(dates).toHaveLength(12)
expect(dates[0]).toMatchObject({ day: 25, month: 1, year: 2026, period: '2026-01' })
})
it('uses the 20th quarterly for paper filing', () => {
const dates = config.generateDates(2026, makeSettings({
periodisk_sammanstallning_enabled: true,
periodisk_sammanstallning_period: 'quarterly',
periodisk_sammanstallning_filing_method: 'paper',
}))
expect(dates).toHaveLength(4)
expect(dates[0]).toMatchObject({ day: 20, month: 3, year: 2026, period: '2026-Q1' })
})
})
describe('inkomstdeklaration_ab: digital filing deadlines', () => {
const config = getConfig('inkomstdeklaration_ab')
+147 -3
View File
@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTaxDeadlinesForUser, shouldRegenerateTaxDeadlines } from '../deadline-generator'
import {
findSettingsMissingUpcomingDeadlines,
generateTaxDeadlinesForUser,
getExpectedUpcomingDeadlineKeys,
shouldRegenerateTaxDeadlines,
} from '../deadline-generator'
import type { CompanySettingsForDeadlines } from '../deadline-config'
const SETTINGS: CompanySettingsForDeadlines = {
@@ -10,6 +15,12 @@ const SETTINGS: CompanySettingsForDeadlines = {
vat_registered: true,
pays_salaries: true,
fiscal_year_start_month: 1,
vat_taxable_base_over_40m: false,
vat_has_eu_trade: false,
vat_filing_method: 'electronic',
periodisk_sammanstallning_enabled: false,
periodisk_sammanstallning_period: 'monthly',
periodisk_sammanstallning_filing_method: 'electronic',
}
// Future year so generated dates are never skipped as "in the past".
@@ -20,12 +31,16 @@ const FUTURE_YEAR = new Date().getFullYear() + 1
* insert payload, so the tests can assert the insert-first/delete-after
* ordering that prevents regeneration failures from wiping deadlines.
*/
function makeRecordingSupabase(opts: { insertError?: { code: string; message: string } } = {}) {
function makeRecordingSupabase(opts: {
insertError?: { code: string; message: string }
completedRows?: Array<{ tax_deadline_type: string; tax_period: string }>
} = {}) {
const calls: string[] = []
let insertPayload: Array<Record<string, unknown>> | null = null
const from = vi.fn(() => {
const chain: Record<string, ReturnType<typeof vi.fn>> = {}
let isDelete = false
const self = () => chain
chain.insert = vi.fn((rows: Array<Record<string, unknown>>) => {
calls.push('insert')
@@ -40,6 +55,7 @@ function makeRecordingSupabase(opts: { insertError?: { code: string; message: st
})
chain.delete = vi.fn(() => {
calls.push('delete')
isDelete = true
return chain
})
chain.eq = vi.fn(self)
@@ -49,7 +65,16 @@ function makeRecordingSupabase(opts: { insertError?: { code: string; message: st
calls.push(`not(${String(args[2]).slice(0, 20)}…)`)
return chain
})
chain.select = vi.fn(async () => ({ data: [{ id: 'old-1' }, { id: 'old-2' }], error: null }))
chain.select = vi.fn(() => {
if (isDelete) {
return Promise.resolve({ data: [{ id: 'old-1' }, { id: 'old-2' }], error: null })
}
return chain
})
chain.then = vi.fn((resolve: (value: unknown) => unknown) => Promise.resolve({
data: opts.completedRows ?? [],
error: null,
}).then(resolve))
return chain
})
@@ -111,6 +136,125 @@ describe('generateTaxDeadlinesForUser', () => {
expect(calls).toContain('insert')
expect(calls).not.toContain('delete')
})
it('does not replace a completed future obligation with a new pending row', async () => {
const completedPeriod = `${FUTURE_YEAR}-01`
const { supabase, getInsertPayload } = makeRecordingSupabase({
completedRows: [{ tax_deadline_type: 'f_skatt', tax_period: completedPeriod }],
})
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
expect(getInsertPayload()).not.toContainEqual(expect.objectContaining({
tax_deadline_type: 'f_skatt',
tax_period: completedPeriod,
}))
})
})
describe('findSettingsMissingUpcomingDeadlines', () => {
const fromDate = new Date(2030, 0, 1)
const years = [2030]
function rowsFor(companyId: string, keys: Set<string>, isCompleted = false) {
return Array.from(keys, (key, index) => {
const [taxDeadlineType, taxPeriod, dueDate] = key.split(':')
return {
id: `${companyId}-${index}`,
company_id: companyId,
tax_deadline_type: taxDeadlineType,
tax_period: taxPeriod,
due_date: dueDate,
is_completed: isCompleted,
}
})
}
it('returns only companies missing at least one expected obligation', () => {
const settings = [
{ company_id: 'company-1', ...SETTINGS },
{ company_id: 'company-2', ...SETTINGS },
]
const completeRows = rowsFor(
'company-1',
getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate),
)
expect(findSettingsMissingUpcomingDeadlines(
settings,
completeRows,
years,
fromDate,
)).toEqual([settings[1]])
})
it('repairs a company that has F-tax deadlines but is missing VAT deadlines', () => {
const settings = [{ company_id: 'company-1', ...SETTINGS }]
const expectedKeys = getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate)
const fTaxRows = rowsFor(
'company-1',
new Set(Array.from(expectedKeys).filter((key) => key.startsWith('f_skatt:'))),
)
expect(findSettingsMissingUpcomingDeadlines(
settings,
fTaxRows,
years,
fromDate,
)).toEqual(settings)
})
it('repairs a company whose rows carry dates from a superseded schedule', () => {
const settings = [{ company_id: 'company-1', ...SETTINGS }]
const staleRows = rowsFor(
'company-1',
getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate),
).map((row) => ({ ...row, due_date: '2030-12-31' }))
expect(findSettingsMissingUpcomingDeadlines(
settings,
staleRows,
years,
fromDate,
)).toEqual(settings)
})
it('treats a completed obligation as satisfied even with a superseded due date', () => {
// A filed (is_completed) row keeps its old statutory date. The generator
// never replaces completed rows, so flagging it by date would make the
// repair loop re-run for this company every day without converging.
const settings = [{ company_id: 'company-1', ...SETTINGS }]
const completedStaleRows = rowsFor(
'company-1',
getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate),
true,
).map((row) => ({ ...row, due_date: '2029-01-15' }))
expect(findSettingsMissingUpcomingDeadlines(
settings,
completedStaleRows,
years,
fromDate,
)).toEqual([])
})
it('still repairs missing pending obligations when other obligations are completed', () => {
const settings = [{ company_id: 'company-1', ...SETTINGS }]
const expectedKeys = getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate)
// Only the F-tax obligations exist (completed); everything else is missing.
const completedFTaxRows = rowsFor(
'company-1',
new Set(Array.from(expectedKeys).filter((key) => key.startsWith('f_skatt:'))),
true,
)
expect(findSettingsMissingUpcomingDeadlines(
settings,
completedFTaxRows,
years,
fromDate,
)).toEqual(settings)
})
})
describe('shouldRegenerateTaxDeadlines', () => {
+224 -93
View File
@@ -3,7 +3,7 @@
* Based on Skatteverket's official deadline schedule
*/
import type { TaxDeadlineType, EntityType, MomsPeriod } from '@/types'
import type { TaxDeadlineType, EntityType, MomsPeriod, TaxFilingMethod } from '@/types'
// Condition function type for determining if a deadline applies
export type DeadlineCondition = (settings: CompanySettingsForDeadlines) => boolean
@@ -16,6 +16,12 @@ export interface CompanySettingsForDeadlines {
vat_registered: boolean
pays_salaries: boolean
fiscal_year_start_month: number // 1-12
vat_taxable_base_over_40m: boolean
vat_has_eu_trade: boolean
vat_filing_method: TaxFilingMethod
periodisk_sammanstallning_enabled: boolean
periodisk_sammanstallning_period: 'monthly' | 'quarterly'
periodisk_sammanstallning_filing_method: TaxFilingMethod
}
// Configuration for a single tax deadline type
@@ -40,6 +46,74 @@ export interface DeadlineInstance {
periodLabel: string // Human-readable, e.g., "Q1 2025", "januari 2025"
}
function getFiscalYearLabel(fiscalYearEndMonth: number, fiscalYearEndYear: number): string {
return fiscalYearEndMonth === 12
? `${fiscalYearEndYear}`
: `${fiscalYearEndYear - 1}/${fiscalYearEndYear}`
}
function getAnnualVatDeadline(
fiscalYearEndMonth: number,
fiscalYearEndYear: number,
settings: CompanySettingsForDeadlines,
): { 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
// trade it is due 26 February (26 kap. 33-33a §§ SFL, Skatteverket's
// published helårsmoms schedule).
if (settings.entity_type === 'enskild_firma') {
return settings.vat_has_eu_trade
? { day: 26, month: 1, year: fiscalYearEndYear + 1 }
: { day: 12, month: 4, year: fiscalYearEndYear + 1 }
}
if (settings.vat_has_eu_trade) {
const month = (fiscalYearEndMonth + 1) % 12
const year = fiscalYearEndYear + (fiscalYearEndMonth >= 11 ? 1 : 0)
// A 26 December due date lands on annandag jul; the banking-day
// adjustment moves it to Skatteverket's published 27th (or later).
return { day: 26, month, year }
}
const paper = settings.vat_filing_method === 'paper'
if (fiscalYearEndMonth <= 4) {
return { day: 12, month: paper ? 10 : 11, year: fiscalYearEndYear }
}
if (fiscalYearEndMonth <= 6) {
return paper
? { day: 27, month: 11, year: fiscalYearEndYear }
: { day: 17, month: 0, year: fiscalYearEndYear + 1 }
}
if (fiscalYearEndMonth <= 8) {
return { day: 12, month: paper ? 2 : 3, year: fiscalYearEndYear + 1 }
}
return { day: paper ? 12 : 17, month: paper ? 6 : 7, year: fiscalYearEndYear + 1 }
}
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,
})
}
return results
}
/**
* All tax deadline configurations
*/
@@ -52,90 +126,17 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
condition: (s) => s.vat_registered && s.moms_period === 'monthly',
priority: 'important',
linkedReportType: 'vat',
generateDates: (year) => {
generateDates: (year, settings) => {
const instances: DeadlineInstance[] = []
// Due on the 12th of the following month
for (let month = 0; month < 12; month++) {
// Deadline for month X is on 12th of month X+1
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
instances.push({
day: 12,
month: deadlineMonth,
year: deadlineYear,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Momsdeklaration (quarterly) - e-tjänst deadline (26:e)
{
type: 'moms_quarterly',
titleTemplate: 'Momsdeklaration {periodLabel}',
description: 'Momsdeklaration för kvartalsredovisare (e-tjänst)',
condition: (s) => s.vat_registered && s.moms_period === 'quarterly',
priority: 'important',
linkedReportType: 'vat',
generateDates: (year) => {
// Q1 (Jan-Mar) -> 26 april
// Q2 (Apr-Jun) -> 26 juli
// Q3 (Jul-Sep) -> 26 oktober
// Q4 (Oct-Dec) -> 26 januari next year
return [
{ day: 26, month: 3, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` }, // April
{ day: 26, month: 6, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` }, // July
{ day: 26, month: 9, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` }, // October
{ day: 26, month: 0, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` }, // January next year
]
},
},
// F-skatt (monthly)
{
type: 'f_skatt',
titleTemplate: 'F-skatt {periodLabel}',
description: 'Inbetalning av preliminär skatt',
condition: (s) => s.f_skatt,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
// Due on the 17th of each month
for (let month = 0; month < 12; month++) {
instances.push({
day: 17,
month,
year,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Arbetsgivardeklaration (monthly, any employer with employees: AB or EF)
// Per Skatteförfarandelagen: every employer paying salary must file AGI monthly.
// Deadline: 12th of following month (17th in Jan/Aug for turnover ≤40 MSEK per agi-filing.md)
{
type: 'arbetsgivardeklaration',
titleTemplate: 'Arbetsgivardeklaration {periodLabel}',
description: 'Arbetsgivardeklaration för arbetsgivare med anställda',
condition: (s) => s.pays_salaries,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
// Due on the 12th of the following month
// Exception: January (for Dec) and August (for Jul) = 17th for ≤40 MSEK turnover
for (let month = 0; month < 12; month++) {
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
// Jan (deadlineMonth=0) and Aug (deadlineMonth=7) get 17th
const day = (deadlineMonth === 0 || deadlineMonth === 7) ? 17 : 12
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,
@@ -148,22 +149,152 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
},
},
// Periodisk sammanställning (quarterly, EU sales)
// Momsdeklaration (quarterly)
{
type: 'moms_quarterly',
titleTemplate: 'Momsdeklaration {periodLabel}',
description: 'Momsdeklaration för kvartalsredovisare',
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}` },
]
},
},
// Momsdeklaration (yearly)
{
type: 'moms_yearly',
titleTemplate: 'Momsdeklaration {periodLabel}',
description: 'Momsdeklaration för årsredovisare',
condition: (s) => s.vat_registered && s.moms_period === 'yearly',
priority: 'important',
linkedReportType: 'vat',
generateDates: (year, settings) => generateAnnualVatDates(year, settings),
},
// F-skatt (monthly)
{
type: 'f_skatt',
titleTemplate: 'F-skatt {periodLabel}',
description: 'Inbetalning av preliminär skatt',
condition: (s) => s.f_skatt,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
for (let month = 0; month < 12; month++) {
instances.push({
day: month === 0 || month === 7 ? 17 : 12,
month,
year,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Arbetsgivardeklaration (monthly, any employer with employees: AB or EF)
// Per Skatteförfarandelagen: every employer paying salary must file AGI monthly.
// The filing day is keyed to the VAT taxable base, not a separate employer
// measure (SFL 26 kap.): above SEK 40M the whole skattedeklaration (AGI and
// VAT) is due the 26th of the following month; otherwise the 12th (17th in
// January and August). Employers without VAT reporting follow the same
// 12th/17th small-company schedule.
{
type: 'arbetsgivardeklaration',
titleTemplate: 'Arbetsgivardeklaration {periodLabel}',
description: 'Arbetsgivardeklaration för arbetsgivare med anställda',
condition: (s) => s.pays_salaries,
priority: 'important',
linkedReportType: null,
generateDates: (year, settings) => {
const storforetag = settings.vat_registered && settings.vat_taxable_base_over_40m
const instances: DeadlineInstance[] = []
for (let month = 0; month < 12; month++) {
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
const day = storforetag
? 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
},
},
// Skatteinbetalning (storföretag): companies above the SEK 40M VAT taxable
// base file the skattedeklaration on the 26th but must still have deducted
// tax and employer contributions paid into skattekontot by the 12th (17th
// in January). Without this row the 26th filing date hides a payment
// deadline two weeks earlier.
{
type: 'skatteinbetalning',
titleTemplate: 'Betala skatt och arbetsgivaravgifter {periodLabel}',
description: 'Inbetalning av avdragen skatt och arbetsgivaravgifter för företag med beskattningsunderlag över 40 miljoner kronor',
condition: (s) => s.pays_salaries && s.vat_registered && s.vat_taxable_base_over_40m,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
for (let month = 0; month < 12; month++) {
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
instances.push({
// Deliberately January-only: the 17 August exception applies to the
// small-company (below SEK 40M) schedule. Storföretag payment dates
// are the 12th every month except January (62 kap. 3 § SFL and
// Skatteverket's published storföretag calendar).
day: deadlineMonth === 0 ? 17 : 12,
month: deadlineMonth,
year: deadlineYear,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Periodisk sammanställning (EU sales)
{
type: 'periodisk_sammanstallning',
titleTemplate: 'Periodisk sammanställning {periodLabel}',
description: 'Periodisk sammanställning för EU-försäljning',
condition: (s) => s.vat_registered, // Simplified - in reality depends on EU sales
condition: (s) => s.vat_registered && s.periodisk_sammanstallning_enabled,
priority: 'normal',
linkedReportType: null,
generateDates: (year) => {
// Q1 -> 20 april, Q2 -> 20 juli, Q3 -> 20 oktober, Q4 -> 20 januari
return [
{ day: 20, month: 3, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` },
{ day: 20, month: 6, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` },
{ day: 20, month: 9, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` },
{ day: 20, month: 0, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` },
]
generateDates: (year, settings) => {
const day = settings.periodisk_sammanstallning_filing_method === 'paper' ? 20 : 25
if (settings.periodisk_sammanstallning_period === 'quarterly') {
return [
{ day, month: 3, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` },
{ day, month: 6, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` },
{ day, month: 9, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` },
{ day, month: 0, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` },
]
}
return Array.from({ length: 12 }, (_, month) => ({
day,
month: (month + 1) % 12,
year: month === 11 ? year + 1 : year,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
}))
},
},
+267 -37
View File
@@ -25,8 +25,17 @@ export const TAX_RELEVANT_FIELDS = [
'vat_registered',
'pays_salaries',
'fiscal_year_start_month',
'vat_taxable_base_over_40m',
'vat_has_eu_trade',
'vat_filing_method',
'periodisk_sammanstallning_enabled',
'periodisk_sammanstallning_period',
'periodisk_sammanstallning_filing_method',
] as const
export const DEADLINE_SETTINGS_SELECT =
'company_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month, vat_taxable_base_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, periodisk_sammanstallning_period, periodisk_sammanstallning_filing_method' as const
/**
* Check if any tax-relevant fields changed
*/
@@ -42,6 +51,34 @@ export function didTaxFieldsChange(
return false
}
export function hasTaxRelevantFields(body: Record<string, unknown>): boolean {
return TAX_RELEVANT_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(body, field))
}
export function toDeadlineSettings(
settings: Partial<CompanySettingsForDeadlines>,
): CompanySettingsForDeadlines {
if (settings.entity_type !== 'aktiebolag' && settings.entity_type !== 'enskild_firma') {
throw new Error('Company entity type is required to generate tax deadlines')
}
return {
entity_type: settings.entity_type,
moms_period: settings.moms_period ?? null,
f_skatt: settings.f_skatt ?? true,
vat_registered: settings.vat_registered ?? false,
pays_salaries: settings.pays_salaries ?? false,
fiscal_year_start_month: settings.fiscal_year_start_month ?? 1,
vat_taxable_base_over_40m: settings.vat_taxable_base_over_40m ?? false,
vat_has_eu_trade: settings.vat_has_eu_trade ?? false,
vat_filing_method: settings.vat_filing_method ?? 'electronic',
periodisk_sammanstallning_enabled: settings.periodisk_sammanstallning_enabled ?? false,
periodisk_sammanstallning_period: settings.periodisk_sammanstallning_period ?? 'monthly',
periodisk_sammanstallning_filing_method:
settings.periodisk_sammanstallning_filing_method ?? 'electronic',
}
}
/**
* Decide whether a settings save should (re)generate tax deadlines.
*
@@ -87,8 +124,37 @@ export async function generateTaxDeadlinesForUser(
// Get applicable deadline configs based on settings
const applicableConfigs = getApplicableDeadlineConfigs(settings)
const startDate = `${Math.min(...years)}-01-01`
const endDate = `${Math.max(...years)}-12-31`
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayIso = formatDateISO(today)
const endDate = `${Math.max(...years) + 1}-12-31`
// Completed deadlines represent real filing progress. Preserve them and do
// not create a second pending row for the same obligation. The window starts
// a year before the earliest generated year, NOT today: a completed row can
// carry a superseded due date that already passed while the current
// statutory date is still ahead, and filtering on today would resurrect a
// pending row for an obligation the user already filed.
const completedFloor = `${Math.min(...years) - 1}-01-01`
const { data: completedRows, error: completedRowsError } = await supabase
.from('deadlines')
.select('tax_deadline_type, tax_period')
.eq('company_id', companyId)
.eq('source', 'system')
.eq('is_completed', true)
.gte('due_date', completedFloor)
if (completedRowsError) {
log.error('Error fetching completed deadlines:', completedRowsError)
throw completedRowsError
}
const completedKeys = new Set(
(completedRows ?? []).map(
(row: { tax_deadline_type: string | null; tax_period: string | null }) =>
`${row.tax_deadline_type}:${row.tax_period}`,
),
)
// Generate new deadlines
const deadlines: Array<{
@@ -121,12 +187,15 @@ export async function generateTaxDeadlinesForUser(
const dueDate = formatDateISO(adjustedDate)
// Skip if the deadline is in the past
const today = new Date()
today.setHours(0, 0, 0, 0)
if (adjustedDate < today) {
continue
}
const deadlineKey = `${config.type}:${instance.period}`
if (completedKeys.has(deadlineKey)) {
continue
}
// Determine initial status based on days until deadline
const daysUntil = Math.ceil((adjustedDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const status: DeadlineStatus = daysUntil <= 14 ? 'action_needed' : 'upcoming'
@@ -157,15 +226,29 @@ export async function generateTaxDeadlinesForUser(
}
}
const uniqueDeadlines = Array.from(
new Map(
deadlines.map((deadline) => [
`${deadline.tax_deadline_type}:${deadline.tax_period}`,
deadline,
]),
).values(),
)
// Insert the replacement rows BEFORE deleting the old set. A failed insert
// then leaves the previous deadlines intact the old delete-first order
// then leaves the previous deadlines intact: the old delete-first order
// meant any insert failure (like the 23502 user_id regression) wiped the
// company's tax deadlines without replacing them.
//
// Not concurrency-safe: two overlapping regenerations (settings save racing
// the cron backfill) can each delete the other's freshly inserted rows and
// leave the company with fewer rows than expected. Accepted: the daily
// backfill cron detects the missing keys and repairs on its next run.
let newIds: string[] = []
if (deadlines.length > 0) {
if (uniqueDeadlines.length > 0) {
const { data: insertedData, error: insertError } = await supabase
.from('deadlines')
.insert(deadlines)
.insert(uniqueDeadlines)
.select('id')
if (insertError) {
@@ -182,7 +265,8 @@ export async function generateTaxDeadlinesForUser(
.delete()
.eq('company_id', companyId)
.eq('source', 'system')
.gte('due_date', startDate)
.eq('is_completed', false)
.gte('due_date', todayIso)
.lte('due_date', endDate)
if (newIds.length > 0) {
@@ -197,7 +281,7 @@ export async function generateTaxDeadlinesForUser(
}
return {
created: deadlines.length,
created: uniqueDeadlines.length,
deleted: deletedData?.length || 0,
}
}
@@ -250,32 +334,133 @@ export async function regenerateTaxDeadlinesForUser(
return generateTaxDeadlinesForUser(supabase, companyId, newSettings, [currentYear, currentYear + 1])
}
interface DeadlineSettingsRow extends Partial<CompanySettingsForDeadlines> {
company_id: string
}
interface UpcomingDeadlineCompanyRow {
id: string
company_id: string
tax_deadline_type: string | null
tax_period: string | null
due_date: string | null
is_completed: boolean | null
}
// The due date is part of the identity: rows created by older schedule logic
// keep their type and period but carry a superseded statutory date, and the
// repair loop must treat those as missing so they get regenerated.
function deadlineIdentity(
type: string | null,
period: string | null,
dueDate: string | null,
): string {
return `${type}:${period}:${dueDate}`
}
// Completed rows use the looser type:period identity (no due date): a filed
// obligation is satisfied even when its stored date comes from a superseded
// schedule, and the generator never replaces completed rows, so flagging them
// by date would make the repair loop re-run for the same company every day
// without ever converging.
function completedIdentity(type: string | null, period: string | null): string {
return `${type}:${period}`
}
export function getExpectedUpcomingDeadlineKeys(
settings: CompanySettingsForDeadlines,
years: number[] = [],
fromDate: Date = new Date(),
): Set<string> {
if (years.length === 0) {
const currentYear = fromDate.getFullYear()
years = [currentYear, currentYear + 1]
}
const today = new Date(fromDate)
today.setHours(0, 0, 0, 0)
const keys = new Set<string>()
for (const config of getApplicableDeadlineConfigs(settings)) {
for (const year of years) {
for (const instance of config.generateDates(year, settings)) {
const adjustedDate = adjustDeadlineToNextBankingDay(
new Date(instance.year, instance.month, instance.day),
)
if (adjustedDate >= today) {
keys.add(deadlineIdentity(config.type, instance.period, formatDateISO(adjustedDate)))
}
}
}
}
return keys
}
export function findSettingsMissingUpcomingDeadlines(
settingsRows: DeadlineSettingsRow[],
upcomingDeadlineRows: UpcomingDeadlineCompanyRow[],
years: number[] = [],
fromDate: Date = new Date(),
): DeadlineSettingsRow[] {
const actualKeysByCompany = new Map<string, Set<string>>()
const completedKeysByCompany = new Map<string, Set<string>>()
for (const row of upcomingDeadlineRows) {
const keys = actualKeysByCompany.get(row.company_id) ?? new Set<string>()
keys.add(deadlineIdentity(row.tax_deadline_type, row.tax_period, row.due_date))
actualKeysByCompany.set(row.company_id, keys)
if (row.is_completed) {
const completed = completedKeysByCompany.get(row.company_id) ?? new Set<string>()
completed.add(completedIdentity(row.tax_deadline_type, row.tax_period))
completedKeysByCompany.set(row.company_id, completed)
}
}
return settingsRows.filter((settings) => {
try {
const expectedKeys = getExpectedUpcomingDeadlineKeys(
toDeadlineSettings(settings),
years,
fromDate,
)
const actualKeys = actualKeysByCompany.get(settings.company_id) ?? new Set<string>()
const completedKeys = completedKeysByCompany.get(settings.company_id) ?? new Set<string>()
return Array.from(expectedKeys).some((key) => {
if (actualKeys.has(key)) return false
// key is `${type}:${period}:${dueDate}`; strip the date to compare
// against the completed set (periods never contain a colon).
const typeAndPeriod = key.slice(0, key.lastIndexOf(':'))
return !completedKeys.has(typeAndPeriod)
})
} catch {
// Include malformed settings so the repair loop logs the company-specific
// generation error without aborting recovery for every other company.
return true
}
})
}
// Paginate: PostgREST silently caps a plain .select() at 1000 rows, which
// would leave companies beyond the cap without deadlines.
async function fetchAllDeadlineSettings(supabase: SupabaseClient): Promise<DeadlineSettingsRow[]> {
return fetchAllRows<DeadlineSettingsRow>(({ from, to }) =>
supabase
.from('company_settings')
.select(DEADLINE_SETTINGS_SELECT)
.order('company_id', { ascending: true })
.range(from, to),
)
}
/**
* Generate tax deadlines for the new year (called by annual cron job)
* Generate tax deadlines for the new year for every company.
*/
export async function generateNewYearDeadlines(
supabase: SupabaseClient
): Promise<{ usersProcessed: number; totalCreated: number }> {
const newYear = new Date().getFullYear()
// Fetch all companies with company settings. Paginate: PostgREST silently
// caps a plain .select() at 1000 rows, which would leave companies beyond the
// cap without next-year deadlines every January.
const allSettings = await fetchAllRows<{
company_id: string
entity_type: CompanySettingsForDeadlines['entity_type']
moms_period: CompanySettingsForDeadlines['moms_period']
f_skatt: boolean
vat_registered: boolean
pays_salaries: boolean | null
fiscal_year_start_month: number
}>(({ from, to }) =>
supabase
.from('company_settings')
.select('company_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
.order('company_id', { ascending: true })
.range(from, to)
)
const allSettings = await fetchAllDeadlineSettings(supabase)
let usersProcessed = 0
let totalCreated = 0
@@ -285,14 +470,7 @@ export async function generateNewYearDeadlines(
const result = await generateTaxDeadlinesForUser(
supabase,
settings.company_id,
{
entity_type: settings.entity_type,
moms_period: settings.moms_period,
f_skatt: settings.f_skatt,
vat_registered: settings.vat_registered,
pays_salaries: settings.pays_salaries ?? false,
fiscal_year_start_month: settings.fiscal_year_start_month,
},
toDeadlineSettings(settings),
[newYear, newYear + 1]
)
usersProcessed++
@@ -304,3 +482,55 @@ export async function generateNewYearDeadlines(
return { usersProcessed, totalCreated }
}
/**
* Repair companies whose upcoming system tax deadlines are missing or carry
* dates from superseded schedule logic.
*/
export async function backfillMissingTaxDeadlines(
supabase: SupabaseClient,
): Promise<{ companiesScanned: number; companiesRepaired: number; totalCreated: number }> {
// Window starts a year back, not today: completed rows with a superseded
// (already passed) due date must still count as satisfied, otherwise the
// repair loop flags the company forever while the generator (correctly)
// refuses to recreate a filed obligation. Matches the generator's own
// completed-row floor.
const pastFloor = `${new Date().getFullYear() - 1}-01-01`
const [allSettings, upcomingDeadlineRows] = await Promise.all([
fetchAllDeadlineSettings(supabase),
fetchAllRows<UpcomingDeadlineCompanyRow>(({ from, to }) =>
supabase
.from('deadlines')
.select('id, company_id, tax_deadline_type, tax_period, due_date, is_completed')
.eq('source', 'system')
.eq('deadline_type', 'tax')
.gte('due_date', pastFloor)
.order('id', { ascending: true })
.range(from, to),
),
])
const missingSettings = findSettingsMissingUpcomingDeadlines(allSettings, upcomingDeadlineRows)
let companiesRepaired = 0
let totalCreated = 0
for (const settings of missingSettings) {
try {
const result = await regenerateTaxDeadlinesForUser(
supabase,
settings.company_id,
toDeadlineSettings(settings),
)
companiesRepaired++
totalCreated += result.created
} catch (err) {
log.error(`Error repairing deadlines for company ${settings.company_id}:`, err)
}
}
return {
companiesScanned: allSettings.length,
companiesRepaired,
totalCreated,
}
}
+17 -2
View File
@@ -511,7 +511,7 @@
"retry": "Please try again.",
"overdue_invoices": "{count} overdue invoices",
"no_system_deadlines_title": "Automatic tax deadlines missing.",
"no_system_deadlines_description": "Deadlines for VAT, employer declarations and F-tax are generated from your company's tax settings — make sure they are filled in.",
"no_system_deadlines_description": "Deadlines for VAT, employer declarations and F-tax are generated from your company's tax settings. Make sure they are filled in.",
"generate_action": "Generate now",
"generate_open_settings": "Open tax settings",
"generate_success_title": "Tax deadlines created",
@@ -1739,8 +1739,20 @@
"period_quarterly": "Quarterly",
"period_yearly": "Yearly",
"moms_period_help": "Per decision from Skatteverket.",
"vat_taxable_base_over_40m_label": "VAT taxable base above SEK 40 million per year",
"vat_taxable_base_over_40m_help": "Requires monthly VAT reporting and moves the filing day for both the VAT and employer declarations to the 26th. Deducted tax and employer contributions must still be paid by the 12th. Also select this if Skatteverket has decided that the company files on these dates despite a lower taxable base.",
"vat_has_eu_trade_label": "Trade with other EU countries",
"vat_has_eu_trade_help": "EU purchases or sales affect the yearly VAT deadline.",
"vat_filing_method_label": "How is the VAT return filed?",
"vat_filing_method_help": "For limited companies with yearly VAT and no EU trade, the date depends on the filing method.",
"filing_method_electronic": "Electronically",
"filing_method_paper": "On a paper form",
"periodisk_enabled_label": "Files EU sales lists",
"periodisk_enabled_help": "Applies to VAT-exempt sales of goods or certain services to VAT-registered businesses in other EU countries.",
"periodisk_label": "EU sales list reporting period",
"periodisk_help": "EU goods sales must normally be reported monthly (35 ch. 2 § SFL). Quarterly requires approval from Skatteverket and only applies to services.",
"periodisk_help": "EU goods sales are normally reported monthly (35 ch. 2 § SFL). Services-only sales are reported quarterly by default; quarterly reporting of goods sales requires a decision from Skatteverket.",
"periodisk_filing_method_label": "Filing method",
"periodisk_filing_method_help": "Electronic filing is normally due on the 25th, while paper filing is due on the 20th.",
"tax_contact_heading": "Tax matters contact",
"tax_contact_help": "Used as sender on files to Skatteverket (EU sales list, etc.).",
"tax_contact_name_label": "Name",
@@ -2333,6 +2345,9 @@
"preview_truncated": "+ {remaining} more lines on booking",
"col_account": "Account",
"col_description": "Description",
"accounts_loading": "Loading chart of accounts...",
"accounts_load_failed": "The chart of accounts could not be loaded. Try again to change the account.",
"accounts_retry": "Try again",
"col_debit": "Debit",
"col_credit": "Credit",
"total_label": "Total",
+17 -2
View File
@@ -511,7 +511,7 @@
"retry": "Försök igen.",
"overdue_invoices": "{count} förfallna fakturor",
"no_system_deadlines_title": "Automatiska skattedeadlines saknas.",
"no_system_deadlines_description": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas från företagets skatteinställningar — kontrollera att de är ifyllda.",
"no_system_deadlines_description": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas från företagets skatteinställningar. Kontrollera att de är ifyllda.",
"generate_action": "Generera nu",
"generate_open_settings": "Öppna skatteinställningar",
"generate_success_title": "Skattedeadlines skapade",
@@ -1739,8 +1739,20 @@
"period_quarterly": "Kvartal",
"period_yearly": "År",
"moms_period_help": "Enligt beslut från Skatteverket.",
"vat_taxable_base_over_40m_label": "Beskattningsunderlag för moms över 40 miljoner kronor per år",
"vat_taxable_base_over_40m_help": "Kräver månadsmoms och flyttar deklarationsdagen för både moms och arbetsgivardeklaration till den 26:e. Avdragen skatt och arbetsgivaravgifter ska ändå vara betalda den 12:e. Markera även rutan om Skatteverket har beslutat att företaget ska deklarera enligt dessa tidpunkter trots lägre underlag.",
"vat_has_eu_trade_label": "Handel med andra EU-länder",
"vat_has_eu_trade_help": "Köp eller försäljning inom EU påverkar datumet för årsmoms.",
"vat_filing_method_label": "Hur lämnas momsdeklarationen?",
"vat_filing_method_help": "För aktiebolag med årsmoms utan EU-handel beror datumet på inlämningssättet.",
"filing_method_electronic": "Digitalt",
"filing_method_paper": "På pappersblankett",
"periodisk_enabled_label": "Lämnar periodisk sammanställning",
"periodisk_enabled_help": "Gäller vid momsfri försäljning av varor eller vissa tjänster till momsregistrerade företag i andra EU-länder.",
"periodisk_label": "Period för periodisk sammanställning",
"periodisk_help": "Varuförsäljning till EU ska normalt rapporteras månadsvis (35 kap. 2 § SFL). Kvartal kräver tillstånd från Skatteverket och gäller endast tjänsteförsäljning.",
"periodisk_help": "Varuförsäljning till EU rapporteras normalt månadsvis (35 kap. 2 § SFL). Enbart tjänsteförsäljning rapporteras kvartalsvis som standard; kvartalsrapportering av varuförsäljning kräver beslut från Skatteverket.",
"periodisk_filing_method_label": "Inlämningssätt",
"periodisk_filing_method_help": "Digital inlämning har normalt den 25:e som sista dag, pappersblankett den 20:e.",
"tax_contact_heading": "Kontaktperson för skatteärenden",
"tax_contact_help": "Används som avsändare på filer till Skatteverket (periodisk sammanställning m.m.).",
"tax_contact_name_label": "Namn",
@@ -2333,6 +2345,9 @@
"preview_truncated": "+ {remaining} fler rader visas vid bokföring",
"col_account": "Konto",
"col_description": "Beskrivning",
"accounts_loading": "Laddar kontoplan...",
"accounts_load_failed": "Kontoplanen kunde inte laddas. Försök igen för att ändra konto.",
"accounts_retry": "Försök igen",
"col_debit": "Debet",
"col_credit": "Kredit",
"total_label": "Totalt",
+8 -17
View File
@@ -27,7 +27,11 @@
import { config } from 'dotenv'
config({ path: '.env.local' })
import { createClient } from '@supabase/supabase-js'
import { generateTaxDeadlinesForUser } from '../lib/tax/deadline-generator'
import {
DEADLINE_SETTINGS_SELECT,
generateTaxDeadlinesForUser,
toDeadlineSettings,
} from '../lib/tax/deadline-generator'
import type { CompanySettingsForDeadlines } from '../lib/tax/deadline-config'
const APPLY = process.argv.includes('--apply')
@@ -45,14 +49,8 @@ const supabase = createClient(supabaseUrl, serviceRoleKey)
const PAGE = 1000
interface SettingsRow {
interface SettingsRow extends Partial<CompanySettingsForDeadlines> {
company_id: string
entity_type: CompanySettingsForDeadlines['entity_type']
moms_period: CompanySettingsForDeadlines['moms_period']
f_skatt: boolean
vat_registered: boolean
pays_salaries: boolean | null
fiscal_year_start_month: number
is_sandbox: boolean | null
}
@@ -62,7 +60,7 @@ async function fetchAllSettings(): Promise<SettingsRow[]> {
for (;;) {
const { data, error } = await supabase
.from('company_settings')
.select('company_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month, is_sandbox')
.select(`${DEADLINE_SETTINGS_SELECT}, is_sandbox`)
.order('company_id', { ascending: true })
.range(from, from + PAGE - 1)
if (error) {
@@ -125,14 +123,7 @@ async function main() {
continue
}
const settings: CompanySettingsForDeadlines = {
entity_type: s.entity_type,
moms_period: s.moms_period,
f_skatt: s.f_skatt,
vat_registered: s.vat_registered,
pays_salaries: s.pays_salaries ?? false,
fiscal_year_start_month: s.fiscal_year_start_month,
}
const settings = toDeadlineSettings(s)
if (DRY_RUN) {
// In dry-run we cannot cheaply know the row count without inserting, so we
@@ -0,0 +1,27 @@
-- Add the filing-profile inputs required to calculate statutory tax deadlines.
-- Defaults represent the common small-company digital filing path. EU sales
-- list deadlines stay disabled until the company explicitly opts in because
-- VAT registration alone does not create that filing obligation.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS tax_turnover_over_40m boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS vat_has_eu_trade boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS vat_filing_method text NOT NULL DEFAULT 'electronic',
ADD COLUMN IF NOT EXISTS periodisk_sammanstallning_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS periodisk_sammanstallning_filing_method text NOT NULL DEFAULT 'electronic';
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_vat_filing_method_check;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_vat_filing_method_check
CHECK (vat_filing_method IN ('electronic', 'paper'));
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_ps_filing_method_check;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_ps_filing_method_check
CHECK (periodisk_sammanstallning_filing_method IN ('electronic', 'paper'));
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,32 @@
-- VAT filing periods use beskattningsunderlag, while employer-declaration
-- deadlines use company turnover. Preserve the former combined answer as the
-- initial value for both settings so existing companies keep their dates.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS vat_taxable_base_over_40m boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS employer_turnover_over_40m boolean NOT NULL DEFAULT false;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'company_settings'
AND column_name = 'tax_turnover_over_40m'
) THEN
EXECUTE '
UPDATE public.company_settings
SET
vat_taxable_base_over_40m = tax_turnover_over_40m,
employer_turnover_over_40m = tax_turnover_over_40m
';
END IF;
END
$$;
-- Keep tax_turnover_over_40m temporarily for rollback compatibility with an
-- older application instance during deployment. New code no longer reads or
-- writes it; remove it in a later migration after the split fields are live.
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,19 @@
-- The 26th filing day for the skattedeklaration (AGI and VAT together) is
-- triggered by one statutory measure: a VAT taxable base above SEK 40 million
-- (SFL 26 kap.). No separate employer-turnover measure exists, so the split
-- introduced by 20260716120353 could show a non-VAT-reporting employer the
-- 26th when its binding AGI deadline is the 12th. Drop the employer column.
ALTER TABLE public.company_settings
DROP COLUMN IF EXISTS employer_turnover_over_40m;
-- Clear the VAT flag where the stored combination is not legally coherent
-- (the flag requires VAT registration and monthly reporting). Ambiguous
-- companies fall back to the small-company schedule, whose dates are always
-- earlier and therefore never produce a late filing.
UPDATE public.company_settings
SET vat_taxable_base_over_40m = false
WHERE vat_taxable_base_over_40m
AND (vat_registered IS DISTINCT FROM true OR moms_period IS DISTINCT FROM 'monthly');
NOTIFY pgrst, 'reload schema';
+5
View File
@@ -523,6 +523,11 @@ export function makeCompanySettings(
vat_number: null,
moms_period: 'quarterly',
periodisk_sammanstallning_period: 'quarterly',
vat_taxable_base_over_40m: false,
vat_has_eu_trade: false,
vat_filing_method: 'electronic',
periodisk_sammanstallning_enabled: false,
periodisk_sammanstallning_filing_method: 'electronic',
tax_contact_name: null,
tax_contact_phone: null,
tax_contact_email: null,
+8
View File
@@ -121,6 +121,7 @@ export type AccountingMethod = 'accrual' | 'cash'
// Moms reporting period
export type MomsPeriod = 'monthly' | 'quarterly' | 'yearly'
export type TaxFilingMethod = 'electronic' | 'paper'
// Reconciliation method
export type ReconciliationMethod = 'auto_exact' | 'auto_date_range' | 'auto_reference' | 'auto_fuzzy' | 'manual'
@@ -226,6 +227,11 @@ export interface CompanySettings {
vat_number: string | null
moms_period: MomsPeriod | null
periodisk_sammanstallning_period: 'monthly' | 'quarterly'
vat_taxable_base_over_40m: boolean
vat_has_eu_trade: boolean
vat_filing_method: TaxFilingMethod
periodisk_sammanstallning_enabled: boolean
periodisk_sammanstallning_filing_method: TaxFilingMethod
// Tax contact (SKV-filings, periodisk sammanställning, AGI, etc.)
tax_contact_name: string | null
@@ -2155,6 +2161,7 @@ export type TaxDeadlineType =
| 'moms_yearly'
| 'f_skatt'
| 'arbetsgivardeklaration'
| 'skatteinbetalning'
| 'inkomstdeklaration_ef'
| 'inkomstdeklaration_ab'
| 'arsredovisning'
@@ -2329,6 +2336,7 @@ export const TAX_DEADLINE_TYPE_LABELS: Record<TaxDeadlineType, string> = {
moms_yearly: 'Momsdeklaration (år)',
f_skatt: 'F-skatt',
arbetsgivardeklaration: 'Arbetsgivardeklaration',
skatteinbetalning: 'Skatteinbetalning (storföretag)',
inkomstdeklaration_ef: 'Inkomstdeklaration EF',
inkomstdeklaration_ab: 'Inkomstdeklaration AB',
arsredovisning: 'Årsredovisning',
+1 -1
View File
@@ -11,7 +11,7 @@
},
{
"path": "/api/tax-deadlines/cron",
"schedule": "0 0 2 1 *"
"schedule": "0 0 * * *"
},
{
"path": "/api/extensions/enable-banking/sync/cron",