fix: GH issues batch: deadlines opt-ins, SKV reconnect, narrative edit, payment-link gating (#1076)
* fix(errors): close remaining raw-message leaks after #1048 (#337) Follow-up to PR #1048. No user-visible toast or response field can now carry a raw engine or DB message; everything maps through getErrorMessage or the structured-errors registry. - get-error-message: only normalize a code-carrying Error instance into the structured path when the registry knows the code; unknown codes (Node system errors, stray third-party codes, Error-wrapped Postgres SQLSTATEs) fall through to pattern match, Swedish check, Postgres map and the status/context/generic fallbacks instead of returning the raw message. New Swedish-detection pattern for "ar last" phrases and a known-pattern row for "already has a journal entry". - structured-errors: add CANNOT_EDIT_NON_DRAFT (409) and MANDATORY_DIMENSION_MISSING (400) rows, plus common Node network codes (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN, EPIPE) as retryable 503 transients with a Swedish message. - pending-operations commit + bulk-commit routes: map executor error strings through getErrorMessage before responding (raw stays in logs); Swedish passes through, English falls to status-appropriate Swedish. - pending page: toast via getErrorMessage, fixing raw English toasts and "[object Object]" for structured envelopes on commit/bulk/reject. - transactions book + journal-entries routes: untyped catch and DB list errors no longer return err.message; mapped or static Swedish instead. - invoice send + issue-credit-note: partial_failures reasons are now Swedish (raw provider/DB text logged, never returned). - Tests: new unknown-code/Error-instance suite, registry rows asserted, route tests updated off the pinned raw-English expectations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): target the räkenskapsår for yearly VAT redovisningsperiod A yearly filer with a broken fiscal year has a Skatteverket period ending in its FY-end month, not December, and the panel's year state is never maintained in yearly mode (the year picker is replaced by the räkenskapsår selector), so calls targeted the wrong period even for calendar-FY companies filing after year end. The selected fiscal period now rides through the whole chain: panel query strings, draft/validate/ submit bodies, buildMomsuppgift (which resolves the FY bounds so the period id and the figures describe the same räkenskapsår), and the staged-commit path. MCP callers without a fiscal period keep the calendar fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): group same-day skattekonto deadlines into one card Moms, AGI and preliminärskatt legally share the skattekonto date (den 12:e), so a small monthly-moms employer saw 2-3 near-identical rows per month. Two or more pending system rows of the skattekonto family on the same due date now render as one grouped card with the date block once and each obligation as a sub-row keeping its own confirm-to-complete flow. Presentation only: rows, statuses, ICS feed unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): KU + ROT/RUT + long-tail opt-in deadlines, rolling horizon Follow-ups from the #1028 audit left out of the #1057-#1060 fix stack, each with its own condition modeling: - kontrolluppgifter (KU10/KU20/KU31), due 31 Jan (SFL 24 kap. 1 §): opt-in flag suggested from ledger signals (2898 utdelning, 2393/2893 ägarlån; deliberately not 2091, see DECISIONS.md), AB only, mirroring the #1059 EU-sales suggest-and-confirm pattern. - rot_rut_begaran, due 31 Jan after the payment year (Lag 2009:194 8 §): rows generated only for years with actually PAID ROT/RUT invoices, resolved inside the generator; invoice-derived suggestion. - Long tail, explicit opt-in ('Fler deadlines'): OSS quarterly and IOSS monthly with a skipBankingDayAdjustment config flag (EU-law dates stand on weekends), Intrastat (10th banking day of the following month), punktskatt (ordinary skattedeklaration schedule), and fyllnadsinbetalning (12th of 2nd month over 30k / 3rd of 5th month, SFL 62:8 + 65 kap.). Kvarskatt deferred: needs a slutskattebesked date the app does not hold. - Rolling generation horizon: recurring types ~6 months ahead, annual 12 months, mirrored in the backfill expectation keys so the nightly cron never thrashes; regeneration now preserves manual in_progress status; one-time cleanup migration removes existing far-future rows. Migrations also applied to the staging branch, together with the previously missing 20260717xxxxxx deadline migrations (staging had drifted and lacked dismissed_at). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(arsredovisning): keep narrative editable after year-end close The narrative save endpoint refused writes whenever the fiscal period was closed/locked, but Verkstall bokslut closes the period before the arsredovisning text is ever written, so every legitimate save failed with PERIOD_LOCKED and the PDF fell back to placeholder text. The narrative is arsredovisning document text (ARL 6 kap.), not journal rakenskapsinformation, so the bookkeeping period lock does not apply. Saves are now refused only once a Bolagsverket submission for the period is registrerad (ARSREDOVISNING_REGISTERED, 409); the filed artifact was already frozen separately by the submissions immutability trigger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): surface dead SKV connections and nudge reconnect Prod has ~70 companies that connected Skatteverket before the post-connect sync fix (#1010) and silently never synced skattekonto: the only reconnect prompt lived in the settings panel nobody revisits. - transactions-page banner when the connection is needs_reconsent or expired without refresh, linking to /settings/tax - pre-connect note in the connect panel: approve ALL behorigheter on Skatteverket's consent page (previously only shown after a failure) - wire the inert skattekonto.connection.expired event to an email nudge to the token owner; one send per consent episode via claim-first dedup in notification_log (type skv_connection_expired, partial unique index in migration 20260720090000, applied to staging) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(archive): per-year behandlingshistorik covers late-booked vouchers + Drive backup disclaimer The per-fiscal-year archive filtered audit rows by created_at within the period, dropping treatment history for bokslut entries, stornos and SIE imports booked after year end (BFNAR 2013:2 kap 8). The year archive now unions the date window with every audit row touching the period's journal entries and lines, deduped by audit id; line rows (company_id NULL by trigger design) are admitted via a scoped OR and reachable on the service-role backup path. ARCHIVE_FORMAT_VERSION 2->3 forces a one-time Drive re-upload so existing archives pick up the complete history. The Drive card on /import Exportera and the LASMIG texts now state the Drive copy is a convenience backup, not the BFL 7 kap legal archive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(decisions): clarify Arsredovisning narrative save behavior on submission status * feat(invoices): gate payment links behind invoice settings opt-in The payment-link section (manual URL field + Stripe auto-create toggle) was visible on every invoice and auto-created Stripe links on send for any connected company. It is now opt-in per company: - new company_settings.invoice_payment_links_enabled, default false for everyone (no grandfathering of Stripe-connected companies) - invoice editor hides the whole section unless enabled; a draft that already carries a link still shows it so old links stay clearable - enforced server-side in maybeCreatePaymentLinkForInvoice (after the provider lookup, so the extension-free core build never queries), so dashboard, v1, MCP and recurring sends all obey it - new toggle on Settings -> Invoicing, saves instantly; sv/en strings Migration applied to the staging branch; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): add invoice_payment_links_enabled to company settings fixture The makeCompanySettings fixture missed the new required boolean, failing the core-only build's type check of tests/helpers.ts. Default false, matching the migration default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(review): address CodeRabbit, compliance and Swedish review findings Round 2 of PR #1076 review feedback, one change per accepted finding: - pending page: res.json() safe fallback in both commit paths so a non-JSON proxy response cannot surface a raw parser error - bulk-commit: map operation status enums to Swedish display labels in the 'Redan hanterad' skip message - payment-link settings: disable the toggle while a save is in flight to prevent out-of-order PUT responses - deadlines group card: route all UI strings through next-intl (deadlines namespace, sv + en) - archive export: scope the period audit entry lookup to posted/reversed, matching the rest of the export - error tests: assert the exact registry English message for ECONNREFUSED to lock the no-leakage contract - signal routes: log.warn when best-effort lookups swallow a Supabase error (forensics), keep fail-closed behavior - narrative route: document that 'avslutad' submissions deliberately stay editable (never registered at Bolagsverket) - VAT: yearly declarations without an explicit fiscalPeriodId now resolve the räkenskapsår ending in the target year from fiscal_periods instead of assuming a calendar FY (SFL 26 kap 10-11 §§); calendar fallback only when no fiscal period exists - deadlines: IOSS deadline no longer requires vat_registered (Art. 369s has no Swedish VAT registration prerequisite) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0fbb0f8fa8
commit
87f0d5af48
@@ -1478,6 +1478,13 @@ export const UpdateSettingsSchema = z.object({
|
||||
periodisk_sammanstallning_enabled: z.boolean().optional(),
|
||||
periodisk_sammanstallning_period: PsPeriodTypeSchema.optional(),
|
||||
periodisk_sammanstallning_filing_method: TaxFilingMethodSchema.optional(),
|
||||
kontrolluppgifter_enabled: z.boolean().optional(),
|
||||
rot_rut_enabled: z.boolean().optional(),
|
||||
oss_enabled: z.boolean().optional(),
|
||||
ioss_enabled: z.boolean().optional(),
|
||||
intrastat_enabled: z.boolean().optional(),
|
||||
punktskatt_enabled: z.boolean().optional(),
|
||||
fyllnadsinbetalning_enabled: z.boolean().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('')),
|
||||
@@ -1548,6 +1555,9 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_company_name_position: z.enum(['header', 'footer']).optional(),
|
||||
invoice_late_fee_text: z.string().nullable().optional(),
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
// Opt-in for the invoice payment-link feature (editor field + automatic
|
||||
// Stripe link on send). Default off at the DB level.
|
||||
invoice_payment_links_enabled: z.boolean().optional(),
|
||||
// Editable invoice email texts: { sv?: {...}, en?: {...} }; null clears
|
||||
// all overrides. Without this entry the generic PUT would silently strip
|
||||
// the field (the schema is the de-facto column whitelist).
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getErrorMessage } from '../get-error-message'
|
||||
import {
|
||||
AccountsNotInChartError,
|
||||
BookkeepingDatabaseError,
|
||||
CannotEditNonDraftError,
|
||||
CannotReverseStornoError,
|
||||
JournalEntryNotBalancedError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
@@ -152,6 +153,66 @@ describe('getErrorMessage: typed bookkeeping Error instances (issue #337)', () =
|
||||
})
|
||||
})
|
||||
|
||||
describe('getErrorMessage: unknown-code Error instances never leak raw text (#337 follow-up)', () => {
|
||||
it('CannotEditNonDraftError instance → registry Swedish message', () => {
|
||||
const msg = getErrorMessage(new CannotEditNonDraftError('posted'))
|
||||
expect(msg).toBe('Endast utkast kan redigeras. Bokförda verifikationer rättas med storno.')
|
||||
expect(msg).not.toContain('Only draft entries')
|
||||
})
|
||||
|
||||
it('CANNOT_EDIT_NON_DRAFT envelope → registry Swedish message', () => {
|
||||
const msg = getErrorMessage({
|
||||
error: { code: 'CANNOT_EDIT_NON_DRAFT', message: 'Only draft entries can be edited' },
|
||||
})
|
||||
expect(msg).toBe('Endast utkast kan redigeras. Bokförda verifikationer rättas med storno.')
|
||||
})
|
||||
|
||||
it('ECONNREFUSED Error → transient Swedish message, never the socket string', () => {
|
||||
const err = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), {
|
||||
code: 'ECONNREFUSED',
|
||||
})
|
||||
const msg = getErrorMessage(err)
|
||||
expect(msg).toBe('Kunde inte nå en extern tjänst. Försök igen om en stund.')
|
||||
expect(msg).not.toContain('127.0.0.1')
|
||||
})
|
||||
|
||||
it('ECONNREFUSED Error with locale "en" → registry English message', () => {
|
||||
const err = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), {
|
||||
code: 'ECONNREFUSED',
|
||||
})
|
||||
const msg = getErrorMessage(err, { locale: 'en' })
|
||||
expect(msg).toBe(
|
||||
'An upstream network call failed. Retry the same request after a short backoff.'
|
||||
)
|
||||
})
|
||||
|
||||
it('Error with an unregistered code and English message → context fallback, not raw', () => {
|
||||
const err = Object.assign(new Error('some upstream failure text'), {
|
||||
code: 'E_SOMETHING_WEIRD',
|
||||
})
|
||||
const msg = getErrorMessage(err, { context: 'transaction' })
|
||||
expect(msg).toBe('Kunde inte hantera transaktionen. Försök igen.')
|
||||
expect(msg).not.toContain('upstream failure')
|
||||
})
|
||||
|
||||
it('Error wrapping a Postgres SQLSTATE → Postgres-map Swedish message', () => {
|
||||
const err = Object.assign(
|
||||
new Error('duplicate key value violates unique constraint "invoices_pkey"'),
|
||||
{ code: '23505' },
|
||||
)
|
||||
const msg = getErrorMessage(err)
|
||||
expect(msg).toBe('En post med samma uppgifter finns redan.')
|
||||
expect(msg).not.toContain('duplicate key')
|
||||
})
|
||||
|
||||
it('Error with an unregistered code but a Swedish message passes through', () => {
|
||||
const err = Object.assign(new Error('Kunde inte hantera fakturan. Försök igen.'), {
|
||||
code: 'EXT_CUSTOM_CODE',
|
||||
})
|
||||
expect(getErrorMessage(err)).toBe('Kunde inte hantera fakturan. Försök igen.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getErrorMessage: English locale uses registry English (C9)', () => {
|
||||
it('returns the registry English message for a known structured code instead of Swedish', () => {
|
||||
const code = 'FISCAL_PERIOD_NOT_FOUND'
|
||||
|
||||
@@ -44,6 +44,11 @@ describe('structured-errors registry', () => {
|
||||
expect(codes.length).toBeGreaterThan(20)
|
||||
expect(codes).toContain('JOURNAL_ENTRY_NOT_BALANCED')
|
||||
expect(codes).toContain('PROVIDER_AUTH_EXPIRED')
|
||||
expect(codes).toContain('CANNOT_EDIT_NON_DRAFT')
|
||||
expect(codes).toContain('MANDATORY_DIMENSION_MISSING')
|
||||
// Node network system codes registered as retryable transients (#337).
|
||||
expect(codes).toContain('ECONNREFUSED')
|
||||
expect(getErrorEntry('ECONNREFUSED')?.retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
// Pure module (no next/server): safe for the client bundles this file lives in.
|
||||
import { formatDimensionValidationIssues } from '@/lib/bookkeeping/dimension-errors'
|
||||
import { getErrorEntry } from './structured-errors'
|
||||
import { getErrorEntry, hasErrorEntry } from './structured-errors'
|
||||
|
||||
type ErrorContext =
|
||||
| 'invoice'
|
||||
@@ -138,6 +138,10 @@ const ERROR_PATTERN_MAP: [RegExp, string | null][] = [
|
||||
/timed out after \d+m?s/i,
|
||||
'Anslutningen mot tjänsten tog för lång tid. Försök igen.',
|
||||
],
|
||||
[
|
||||
/already has a journal entry/i,
|
||||
'Transaktionen är redan bokförd. Ångra kategoriseringen om du vill ändra den.',
|
||||
],
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -178,7 +182,7 @@ function isSwedishUserMessage(message: string): boolean {
|
||||
/session/i,
|
||||
/förfrågan/i,
|
||||
/obligatorisk/i,
|
||||
/bokföringen är låst/i,
|
||||
/är låst/i,
|
||||
/fält/i,
|
||||
/värde/i,
|
||||
/felaktig/i,
|
||||
@@ -283,22 +287,32 @@ export function getErrorMessage(
|
||||
// stays out of details. Plain objects (forwarded inner envelopes,
|
||||
// PostgrestError-shaped literals) keep the passthrough behavior.
|
||||
if (error instanceof Error) {
|
||||
return getErrorMessage(
|
||||
{
|
||||
error: {
|
||||
code: obj.code,
|
||||
message: obj.message,
|
||||
account_numbers: (obj as { accountNumbers?: unknown }).accountNumbers,
|
||||
details: { ...obj },
|
||||
// Only recurse when the registry knows the code: the structured
|
||||
// branches then own the translation. An unknown code (a Node system
|
||||
// error like ECONNREFUSED, a Postgres SQLSTATE on a wrapped Error, a
|
||||
// stray third-party code) would fall out of the structured path with
|
||||
// its raw English message, so instead fall through to the plain
|
||||
// handling below: Postgres map, known patterns, Swedish check, and
|
||||
// finally the status/context/generic fallbacks.
|
||||
if (hasErrorEntry(obj.code)) {
|
||||
return getErrorMessage(
|
||||
{
|
||||
error: {
|
||||
code: obj.code,
|
||||
message: obj.message,
|
||||
account_numbers: (obj as { accountNumbers?: unknown }).accountNumbers,
|
||||
details: { ...obj },
|
||||
},
|
||||
},
|
||||
},
|
||||
options
|
||||
)
|
||||
options
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (locale === 'en' && typeof obj.message_en === 'string' && obj.message_en.trim()) {
|
||||
return obj.message_en
|
||||
}
|
||||
return obj.message
|
||||
}
|
||||
if (locale === 'en' && typeof obj.message_en === 'string' && obj.message_en.trim()) {
|
||||
return obj.message_en
|
||||
}
|
||||
return obj.message
|
||||
}
|
||||
|
||||
// Structured application error: { error: { code, message, message_en?, ... } }
|
||||
|
||||
@@ -199,6 +199,14 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Endast bokförda verifikationer kan rättas.',
|
||||
message_en: 'Only posted entries can be corrected.',
|
||||
},
|
||||
CANNOT_EDIT_NON_DRAFT: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Endast utkast kan redigeras. Bokförda verifikationer rättas med storno.',
|
||||
message_en: 'Only draft entries can be edited; posted entries are immutable and are corrected with storno.',
|
||||
remediation: {
|
||||
description: 'Use the correction (storno) flow to change a posted entry instead of editing it.',
|
||||
},
|
||||
},
|
||||
ENTRY_ALREADY_REVERSED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
@@ -226,6 +234,17 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
|
||||
'Create the missing dimension value in the register (or re-activate the archived value), then retry. Only companies with dimensions enabled are validated; each issue in details.issues carries sie_dim_no, code and reason (unknown_dimension | unknown_value | archived_value).',
|
||||
},
|
||||
},
|
||||
MANDATORY_DIMENSION_MISSING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Ett eller flera konton kräver en dimension (t.ex. projekt eller kostnadsställe). Välj värden innan bokföring.',
|
||||
message_en:
|
||||
'One or more accounts require a dimension value. details.violations lists each account_number, sie_dim_no and dimension_name.',
|
||||
remediation: {
|
||||
description:
|
||||
'Tag every listed line with the required dimension value, then retry the commit.',
|
||||
},
|
||||
},
|
||||
BOOKKEEPING_DATABASE_ERROR: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Verifikationen kunde inte sparas. Försök igen.',
|
||||
@@ -2756,6 +2775,13 @@ const BOLAGSVERKET: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'No Bolagsverket event subscription exists for this company yet. One is created on the first submission.',
|
||||
},
|
||||
ARSREDOVISNING_REGISTERED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Årsredovisningen för räkenskapsåret är registrerad hos Bolagsverket och texterna kan inte längre ändras.',
|
||||
message_en:
|
||||
'The årsredovisning for this fiscal period has been registered with Bolagsverket; its narrative texts can no longer be edited.',
|
||||
},
|
||||
}
|
||||
|
||||
const ASSETS: Record<string, StructuredErrorEntry> = {
|
||||
@@ -2837,6 +2863,30 @@ const DIMENSION: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Node.js / undici network system codes. These surface when an outbound call
|
||||
// (email provider, Riksbanken, Skatteverket, a DB socket) fails at the
|
||||
// network layer and the raw Error bubbles up with its `code` intact.
|
||||
// Registered so they translate to a Swedish transient message instead of
|
||||
// leaking strings like "connect ECONNREFUSED 10.0.0.1:443" (#337 follow-up).
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const NETWORK_TRANSIENT_ENTRY: StructuredErrorEntry = {
|
||||
httpStatus: 503,
|
||||
message_sv: 'Kunde inte nå en extern tjänst. Försök igen om en stund.',
|
||||
message_en: 'An upstream network call failed. Retry the same request after a short backoff.',
|
||||
retryable: true,
|
||||
}
|
||||
|
||||
const NODE_SYSTEM: Record<string, StructuredErrorEntry> = {
|
||||
ECONNREFUSED: NETWORK_TRANSIENT_ENTRY,
|
||||
ECONNRESET: NETWORK_TRANSIENT_ENTRY,
|
||||
ETIMEDOUT: NETWORK_TRANSIENT_ENTRY,
|
||||
ENOTFOUND: NETWORK_TRANSIENT_ENTRY,
|
||||
EAI_AGAIN: NETWORK_TRANSIENT_ENTRY,
|
||||
EPIPE: NETWORK_TRANSIENT_ENTRY,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -2881,6 +2931,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...BOLAGSVERKET,
|
||||
...ASSETS,
|
||||
...DIMENSION,
|
||||
...NODE_SYSTEM,
|
||||
}
|
||||
|
||||
export function getErrorEntry(code: string): StructuredErrorEntry | undefined {
|
||||
|
||||
@@ -8,9 +8,27 @@ import { makeInvoice } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
const supabase = {} as SupabaseClient
|
||||
const log = { warn: vi.fn() }
|
||||
|
||||
// The bridge reads company_settings.invoice_payment_links_enabled before
|
||||
// calling the provider: `enabled: null` models a missing settings row.
|
||||
function settingsChain(enabled: boolean | null) {
|
||||
const maybeSingle = vi.fn().mockResolvedValue({
|
||||
data: enabled === null ? null : { invoice_payment_links_enabled: enabled },
|
||||
error: null,
|
||||
})
|
||||
const eq = vi.fn().mockReturnValue({ maybeSingle })
|
||||
return { select: vi.fn().mockReturnValue({ eq }) }
|
||||
}
|
||||
|
||||
function mockClient(enabled: boolean | null = true) {
|
||||
const from = vi.fn((table: string) => {
|
||||
if (table === 'company_settings') return settingsChain(enabled)
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
})
|
||||
return { client: { from } as unknown as SupabaseClient, from }
|
||||
}
|
||||
|
||||
function eligibleInvoice(overrides: Partial<Invoice> = {}): Invoice {
|
||||
return makeInvoice({
|
||||
payment_link_url: null,
|
||||
@@ -39,10 +57,13 @@ describe('maybeCreatePaymentLinkForInvoice', () => {
|
||||
})
|
||||
|
||||
it('returns null when no provider extension is registered', async () => {
|
||||
const { client, from } = mockClient()
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
client, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
// Without a provider the settings query never runs (core build stays DB-free).
|
||||
expect(from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -54,7 +75,43 @@ describe('maybeCreatePaymentLinkForInvoice', () => {
|
||||
const create = vi.fn()
|
||||
registerProvider(create)
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(overrides as Partial<Invoice>), log,
|
||||
mockClient().client, 'company-1', 'user-1',
|
||||
eligibleInvoice(overrides as Partial<Invoice>), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the provider when the company has not enabled payment links', async () => {
|
||||
const create = vi.fn()
|
||||
registerProvider(create)
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
mockClient(false).client, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a missing settings row as disabled', async () => {
|
||||
const create = vi.fn()
|
||||
registerProvider(create)
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
mockClient(null).client, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed (no link, no throw) when the settings read throws', async () => {
|
||||
const create = vi.fn()
|
||||
registerProvider(create)
|
||||
const broken = {
|
||||
from: vi.fn(() => {
|
||||
throw new Error('db down')
|
||||
}),
|
||||
} as unknown as SupabaseClient
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
broken, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
@@ -66,22 +123,23 @@ describe('maybeCreatePaymentLinkForInvoice', () => {
|
||||
externalId: 'plink_1',
|
||||
})
|
||||
registerProvider(create)
|
||||
const { client } = mockClient()
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', invoice, log,
|
||||
client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
})
|
||||
expect(create).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', invoice)
|
||||
expect(create).toHaveBeenCalledWith(client, 'company-1', 'user-1', invoice)
|
||||
})
|
||||
|
||||
it('treats a provider null (not eligible) as no link', async () => {
|
||||
registerProvider(vi.fn().mockResolvedValue(null))
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
mockClient().client, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
@@ -89,7 +147,7 @@ describe('maybeCreatePaymentLinkForInvoice', () => {
|
||||
it('never throws: a provider failure becomes { ok: false, reason }', async () => {
|
||||
registerProvider(vi.fn().mockRejectedValue(new Error('stripe down')))
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
mockClient().client, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toEqual({ ok: false, reason: 'stripe down' })
|
||||
expect(log.warn).toHaveBeenCalled()
|
||||
@@ -97,11 +155,14 @@ describe('maybeCreatePaymentLinkForInvoice', () => {
|
||||
})
|
||||
|
||||
describe('applyPaymentLinkToInvoice', () => {
|
||||
function mockPersist(error: { message: string } | null) {
|
||||
function mockPersist(error: { message: string } | null, enabled: boolean = true) {
|
||||
const eqCompany = vi.fn().mockResolvedValue({ error })
|
||||
const eqId = vi.fn().mockReturnValue({ eq: eqCompany })
|
||||
const update = vi.fn().mockReturnValue({ eq: eqId })
|
||||
const from = vi.fn().mockReturnValue({ update })
|
||||
const from = vi.fn((table: string) => {
|
||||
if (table === 'company_settings') return settingsChain(enabled)
|
||||
return { update }
|
||||
})
|
||||
return { client: { from } as unknown as SupabaseClient, from, update, eqId, eqCompany }
|
||||
}
|
||||
|
||||
@@ -124,6 +185,20 @@ describe('applyPaymentLinkToInvoice', () => {
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
})
|
||||
|
||||
it('is a no-op when the company has not enabled payment links', async () => {
|
||||
const create = vi.fn()
|
||||
registerProvider(create)
|
||||
const persist = mockPersist(null, false)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({ failure: null })
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(persist.update).not.toHaveBeenCalled()
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
})
|
||||
|
||||
it('persists the link, then mirrors it onto the invoice object', async () => {
|
||||
registerProvider(vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
@@ -173,7 +248,7 @@ describe('applyPaymentLinkToInvoice', () => {
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({ failure: 'stripe down' })
|
||||
expect(persist.from).not.toHaveBeenCalled()
|
||||
expect(persist.update).not.toHaveBeenCalled()
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -68,6 +68,22 @@ export async function maybeCreatePaymentLinkForInvoice(
|
||||
.find((ext) => typeof ext.services?.[CREATE_INVOICE_PAYMENT_LINK_SERVICE] === 'function')
|
||||
if (!provider) return null
|
||||
|
||||
// Company-wide opt-in from the invoice settings page. Checked here, after
|
||||
// the provider lookup (so the extension-free core build never queries), so
|
||||
// every send path obeys it: dashboard, v1, MCP, recurring schedules. Fail
|
||||
// closed on a read error: the feature defaults to off and a link is a
|
||||
// convenience, never worth blocking a send over.
|
||||
try {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('invoice_payment_links_enabled')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (settings?.invoice_payment_links_enabled !== true) return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const result = (await provider.services: string {
|
||||
return error instanceof Error ? error.message : 'Okänt fel'
|
||||
/**
|
||||
* Failure reasons land in the `partial_failures` response field, which is
|
||||
* user-visible: map through getErrorMessage so typed engine errors translate
|
||||
* and untyped ones fall to the Swedish context fallback instead of leaking
|
||||
* the raw message (issue #337). The raw error is always logged at the call
|
||||
* site before this runs.
|
||||
*/
|
||||
function failureReason(error: unknown): string {
|
||||
return getErrorMessage(error, { context: 'invoice' })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +179,7 @@ export async function issueCreditNote(input: IssueCreditNoteInput): Promise<Issu
|
||||
log.error('failed to create or recover credit note journal entry on issue', error, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
failures.push({ step: 'journal_entry', reason: errorMessage(error) })
|
||||
failures.push({ step: 'journal_entry', reason: failureReason(error) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +209,7 @@ export async function issueCreditNote(input: IssueCreditNoteInput): Promise<Issu
|
||||
})
|
||||
failures.push({
|
||||
step: 'journal_link',
|
||||
reason: linkError?.message ?? 'Kreditfakturan kunde inte kopplas till verifikatet.',
|
||||
reason: 'Kreditfakturan kunde inte kopplas till verifikatet.',
|
||||
})
|
||||
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
|
||||
}
|
||||
@@ -226,7 +234,7 @@ export async function issueCreditNote(input: IssueCreditNoteInput): Promise<Issu
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('failed to cancel accrual schedules for credited invoice', error)
|
||||
failures.push({ step: 'accrual_schedules', reason: errorMessage(error) })
|
||||
failures.push({ step: 'accrual_schedules', reason: failureReason(error) })
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
@@ -252,7 +260,7 @@ export async function issueCreditNote(input: IssueCreditNoteInput): Promise<Issu
|
||||
})
|
||||
failures.push({
|
||||
step: 'original_status',
|
||||
reason: originalStatusError?.message ?? 'Originalfakturan kunde inte markeras som krediterad.',
|
||||
reason: 'Originalfakturan kunde inte markeras som krediterad.',
|
||||
})
|
||||
return {
|
||||
complete: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../full-archive-export'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
import type { AuditLogEntry } from '@/types'
|
||||
|
||||
vi.mock('../sie-export', () => ({
|
||||
generateSIEExport: vi.fn().mockResolvedValue('#FLAGGA 0\n#PROGRAM "ERPBase"'),
|
||||
@@ -245,6 +246,76 @@ describe('generateFullArchive', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("includes audit rows for the period's entries booked outside the window", async () => {
|
||||
// Bokslut reality: the FY2024 year-end entry is committed in March 2025,
|
||||
// so its audit rows fall outside the period's date window. The year's
|
||||
// archive must still carry them (BFNAR 2013:2 kap 8).
|
||||
const inWindow: AuditLogEntry = {
|
||||
id: 'audit-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
action: 'INSERT',
|
||||
table_name: 'journal_entries',
|
||||
record_id: 'e-1',
|
||||
actor_id: 'user-1',
|
||||
actor_type: null,
|
||||
actor_label: null,
|
||||
old_state: null,
|
||||
new_state: null,
|
||||
description: 'Created journal_entries record',
|
||||
created_at: '2024-06-01T10:00:00Z',
|
||||
}
|
||||
const outOfWindowCommit: AuditLogEntry = {
|
||||
...inWindow,
|
||||
id: 'audit-2',
|
||||
action: 'COMMIT',
|
||||
description: 'Committed journal entry A55',
|
||||
created_at: '2025-03-15T09:00:00Z',
|
||||
}
|
||||
// Line audit rows carry company_id NULL (write_audit_log finds no
|
||||
// company_id column on journal_entry_lines).
|
||||
const lineRow: AuditLogEntry = {
|
||||
...inWindow,
|
||||
id: 'audit-3',
|
||||
company_id: null,
|
||||
table_name: 'journal_entry_lines',
|
||||
record_id: 'l-1',
|
||||
created_at: '2025-03-15T09:00:01Z',
|
||||
}
|
||||
|
||||
mockGetAuditLog.mockResolvedValue({ data: [inWindow], count: 1 })
|
||||
|
||||
enqueueMany([
|
||||
{ data: COMPANY_ROW }, // company_settings
|
||||
{ data: PERIOD_2024 }, // fiscal_periods
|
||||
{ data: [] }, // document_attachments
|
||||
{ data: [{ id: 'e-1' }] }, // journal_entries ids for the period
|
||||
{ data: [{ id: 'l-1' }] }, // journal_entry_lines ids
|
||||
// audit_log by record id: returns the in-window row again (dedupe)
|
||||
// plus the two out-of-window rows
|
||||
{ data: [inWindow, outOfWindowCommit, lineRow] },
|
||||
])
|
||||
|
||||
const buffer = await generateFullArchive(supabase as any, 'company-1', {
|
||||
scope: 'period',
|
||||
period_id: PERIOD_2024.id,
|
||||
})
|
||||
|
||||
const zip = await JSZip.loadAsync(buffer)
|
||||
const history = JSON.parse(
|
||||
await zip.file('revision/behandlingshistorik.json')!.async('text')
|
||||
) as Array<{ id: string }>
|
||||
|
||||
// Deduped: audit-1 appears once despite arriving via both fetches.
|
||||
expect(history).toHaveLength(3)
|
||||
const ids = history.map((h) => h.id)
|
||||
expect(ids).toContain('audit-2')
|
||||
expect(ids).toContain('audit-3')
|
||||
// Newest first, matching getAuditLog's output order.
|
||||
expect(ids[0]).toBe('audit-3')
|
||||
expect(ids[2]).toBe('audit-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope: all', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'neq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) {
|
||||
for (const m of ['select', 'eq', 'neq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range', 'limit']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -865,6 +865,9 @@ describe('calculateVatDeclaration: parent/summary accounts', () => {
|
||||
// Customer screenshot scenario (simplified): 3001 + 2610 booked with the
|
||||
// correct VAT amount on the parent account. Before the fix, ruta10 read 0
|
||||
// and ruta49 incorrectly showed a refund.
|
||||
// Yearly without fiscalPeriodId now looks up the räkenskapsår ending in
|
||||
// the year first; no fiscal period rows → calendar fallback.
|
||||
results = [{ data: null, error: null }]
|
||||
seedLedger([
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 21600 },
|
||||
{ account_number: '2610', debit_amount: 0, credit_amount: 9768 },
|
||||
@@ -920,6 +923,38 @@ describe('calculateVatDeclaration: annual VAT spans the räkenskapsår', () => {
|
||||
expect(result.period.end).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
it('resolves the räkenskapsår ending in the year for yearly WITHOUT a fiscalPeriodId', async () => {
|
||||
// Broken FY 2025-07-01 → 2026-06-30: a yearly submission for 2026 with no
|
||||
// explicit fiscal period (e.g. before the FY selector populated) must
|
||||
// still target the actual räkenskapsår, not calendar 2026
|
||||
// (SFL 26 kap 10-11 §§).
|
||||
results = [
|
||||
{ data: { period_start: '2025-07-01', period_end: '2026-06-30' }, error: null },
|
||||
]
|
||||
seedLedger([])
|
||||
|
||||
const result = await calculateVatDeclaration(
|
||||
supabase, 'company-1', 'yearly', 2026, 1, 'accrual',
|
||||
)
|
||||
|
||||
expect(result.period.start).toBe('2025-07-01')
|
||||
expect(result.period.end).toBe('2026-06-30')
|
||||
})
|
||||
|
||||
it('falls back to the calendar year for yearly without a fiscalPeriodId when no fiscal period exists', async () => {
|
||||
results = [
|
||||
{ data: null, error: null }, // no fiscal period ending in 2026
|
||||
]
|
||||
seedLedger([])
|
||||
|
||||
const result = await calculateVatDeclaration(
|
||||
supabase, 'company-1', 'yearly', 2026, 1, 'accrual',
|
||||
)
|
||||
|
||||
expect(result.period.start).toBe('2026-01-01')
|
||||
expect(result.period.end).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
it('ignores fiscalPeriodId for monthly periods (calendar month, no lookup)', async () => {
|
||||
seedLedger([])
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ export function buildDriveFolderReadme(params: DriveFolderReadmeParams): string
|
||||
'',
|
||||
'Arkiv <år>.zip Ett komplett arkiv per räkenskapsår: bokföringen som',
|
||||
' SIE4-fil, rapporter (JSON och CSV), underlag döpta',
|
||||
' efter verifikat samt behandlingshistorik för året.',
|
||||
' efter verifikat samt behandlingshistorik för årets',
|
||||
' bokföring.',
|
||||
'Grunddata.zip Register (kunder, leverantörer, fakturor, anställda,',
|
||||
' löner, tillgångar med mera), ursprungliga SIE-filer,',
|
||||
' dokument som ännu inte kopplats till verifikat samt',
|
||||
@@ -49,6 +50,11 @@ export function buildDriveFolderReadme(params: DriveFolderReadmeParams): string
|
||||
'ändringar laddas upp på nytt. Google Drive sparar tidigare versioner i',
|
||||
'cirka 30 dagar (högerklicka på filen och välj Hantera versioner).',
|
||||
'',
|
||||
'Den här mappen är en extra säkerhetskopia som du själv råder över.',
|
||||
'Filer i Drive kan ändras eller raderas, så mappen utgör inte företagets',
|
||||
`lagliga arkiv enligt bokföringslagen: ${app} bevarar`,
|
||||
'räkenskapsinformationen i oföränderligt skick i minst 7 år (BFL 7 kap.).',
|
||||
'',
|
||||
'Varje zip innehåller en egen LÄSMIG.txt som beskriver innehållet.',
|
||||
`${app} är öppen källkod (AGPL): bokföringen förblir läsbar utan tjänsten.`,
|
||||
'',
|
||||
@@ -71,8 +77,12 @@ export function buildArchiveReadme(params: ArchiveReadmeParams): string {
|
||||
`Omfattning: ${scopeLine}`,
|
||||
'',
|
||||
'Det här arkivet innehåller företagets räkenskapsinformation i öppna',
|
||||
'format (SIE4, JSON, CSV). Det är läsbart utan särskild programvara och',
|
||||
'är avsett att uppfylla arkiveringskraven i Bokföringslagen (7 år).',
|
||||
'format (SIE4, JSON, CSV), läsbart utan särskild programvara.',
|
||||
'',
|
||||
'Arkivet är en säkerhetskopia. En fil som lagras utanför tjänsten kan',
|
||||
'ändras eller raderas och utgör därför inte i sig det lagliga arkivet',
|
||||
`enligt bokföringslagen: ${app} bevarar räkenskapsinformationen i`,
|
||||
'oföränderligt skick i minst 7 år (BFL 7 kap.).',
|
||||
'',
|
||||
'INNEHÅLL',
|
||||
'--------',
|
||||
@@ -107,8 +117,9 @@ export function buildArchiveReadme(params: ArchiveReadmeParams): string {
|
||||
' CSV för Excel (semikolonavgränsad).',
|
||||
'dokument/ Underlag (kvitton, fakturor) döpta efter verifikat.',
|
||||
' dokument/manifest.json listar alla filer med SHA-256.',
|
||||
'revision/ behandlingshistorik.json (alla ändringar under året)',
|
||||
' och systemdokumentation.json.'
|
||||
'revision/ behandlingshistorik.json (ändringar under året samt',
|
||||
' ändringar som rör årets verifikat även när de',
|
||||
' bokförts efter årets slut) och systemdokumentation.json.'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -178,14 +178,10 @@ export async function generateFullArchive(
|
||||
|
||||
const revision = zip.folder('revision')!
|
||||
|
||||
const auditFilters =
|
||||
const auditEntries =
|
||||
options.scope === 'period'
|
||||
? {
|
||||
from_date: periods[0].period_start,
|
||||
to_date: `${periods[0].period_end}T23:59:59.999Z`,
|
||||
}
|
||||
: {}
|
||||
const auditEntries = await fetchAllAuditEntries(supabase, companyId, auditFilters)
|
||||
? await fetchPeriodAuditEntries(supabase, companyId, periods[0])
|
||||
: await fetchAllAuditEntries(supabase, companyId, {})
|
||||
revision.file('behandlingshistorik.json', JSON.stringify(auditEntries, null, 2))
|
||||
|
||||
const systemDoc = await buildSystemDoc(supabase, companyId, periods, options.scope)
|
||||
@@ -1044,6 +1040,87 @@ async function buildEntryToPeriodMap(
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandlingshistorik for a single räkenskapsår.
|
||||
*
|
||||
* A date window alone is not enough: bokslut entries and stornos for the year
|
||||
* are routinely committed months after period_end, so their audit rows fall
|
||||
* outside [period_start, period_end]. BFNAR 2013:2 kap 8 expects the year's
|
||||
* archive to carry the treatment history of the year's bokföringsposter, so
|
||||
* the window is complemented with every audit row touching the period's
|
||||
* journal entries and lines, regardless of when it was logged.
|
||||
*/
|
||||
async function fetchPeriodAuditEntries(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
period: FiscalPeriodRow
|
||||
): Promise<AuditLogEntry[]> {
|
||||
const windowed = await fetchAllAuditEntries(supabase, companyId, {
|
||||
from_date: period.period_start,
|
||||
to_date: `${period.period_end}T23:59:59.999Z`,
|
||||
})
|
||||
|
||||
const entryIds = (
|
||||
await fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', period.id)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
// Stable total order for correct paging (see fetch-all.ts).
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
).map((r) => r.id)
|
||||
if (entryIds.length === 0) return windowed
|
||||
|
||||
// journal_entry_lines has no company_id column; tenant scoping comes from
|
||||
// the entry ids fetched above.
|
||||
const lineIds: string[] = []
|
||||
for (let i = 0; i < entryIds.length; i += CHILD_FK_CHUNK) {
|
||||
const chunk = entryIds.slice(i, i + CHILD_FK_CHUNK)
|
||||
const lines = await fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id')
|
||||
.in('journal_entry_id', chunk)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
lineIds.push(...lines.map((r) => r.id))
|
||||
}
|
||||
|
||||
const byId = new Map<string, AuditLogEntry>()
|
||||
for (const row of windowed) byId.set(row.id, row)
|
||||
|
||||
// write_audit_log derives company_id from the audited row, and
|
||||
// journal_entry_lines has no such column, so line audit rows carry
|
||||
// company_id NULL: a plain company filter would drop them. The record-id
|
||||
// set above is already tenant-scoped; the OR admits NULL-company rows only
|
||||
// for journal_entry_lines. Under RLS (manual download) those rows stay
|
||||
// invisible; the service-role backup path (Drive cron) sees them.
|
||||
const recordIds = [...entryIds, ...lineIds]
|
||||
for (let i = 0; i < recordIds.length; i += CHILD_FK_CHUNK) {
|
||||
const chunk = recordIds.slice(i, i + CHILD_FK_CHUNK)
|
||||
const rows = await fetchAllRows<AuditLogEntry>(({ from, to }) =>
|
||||
supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.in('record_id', chunk)
|
||||
.or(
|
||||
`company_id.eq.${companyId},and(company_id.is.null,table_name.eq.journal_entry_lines)`
|
||||
)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
for (const row of rows) byId.set(row.id, row)
|
||||
}
|
||||
|
||||
// Newest first, matching getAuditLog's output order.
|
||||
return [...byId.values()].sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
}
|
||||
|
||||
async function fetchAllAuditEntries(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
|
||||
@@ -222,15 +222,36 @@ export async function resolvePeriodDates(
|
||||
period: number,
|
||||
fiscalPeriodId?: string
|
||||
): Promise<{ start: string; end: string }> {
|
||||
if (periodType === 'yearly' && fiscalPeriodId) {
|
||||
const { data: fp } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (fp?.period_start && fp?.period_end) {
|
||||
return { start: fp.period_start, end: fp.period_end }
|
||||
if (periodType === 'yearly') {
|
||||
if (fiscalPeriodId) {
|
||||
const { data: fp } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (fp?.period_start && fp?.period_end) {
|
||||
return { start: fp.period_start, end: fp.period_end }
|
||||
}
|
||||
} else {
|
||||
// No explicit fiscal period: resolve the räkenskapsår ending in `year`
|
||||
// instead of assuming a calendar FY. Helårsmoms is filed per
|
||||
// räkenskapsår (SFL 26 kap 10-11 §§), so for a broken fiscal year the
|
||||
// calendar-year assumption would put both the redovisningsperiod and
|
||||
// the figures on the wrong period. For calendar-FY companies this
|
||||
// resolves to Jan-Dec of `year`, identical to the arithmetic fallback.
|
||||
const { data: fp } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('company_id', companyId)
|
||||
.gte('period_end', `${year}-01-01`)
|
||||
.lte('period_end', `${year}-12-31`)
|
||||
.order('period_end', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (fp?.period_start && fp?.period_end) {
|
||||
return { start: fp.period_start, end: fp.period_end }
|
||||
}
|
||||
}
|
||||
}
|
||||
return calculatePeriodDates(periodType, year, period)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatRedovisningsperiod } from '../format'
|
||||
|
||||
describe('formatRedovisningsperiod', () => {
|
||||
it('uses the period month for monthly filers', () => {
|
||||
expect(formatRedovisningsperiod('monthly', 2025, 3)).toBe('202503')
|
||||
expect(formatRedovisningsperiod('monthly', 2025, 12)).toBe('202512')
|
||||
})
|
||||
|
||||
it('uses the last month of the quarter for quarterly filers', () => {
|
||||
expect(formatRedovisningsperiod('quarterly', 2025, 1)).toBe('202503')
|
||||
expect(formatRedovisningsperiod('quarterly', 2025, 4)).toBe('202512')
|
||||
})
|
||||
|
||||
it('falls back to December for yearly filers without a fiscal-year end', () => {
|
||||
expect(formatRedovisningsperiod('yearly', 2025, 1)).toBe('202512')
|
||||
})
|
||||
|
||||
it('targets the FY-end month for yearly filers with a broken fiscal year', () => {
|
||||
// Räkenskapsår ending 2026-06-30: helårsmoms is reported per
|
||||
// räkenskapsår (SFL 26 kap 10-11 §§), so the period is 202606.
|
||||
expect(formatRedovisningsperiod('yearly', 2026, 1, { year: 2026, month: 6 })).toBe('202606')
|
||||
expect(formatRedovisningsperiod('yearly', 2026, 1, { year: 2025, month: 12 })).toBe('202512')
|
||||
})
|
||||
|
||||
it('ignores fiscalYearEnd for sub-annual periods (calendar periods per SFL 26 kap)', () => {
|
||||
expect(formatRedovisningsperiod('monthly', 2025, 3, { year: 2026, month: 6 })).toBe('202503')
|
||||
expect(formatRedovisningsperiod('quarterly', 2025, 2, { year: 2026, month: 6 })).toBe('202506')
|
||||
})
|
||||
})
|
||||
@@ -36,11 +36,17 @@ export function formatRedovisare(
|
||||
* - monthly period 3, year 2025 → "202503"
|
||||
* - quarterly period 1, year 2025 → "202503" (Q1 ends in March)
|
||||
* - yearly period 1, year 2025 → "202512"
|
||||
*
|
||||
* Annual VAT (helårsmoms) is reported per räkenskapsår (SFL 26 kap 10-11 §§),
|
||||
* so a broken fiscal year ends in its own month, not December. Callers that
|
||||
* know the räkenskapsår pass `fiscalYearEnd`; without it the yearly branch
|
||||
* keeps the calendar-year fallback.
|
||||
*/
|
||||
export function formatRedovisningsperiod(
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number
|
||||
period: number,
|
||||
fiscalYearEnd?: { year: number; month: number }
|
||||
): string {
|
||||
let lastMonth: number
|
||||
|
||||
@@ -52,6 +58,9 @@ export function formatRedovisningsperiod(
|
||||
lastMonth = period * 3
|
||||
break
|
||||
case 'yearly':
|
||||
if (fiscalYearEnd) {
|
||||
return `${fiscalYearEnd.year}${String(fiscalYearEnd.month).padStart(2, '0')}`
|
||||
}
|
||||
lastMonth = 12
|
||||
break
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ function makeSettings(overrides: Partial<CompanySettingsForDeadlines> = {}): Com
|
||||
periodisk_sammanstallning_enabled: false,
|
||||
periodisk_sammanstallning_period: 'monthly',
|
||||
periodisk_sammanstallning_filing_method: 'electronic',
|
||||
kontrolluppgifter_enabled: false,
|
||||
rot_rut_enabled: false,
|
||||
oss_enabled: false,
|
||||
ioss_enabled: false,
|
||||
intrastat_enabled: false,
|
||||
punktskatt_enabled: false,
|
||||
fyllnadsinbetalning_enabled: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -212,6 +219,136 @@ describe('periodic EU sales list deadlines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('kontrolluppgifter: 31 January (SFL 24 kap. 1 §)', () => {
|
||||
const config = getConfig('kontrolluppgifter')
|
||||
|
||||
it('is only applicable when explicitly enabled', () => {
|
||||
expect(config.condition(makeSettings())).toBe(false)
|
||||
expect(config.condition(makeSettings({ kontrolluppgifter_enabled: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('is due 31 January for the previous income year', () => {
|
||||
const dates = config.generateDates(2027, makeSettings({ kontrolluppgifter_enabled: true }))
|
||||
expect(dates).toHaveLength(1)
|
||||
expect(dates[0]).toMatchObject({
|
||||
day: 31, month: 0, year: 2027, period: '2026', periodLabel: '2026',
|
||||
})
|
||||
})
|
||||
|
||||
it('follows the calendar income year even for broken fiscal years', () => {
|
||||
// KU reporting follows the income year (SFL 24 kap.), never the
|
||||
// räkenskapsår: a broken FY must not shift the period.
|
||||
const dates = config.generateDates(2027, makeSettings({
|
||||
kontrolluppgifter_enabled: true,
|
||||
fiscal_year_start_month: 7,
|
||||
}))
|
||||
expect(dates[0]).toMatchObject({ day: 31, month: 0, year: 2027, period: '2026' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rot_rut_begaran: 31 January after the payment year (Lag 2009:194 8 §)', () => {
|
||||
const config = getConfig('rot_rut_begaran')
|
||||
|
||||
it('is only applicable when explicitly enabled', () => {
|
||||
expect(config.condition(makeSettings())).toBe(false)
|
||||
expect(config.condition(makeSettings({ rot_rut_enabled: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('generates a 31 January row only for years with ROT/RUT payments', () => {
|
||||
const settings = makeSettings({
|
||||
rot_rut_enabled: true,
|
||||
rot_rut_payment_years: [2026],
|
||||
})
|
||||
expect(config.generateDates(2027, settings)).toEqual([
|
||||
{ day: 31, month: 0, year: 2027, period: '2026', periodLabel: '2026' },
|
||||
])
|
||||
// No payments in 2027 → no row for the 2028 deadline year.
|
||||
expect(config.generateDates(2028, settings)).toEqual([])
|
||||
})
|
||||
|
||||
it('generates nothing when payment years are unknown (pure-settings contexts)', () => {
|
||||
// Backfill detection passes settings without the derived field: the
|
||||
// deadline must never be "expected" there, or the nightly cron would
|
||||
// regenerate (and status-reset) the company every day.
|
||||
const settings = makeSettings({ rot_rut_enabled: true })
|
||||
expect(config.generateDates(2027, settings)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('long-tail opt-in deadlines', () => {
|
||||
it('OSS: quarterly, last day of the month after the quarter, opt-in, no banking-day shift', () => {
|
||||
const config = getConfig('oss_quarterly')
|
||||
expect(config.condition(makeSettings())).toBe(false)
|
||||
expect(config.condition(makeSettings({ oss_enabled: true }))).toBe(true)
|
||||
// Requires VAT registration.
|
||||
expect(config.condition(makeSettings({ oss_enabled: true, vat_registered: false }))).toBe(false)
|
||||
expect(config.skipBankingDayAdjustment).toBe(true)
|
||||
|
||||
const dates = config.generateDates(2030, makeSettings({ oss_enabled: true }))
|
||||
expect(dates.map(({ day, month, year }) => ({ day, month, year }))).toEqual([
|
||||
{ day: 30, month: 3, year: 2030 },
|
||||
{ day: 31, month: 6, year: 2030 },
|
||||
{ day: 31, month: 9, year: 2030 },
|
||||
{ day: 31, month: 0, year: 2031 },
|
||||
])
|
||||
})
|
||||
|
||||
it('IOSS: opt-in alone controls the deadline (Art. 369s does not require Swedish VAT registration)', () => {
|
||||
const config = getConfig('ioss_monthly')
|
||||
expect(config.condition(makeSettings({ ioss_enabled: true, vat_registered: false }))).toBe(true)
|
||||
expect(config.condition(makeSettings({ ioss_enabled: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('IOSS: monthly, last day of the following month, no banking-day shift', () => {
|
||||
const config = getConfig('ioss_monthly')
|
||||
expect(config.skipBankingDayAdjustment).toBe(true)
|
||||
const dates = config.generateDates(2030, makeSettings({ ioss_enabled: true }))
|
||||
expect(dates).toHaveLength(12)
|
||||
// February 2030 → due 31 March 2030 (a Sunday: the EU deadline stands).
|
||||
expect(dates[1]).toMatchObject({ day: 31, month: 2, year: 2030, period: '2030-02' })
|
||||
// December 2030 → due 31 January 2031.
|
||||
expect(dates[11]).toMatchObject({ day: 31, month: 0, year: 2031, period: '2030-12' })
|
||||
})
|
||||
|
||||
it('Intrastat: 10th banking day of the month after the reference month', () => {
|
||||
const config = getConfig('intrastat_monthly')
|
||||
const dates = config.generateDates(2030, makeSettings({ intrastat_enabled: true }))
|
||||
// January 2030 → February 2030: banking days 1,4,5,6,7,8,11,12,13,14.
|
||||
expect(dates[0]).toMatchObject({ day: 14, month: 1, year: 2030, period: '2030-01' })
|
||||
})
|
||||
|
||||
it('punktskatt: ordinary skattedeklaration schedule (12th, 17th in Jan/Aug)', () => {
|
||||
const config = getConfig('punktskatt_monthly')
|
||||
expect(config.condition(makeSettings({ punktskatt_enabled: true, vat_registered: false }))).toBe(true)
|
||||
const dates = config.generateDates(2030, makeSettings({ punktskatt_enabled: true }))
|
||||
expect(dates[0]).toMatchObject({ day: 12, month: 1, year: 2030, period: '2030-01' })
|
||||
expect(dates[6]).toMatchObject({ day: 17, month: 7, year: 2030, period: '2030-07' })
|
||||
expect(dates[11]).toMatchObject({ day: 17, month: 0, year: 2031, period: '2030-12' })
|
||||
})
|
||||
|
||||
it('fyllnadsinbetalning: 12th of 2nd month over 30k, 3rd of 5th month for the rest (SFL 62:8, 65 kap.)', () => {
|
||||
const config = getConfig('fyllnadsinbetalning')
|
||||
// Calendar FY 2030: 12 Feb 2031 and 3 May 2031.
|
||||
const dates = config.generateDates(2031, makeSettings({ fyllnadsinbetalning_enabled: true }))
|
||||
expect(dates.map(({ day, month, year }) => ({ day, month, year }))).toEqual([
|
||||
{ day: 12, month: 1, year: 2031 },
|
||||
{ day: 3, month: 4, year: 2031 },
|
||||
])
|
||||
expect(dates[0].period).toBe('2030-over30k')
|
||||
expect(dates[1].period).toBe('2030-rest')
|
||||
|
||||
// Broken FY ending June 2030 (start July): 12 Aug 2030 and 3 Nov 2030.
|
||||
const broken = config.generateDates(2030, makeSettings({
|
||||
fyllnadsinbetalning_enabled: true,
|
||||
fiscal_year_start_month: 7,
|
||||
}))
|
||||
expect(broken.map(({ day, month, year }) => ({ day, month, year }))).toEqual([
|
||||
{ day: 12, month: 7, year: 2030 },
|
||||
{ day: 3, month: 10, year: 2030 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('inkomstdeklaration_ab: digital filing deadlines', () => {
|
||||
const config = getConfig('inkomstdeklaration_ab')
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
ANNUAL_HORIZON_DAYS,
|
||||
RECURRING_HORIZON_DAYS,
|
||||
findSettingsMissingUpcomingDeadlines,
|
||||
generateTaxDeadlinesForUser,
|
||||
getExpectedUpcomingDeadlineKeys,
|
||||
@@ -24,10 +26,28 @@ const SETTINGS: CompanySettingsForDeadlines = {
|
||||
periodisk_sammanstallning_enabled: false,
|
||||
periodisk_sammanstallning_period: 'monthly',
|
||||
periodisk_sammanstallning_filing_method: 'electronic',
|
||||
kontrolluppgifter_enabled: false,
|
||||
rot_rut_enabled: false,
|
||||
oss_enabled: false,
|
||||
ioss_enabled: false,
|
||||
intrastat_enabled: false,
|
||||
punktskatt_enabled: false,
|
||||
fyllnadsinbetalning_enabled: false,
|
||||
}
|
||||
|
||||
// Future year so generated dates are never skipped as "in the past".
|
||||
const FUTURE_YEAR = new Date().getFullYear() + 1
|
||||
// Current + next year (the generator's own default): with the rolling
|
||||
// horizon, rows only generate within ~6-12 months of today, so a
|
||||
// next-year-only window would be empty for most of the year.
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
const GEN_YEARS = [CURRENT_YEAR, CURRENT_YEAR + 1]
|
||||
|
||||
/** Period string for next month (YYYY-MM): always inside the horizon. */
|
||||
function nextMonthPeriod(): string {
|
||||
const d = new Date()
|
||||
d.setDate(1)
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording mock: captures the order of insert/delete operations and the
|
||||
@@ -37,6 +57,7 @@ const FUTURE_YEAR = new Date().getFullYear() + 1
|
||||
function makeRecordingSupabase(opts: {
|
||||
insertError?: { code: string; message: string }
|
||||
completedRows?: Array<{ tax_deadline_type: string; tax_period: string }>
|
||||
inProgressRows?: Array<{ tax_deadline_type: string; tax_period: string; status: string }>
|
||||
} = {}) {
|
||||
const calls: string[] = []
|
||||
let insertPayload: Array<Record<string, unknown>> | null = null
|
||||
@@ -44,6 +65,7 @@ function makeRecordingSupabase(opts: {
|
||||
const from = vi.fn(() => {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {}
|
||||
let isDelete = false
|
||||
let isInProgressQuery = false
|
||||
const self = () => chain
|
||||
chain.insert = vi.fn((rows: Array<Record<string, unknown>>) => {
|
||||
calls.push('insert')
|
||||
@@ -61,7 +83,10 @@ function makeRecordingSupabase(opts: {
|
||||
isDelete = true
|
||||
return chain
|
||||
})
|
||||
chain.eq = vi.fn(self)
|
||||
chain.eq = vi.fn((...args: unknown[]) => {
|
||||
if (args[0] === 'status' && args[1] === 'in_progress') isInProgressQuery = true
|
||||
return chain
|
||||
})
|
||||
chain.or = vi.fn(self)
|
||||
chain.is = vi.fn(self)
|
||||
chain.gte = vi.fn(self)
|
||||
@@ -77,7 +102,7 @@ function makeRecordingSupabase(opts: {
|
||||
return chain
|
||||
})
|
||||
chain.then = vi.fn((resolve: (value: unknown) => unknown) => Promise.resolve({
|
||||
data: opts.completedRows ?? [],
|
||||
data: isInProgressQuery ? (opts.inProgressRows ?? []) : (opts.completedRows ?? []),
|
||||
error: null,
|
||||
}).then(resolve))
|
||||
return chain
|
||||
@@ -98,7 +123,7 @@ describe('generateTaxDeadlinesForUser', () => {
|
||||
it('inserts replacement rows before deleting the old set', async () => {
|
||||
const { supabase, calls } = makeRecordingSupabase()
|
||||
|
||||
const result = await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
|
||||
const result = await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, GEN_YEARS)
|
||||
|
||||
expect(calls[0]).toBe('insert')
|
||||
expect(calls[1]).toBe('delete')
|
||||
@@ -109,7 +134,7 @@ describe('generateTaxDeadlinesForUser', () => {
|
||||
it('excludes the newly inserted rows from the delete', async () => {
|
||||
const { supabase, calls } = makeRecordingSupabase()
|
||||
|
||||
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
|
||||
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, GEN_YEARS)
|
||||
|
||||
expect(calls.some((c) => c.startsWith('not('))).toBe(true)
|
||||
})
|
||||
@@ -117,7 +142,7 @@ describe('generateTaxDeadlinesForUser', () => {
|
||||
it('builds rows owned by company_id, without a user_id field', async () => {
|
||||
const { supabase, getInsertPayload } = makeRecordingSupabase()
|
||||
|
||||
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
|
||||
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, GEN_YEARS)
|
||||
|
||||
const rows = getInsertPayload()
|
||||
expect(rows).not.toBeNull()
|
||||
@@ -129,13 +154,41 @@ describe('generateTaxDeadlinesForUser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('caps generation at the rolling horizon (6 months recurring, 12 months annual)', async () => {
|
||||
const { supabase, getInsertPayload } = makeRecordingSupabase()
|
||||
|
||||
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, GEN_YEARS)
|
||||
|
||||
const rows = getInsertPayload()
|
||||
expect(rows).not.toBeNull()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const daysOut = (dueDate: string) =>
|
||||
Math.round((new Date(`${dueDate}T00:00:00`).getTime() - today.getTime()) / 86_400_000)
|
||||
|
||||
const recurring = new Set([
|
||||
'moms_monthly', 'moms_quarterly', 'f_skatt',
|
||||
'arbetsgivardeklaration', 'skatteinbetalning', 'periodisk_sammanstallning',
|
||||
])
|
||||
for (const row of rows!) {
|
||||
const limit = recurring.has(row.tax_deadline_type as string)
|
||||
? RECURRING_HORIZON_DAYS
|
||||
: ANNUAL_HORIZON_DAYS
|
||||
expect(daysOut(row.due_date as string)).toBeLessThanOrEqual(limit)
|
||||
}
|
||||
// The old 17-month window is gone: nothing sits beyond a year out.
|
||||
expect(rows!.every((row) => daysOut(row.due_date as string) <= ANNUAL_HORIZON_DAYS)).toBe(true)
|
||||
// But the near-term rows are all there (monthly settings → rows exist).
|
||||
expect(rows!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not delete existing deadlines when the insert fails', async () => {
|
||||
const { supabase, calls } = makeRecordingSupabase({
|
||||
insertError: { code: '23502', message: 'null value in column "user_id"' },
|
||||
})
|
||||
|
||||
await expect(
|
||||
generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
|
||||
generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, GEN_YEARS)
|
||||
).rejects.toMatchObject({ code: '23502' })
|
||||
|
||||
expect(calls).toContain('insert')
|
||||
@@ -143,18 +196,63 @@ describe('generateTaxDeadlinesForUser', () => {
|
||||
})
|
||||
|
||||
it('does not replace a completed future obligation with a new pending row', async () => {
|
||||
const completedPeriod = `${FUTURE_YEAR}-01`
|
||||
const completedPeriod = nextMonthPeriod()
|
||||
const { supabase, getInsertPayload } = makeRecordingSupabase({
|
||||
completedRows: [{ tax_deadline_type: 'f_skatt', tax_period: completedPeriod }],
|
||||
})
|
||||
|
||||
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
|
||||
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, GEN_YEARS)
|
||||
|
||||
expect(getInsertPayload()).not.toContainEqual(expect.objectContaining({
|
||||
tax_deadline_type: 'f_skatt',
|
||||
tax_period: completedPeriod,
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps a manually set in_progress status on the replacement row', async () => {
|
||||
const period = nextMonthPeriod()
|
||||
const { supabase, getInsertPayload } = makeRecordingSupabase({
|
||||
inProgressRows: [
|
||||
{ tax_deadline_type: 'f_skatt', tax_period: period, status: 'in_progress' },
|
||||
],
|
||||
})
|
||||
|
||||
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, GEN_YEARS)
|
||||
|
||||
const replaced = getInsertPayload()!.find(
|
||||
(row) => row.tax_deadline_type === 'f_skatt' && row.tax_period === period,
|
||||
)
|
||||
expect(replaced).toBeDefined()
|
||||
expect(replaced!.status).toBe('in_progress')
|
||||
// Other rows keep the computed status.
|
||||
const other = getInsertPayload()!.find(
|
||||
(row) => row.tax_deadline_type === 'f_skatt' && row.tax_period !== period,
|
||||
)
|
||||
expect(other?.status).not.toBe('in_progress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExpectedUpcomingDeadlineKeys: banking-day handling', () => {
|
||||
it('keeps EU-law deadlines (IOSS) on the raw date even when it is a Sunday', () => {
|
||||
// 2030-03-31 (IOSS for February 2030) is a Sunday; the banking-day
|
||||
// adjustment would move it to 2030-04-01, but EU deadlines stand.
|
||||
const keys = getExpectedUpcomingDeadlineKeys(
|
||||
{ ...SETTINGS, ioss_enabled: true },
|
||||
[2030],
|
||||
new Date(2030, 0, 1),
|
||||
)
|
||||
expect(keys.has('ioss_monthly:2030-02:2030-03-31')).toBe(true)
|
||||
expect(keys.has('ioss_monthly:2030-02:2030-04-01')).toBe(false)
|
||||
})
|
||||
|
||||
it('still shifts ordinary Skatteverket deadlines to the next banking day', () => {
|
||||
// 12 January 2030 is a Saturday; the January f-skatt date is the 17th
|
||||
// (a Thursday) so use February: 12 Feb 2030 is a Tuesday. Take May
|
||||
// instead: 2030-05-12 is a Sunday → shifted to Monday 2030-05-13.
|
||||
const keys = getExpectedUpcomingDeadlineKeys(SETTINGS, [2030], new Date(2030, 0, 1))
|
||||
expect(keys.has('f_skatt:2030-05:2030-05-13')).toBe(true)
|
||||
expect(keys.has('f_skatt:2030-05:2030-05-12')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSettingsMissingUpcomingDeadlines', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { TaxDeadlineType, EntityType, MomsPeriod, TaxFilingMethod } from '@/types'
|
||||
import { isBankingDay } from './swedish-holidays'
|
||||
|
||||
// Condition function type for determining if a deadline applies
|
||||
export type DeadlineCondition = (settings: CompanySettingsForDeadlines) => boolean
|
||||
@@ -27,6 +28,21 @@ export interface CompanySettingsForDeadlines {
|
||||
periodisk_sammanstallning_enabled: boolean
|
||||
periodisk_sammanstallning_period: 'monthly' | 'quarterly'
|
||||
periodisk_sammanstallning_filing_method: TaxFilingMethod
|
||||
kontrolluppgifter_enabled: boolean
|
||||
rot_rut_enabled: boolean
|
||||
oss_enabled: boolean
|
||||
ioss_enabled: boolean
|
||||
intrastat_enabled: boolean
|
||||
punktskatt_enabled: boolean
|
||||
fyllnadsinbetalning_enabled: boolean
|
||||
/**
|
||||
* Derived, NOT a company_settings column: distinct years with paid ROT/RUT
|
||||
* invoices. Populated by the generator from the invoices table (a begäran
|
||||
* deadline for year Y only exists when Y actually has ROT/RUT payments,
|
||||
* Lag 2009:194 8 §). Undefined in pure-settings contexts (backfill
|
||||
* detection), where rot_rut_begaran rows are simply never expected.
|
||||
*/
|
||||
rot_rut_payment_years?: number[]
|
||||
}
|
||||
|
||||
// Configuration for a single tax deadline type
|
||||
@@ -40,6 +56,12 @@ export interface TaxDeadlineConfig {
|
||||
generateDates: (year: number, settings: CompanySettingsForDeadlines) => DeadlineInstance[]
|
||||
// Link to report type for navigation
|
||||
linkedReportType: string | null
|
||||
/**
|
||||
* EU-law deadlines (OSS/IOSS) do not move to the next banking day: the
|
||||
* last day of the month stands even on weekends and holidays. Also set
|
||||
* for dates that are already computed as banking days (Intrastat).
|
||||
*/
|
||||
skipBankingDayAdjustment?: boolean
|
||||
}
|
||||
|
||||
// A specific instance of a deadline
|
||||
@@ -51,6 +73,25 @@ export interface DeadlineInstance {
|
||||
periodLabel: string // Human-readable, e.g., "Q1 2025", "januari 2025"
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-of-month of the nth Swedish banking day in a month (1-based n).
|
||||
* Used for Intrastat, whose SCB reporting dates follow the ~10th working
|
||||
* day of the month after the reference month.
|
||||
*/
|
||||
function nthBankingDayOfMonth(year: number, month: number, n: number): number {
|
||||
let count = 0
|
||||
for (let day = 1; day <= 31; day++) {
|
||||
const date = new Date(year, month, day)
|
||||
if (date.getMonth() !== month) break
|
||||
if (isBankingDay(date)) {
|
||||
count++
|
||||
if (count === n) return day
|
||||
}
|
||||
}
|
||||
// A month always has more than 10 banking days; never reached.
|
||||
return 28
|
||||
}
|
||||
|
||||
function getFiscalYearLabel(fiscalYearEndMonth: number, fiscalYearEndYear: number): string {
|
||||
return fiscalYearEndMonth === 12
|
||||
? `${fiscalYearEndYear}`
|
||||
@@ -320,6 +361,214 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// OSS (unionsordningen): quarterly declaration for B2C distance sales
|
||||
// above the EUR 10 000 threshold, filed in Skatteverket's OSS portal
|
||||
// (ML 22 kap., Art. 369f VAT directive). Due the last day of the month
|
||||
// after the quarter. EU-law deadline: it does NOT move to the next
|
||||
// banking day; a Sunday 31st stands.
|
||||
{
|
||||
type: 'oss_quarterly',
|
||||
titleTemplate: 'OSS-deklaration {periodLabel}',
|
||||
description: 'OSS-deklaration (unionsordningen) för EU-försäljning till konsumenter',
|
||||
condition: (s) => s.vat_registered && s.oss_enabled,
|
||||
priority: 'important',
|
||||
linkedReportType: null,
|
||||
skipBankingDayAdjustment: true,
|
||||
generateDates: (year) => [
|
||||
{ day: 30, month: 3, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` },
|
||||
{ day: 31, month: 6, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` },
|
||||
{ day: 31, month: 9, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` },
|
||||
{ day: 31, month: 0, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` },
|
||||
],
|
||||
},
|
||||
|
||||
// IOSS (importordningen): monthly declaration for distance sales of
|
||||
// imported low-value goods (Art. 369s VAT directive). Due the last day
|
||||
// of the following month; same EU no-shift rule as OSS. Unlike OSS the
|
||||
// scheme does not require Swedish VAT registration (Art. 369s applies to
|
||||
// registered IOSS sellers regardless), so the opt-in flag stands alone.
|
||||
{
|
||||
type: 'ioss_monthly',
|
||||
titleTemplate: 'IOSS-deklaration {periodLabel}',
|
||||
description: 'IOSS-deklaration (importordningen) för distansförsäljning av importerade varor',
|
||||
condition: (s) => s.ioss_enabled,
|
||||
priority: 'important',
|
||||
linkedReportType: null,
|
||||
skipBankingDayAdjustment: true,
|
||||
generateDates: (year) =>
|
||||
Array.from({ length: 12 }, (_, month) => {
|
||||
const deadlineYear = month === 11 ? year + 1 : year
|
||||
const deadlineMonth = (month + 1) % 12
|
||||
// Last day of the month after the reference month.
|
||||
const day = new Date(deadlineYear, deadlineMonth + 1, 0).getDate()
|
||||
return {
|
||||
day,
|
||||
month: deadlineMonth,
|
||||
year: deadlineYear,
|
||||
period: `${year}-${String(month + 1).padStart(2, '0')}`,
|
||||
periodLabel: getMonthLabel(month, year),
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
// Intrastat: SCB's monthly trade-in-goods report for companies above the
|
||||
// arrival/dispatch thresholds. SCB publishes exact dates yearly; they
|
||||
// follow the ~10th working day of the month after the reference month,
|
||||
// which is what we compute. Already a banking day, so no adjustment.
|
||||
{
|
||||
type: 'intrastat_monthly',
|
||||
titleTemplate: 'Intrastat {periodLabel}',
|
||||
description: 'Intrastat-rapport till SCB för varuhandel inom EU',
|
||||
condition: (s) => s.vat_registered && s.intrastat_enabled,
|
||||
priority: 'normal',
|
||||
linkedReportType: null,
|
||||
skipBankingDayAdjustment: true,
|
||||
generateDates: (year) =>
|
||||
Array.from({ length: 12 }, (_, month) => {
|
||||
const deadlineYear = month === 11 ? year + 1 : year
|
||||
const deadlineMonth = (month + 1) % 12
|
||||
return {
|
||||
day: nthBankingDayOfMonth(deadlineYear, deadlineMonth, 10),
|
||||
month: deadlineMonth,
|
||||
year: deadlineYear,
|
||||
period: `${year}-${String(month + 1).padStart(2, '0')}`,
|
||||
periodLabel: getMonthLabel(month, year),
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
// Punktskattedeklaration (monthly): excise duties follow the ordinary
|
||||
// skattedeklaration schedule (SFL 26 kap.): the 12th of the following
|
||||
// month (17th in January and August), the 26th for storföretag.
|
||||
{
|
||||
type: 'punktskatt_monthly',
|
||||
titleTemplate: 'Punktskattedeklaration {periodLabel}',
|
||||
description: 'Punktskattedeklaration för punktskattepliktiga företag',
|
||||
condition: (s) => s.punktskatt_enabled,
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
// Fyllnadsinbetalning: extra preliminary tax payments that stop
|
||||
// kostnadsränta on the coming kvarskatt (SFL 62 kap. 8 §, 65 kap.).
|
||||
// Parts over 30 000 kr must be on skattekontot by the 12th of the second
|
||||
// month after the beskattningsår ends (12 Feb for calendar years); the
|
||||
// remainder by the 3rd of the fifth month (3 May). Both dates are
|
||||
// generated since the app cannot know the kvarskatt amount; the labels
|
||||
// say which part each date covers.
|
||||
{
|
||||
type: 'fyllnadsinbetalning',
|
||||
titleTemplate: 'Fyllnadsinbetalning {periodLabel}',
|
||||
description: 'Extra inbetalning av preliminärskatt för att undvika kostnadsränta',
|
||||
condition: (s) => s.fyllnadsinbetalning_enabled,
|
||||
priority: 'normal',
|
||||
linkedReportType: null,
|
||||
generateDates: (year, settings) => {
|
||||
const fyEndMonth = settings.entity_type === 'enskild_firma'
|
||||
? 12
|
||||
: (settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1)
|
||||
const results: DeadlineInstance[] = []
|
||||
for (const fyEndYear of [year - 1, year]) {
|
||||
const fyLabel = getFiscalYearLabel(fyEndMonth, fyEndYear)
|
||||
// fyEndMonth is 1-indexed; (fyEndMonth - 1 + n) is the 0-indexed
|
||||
// month n months after FY end, counted from fyEndYear's January.
|
||||
// 12th of the second month after FY end (amounts over 30 000 kr):
|
||||
// February for a calendar fiscal year.
|
||||
const over = {
|
||||
day: 12,
|
||||
month: (fyEndMonth + 1) % 12,
|
||||
year: fyEndYear + Math.floor((fyEndMonth + 1) / 12),
|
||||
}
|
||||
if (over.year === year) {
|
||||
results.push({
|
||||
...over,
|
||||
period: `${fyLabel}-over30k`,
|
||||
periodLabel: `belopp över 30 000 kr, beskattningsår ${fyLabel}`,
|
||||
})
|
||||
}
|
||||
// 3rd of the fifth month after FY end (the remainder): May for a
|
||||
// calendar fiscal year.
|
||||
const rest = {
|
||||
day: 3,
|
||||
month: (fyEndMonth + 4) % 12,
|
||||
year: fyEndYear + Math.floor((fyEndMonth + 4) / 12),
|
||||
}
|
||||
if (rest.year === year) {
|
||||
results.push({
|
||||
...rest,
|
||||
period: `${fyLabel}-rest`,
|
||||
periodLabel: `resterande belopp, beskattningsår ${fyLabel}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
},
|
||||
},
|
||||
|
||||
// Kontrolluppgifter (KU10/KU20/KU31): annual income statements to
|
||||
// Skatteverket, due 31 January after the income year (SFL 24 kap. 1 §).
|
||||
// KU31 (utdelning) is never covered by the monthly AGI, so a fåmansbolag
|
||||
// paying utdelning must file it separately even when all salaries are
|
||||
// AGI-reported. Opt-in: the user confirms the flag in tax settings, where
|
||||
// a ledger-derived signal (2898 utdelning, 2393/2893 ägarlån) suggests it.
|
||||
{
|
||||
type: 'kontrolluppgifter',
|
||||
titleTemplate: 'Kontrolluppgifter {periodLabel}',
|
||||
description: 'Kontrolluppgifter (KU10/KU20/KU31) till Skatteverket',
|
||||
condition: (s) => s.kontrolluppgifter_enabled,
|
||||
priority: 'important',
|
||||
linkedReportType: null,
|
||||
generateDates: (year) => {
|
||||
// Due 31 January for the previous income year (always calendar year:
|
||||
// kontrolluppgifter follow the income year, not the räkenskapsår).
|
||||
return [
|
||||
{ day: 31, month: 0, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
|
||||
]
|
||||
},
|
||||
},
|
||||
|
||||
// ROT/RUT begäran om utbetalning: the payout request for deductions given
|
||||
// during year Y must reach Skatteverket by 31 January of year Y+1
|
||||
// (Lag 2009:194 8 §). Missing the date forfeits the payout on account
|
||||
// 1513, so this is the one deadline where lateness costs the principal,
|
||||
// not a fee. Keyed on PAYMENT years (buyer paid), never invoice dates:
|
||||
// rows only exist for years present in rot_rut_payment_years.
|
||||
{
|
||||
type: 'rot_rut_begaran',
|
||||
titleTemplate: 'ROT/RUT-begäran om utbetalning {periodLabel}',
|
||||
description: 'Begäran om utbetalning för ROT/RUT-avdrag till Skatteverket',
|
||||
condition: (s) => s.rot_rut_enabled,
|
||||
priority: 'critical',
|
||||
linkedReportType: null,
|
||||
generateDates: (year, settings) => {
|
||||
if (!(settings.rot_rut_payment_years ?? []).includes(year - 1)) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{ day: 31, month: 0, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
|
||||
]
|
||||
},
|
||||
},
|
||||
|
||||
// Inkomstdeklaration (EF) - 2 maj
|
||||
{
|
||||
type: 'inkomstdeklaration_ef',
|
||||
|
||||
@@ -15,6 +15,45 @@ import {
|
||||
} from './deadline-config'
|
||||
import { adjustDeadlineToNextBankingDay } from './swedish-holidays'
|
||||
|
||||
/**
|
||||
* Rolling generation horizons. Recurring skattekonto obligations (monthly
|
||||
* and quarterly filings) only generate ~6 months ahead: nobody acts on a
|
||||
* moms deadline 14 months out, and the rows just bury the near-term list.
|
||||
* Annual obligations keep 12 months so year-end planning still gets
|
||||
* warning. The daily backfill cron rolls the window forward: a row is
|
||||
* created once its due date enters the horizon.
|
||||
*
|
||||
* The same cutoff MUST apply in generateTaxDeadlinesForUser and
|
||||
* getExpectedUpcomingDeadlineKeys: if detection expected a row the
|
||||
* generator refuses to create, the nightly cron would regenerate (and
|
||||
* status-reset) the company every day forever.
|
||||
*/
|
||||
export const RECURRING_HORIZON_DAYS = 183
|
||||
export const ANNUAL_HORIZON_DAYS = 365
|
||||
|
||||
/** Types on the recurring horizon; anything not listed defaults to annual. */
|
||||
const RECURRING_HORIZON_TYPES = new Set<TaxDeadlineType>([
|
||||
'moms_monthly',
|
||||
'moms_quarterly',
|
||||
'f_skatt',
|
||||
'arbetsgivardeklaration',
|
||||
'skatteinbetalning',
|
||||
'periodisk_sammanstallning',
|
||||
'oss_quarterly',
|
||||
'ioss_monthly',
|
||||
'intrastat_monthly',
|
||||
'punktskatt_monthly',
|
||||
])
|
||||
|
||||
function horizonEndFor(type: TaxDeadlineType, today: Date): Date {
|
||||
const days = RECURRING_HORIZON_TYPES.has(type)
|
||||
? RECURRING_HORIZON_DAYS
|
||||
: ANNUAL_HORIZON_DAYS
|
||||
const end = new Date(today)
|
||||
end.setDate(end.getDate() + days)
|
||||
return end
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields in company_settings that affect tax deadline generation
|
||||
*/
|
||||
@@ -34,10 +73,17 @@ export const TAX_RELEVANT_FIELDS = [
|
||||
'periodisk_sammanstallning_enabled',
|
||||
'periodisk_sammanstallning_period',
|
||||
'periodisk_sammanstallning_filing_method',
|
||||
'kontrolluppgifter_enabled',
|
||||
'rot_rut_enabled',
|
||||
'oss_enabled',
|
||||
'ioss_enabled',
|
||||
'intrastat_enabled',
|
||||
'punktskatt_enabled',
|
||||
'fyllnadsinbetalning_enabled',
|
||||
] as const
|
||||
|
||||
export const DEADLINE_SETTINGS_SELECT =
|
||||
'company_id, entity_type, moms_period, f_skatt, preliminary_tax_monthly, vat_registered, pays_salaries, employer_registered, employer_seasonal, 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
|
||||
'company_id, entity_type, moms_period, f_skatt, preliminary_tax_monthly, vat_registered, pays_salaries, employer_registered, employer_seasonal, 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, kontrolluppgifter_enabled, rot_rut_enabled, oss_enabled, ioss_enabled, intrastat_enabled, punktskatt_enabled, fyllnadsinbetalning_enabled' as const
|
||||
|
||||
/**
|
||||
* Check if any tax-relevant fields changed
|
||||
@@ -82,6 +128,14 @@ export function toDeadlineSettings(
|
||||
periodisk_sammanstallning_period: settings.periodisk_sammanstallning_period ?? 'monthly',
|
||||
periodisk_sammanstallning_filing_method:
|
||||
settings.periodisk_sammanstallning_filing_method ?? 'electronic',
|
||||
kontrolluppgifter_enabled: settings.kontrolluppgifter_enabled ?? false,
|
||||
rot_rut_enabled: settings.rot_rut_enabled ?? false,
|
||||
rot_rut_payment_years: settings.rot_rut_payment_years,
|
||||
oss_enabled: settings.oss_enabled ?? false,
|
||||
ioss_enabled: settings.ioss_enabled ?? false,
|
||||
intrastat_enabled: settings.intrastat_enabled ?? false,
|
||||
punktskatt_enabled: settings.punktskatt_enabled ?? false,
|
||||
fyllnadsinbetalning_enabled: settings.fyllnadsinbetalning_enabled ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +181,35 @@ export async function generateTaxDeadlinesForUser(
|
||||
years = [currentYear, currentYear + 1]
|
||||
}
|
||||
|
||||
// The ROT/RUT begäran deadline is data-dependent: a row for year Y only
|
||||
// exists when Y has paid ROT/RUT invoices (Lag 2009:194 8 §, payment
|
||||
// dates). Resolve the payment years here, in the one place that inserts
|
||||
// and deletes rows, so every generation path agrees. Callers that pass
|
||||
// pure settings (backfill detection) leave the field undefined and simply
|
||||
// never expect rot_rut_begaran rows.
|
||||
if (settings.rot_rut_enabled && settings.rot_rut_payment_years === undefined) {
|
||||
const rotRutRows = await fetchAllRows<{ paid_at: string | null }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('paid_at')
|
||||
.eq('company_id', companyId)
|
||||
.gt('deduction_total', 0)
|
||||
.not('paid_at', 'is', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
settings = {
|
||||
...settings,
|
||||
rot_rut_payment_years: Array.from(
|
||||
new Set(
|
||||
rotRutRows
|
||||
.filter((row) => row.paid_at)
|
||||
.map((row) => Number(String(row.paid_at).slice(0, 4))),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Get applicable deadline configs based on settings
|
||||
const applicableConfigs = getApplicableDeadlineConfigs(settings)
|
||||
|
||||
@@ -163,6 +246,33 @@ export async function generateTaxDeadlinesForUser(
|
||||
),
|
||||
)
|
||||
|
||||
// Manual progress survives regeneration: a replacement row for the same
|
||||
// obligation keeps its in_progress status instead of resetting to
|
||||
// upcoming. This matters with the rolling horizon, where the backfill
|
||||
// cron regenerates a company every time a new row enters the window.
|
||||
const { data: inProgressRows, error: inProgressError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('tax_deadline_type, tax_period, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source', 'system')
|
||||
.eq('is_completed', false)
|
||||
.is('dismissed_at', null)
|
||||
.eq('status', 'in_progress')
|
||||
|
||||
if (inProgressError) {
|
||||
log.error('Error fetching in-progress deadlines:', inProgressError)
|
||||
throw inProgressError
|
||||
}
|
||||
|
||||
const inProgressKeys = new Set(
|
||||
(inProgressRows ?? [])
|
||||
.filter((row: { status?: string }) => row.status === 'in_progress')
|
||||
.map(
|
||||
(row: { tax_deadline_type: string | null; tax_period: string | null }) =>
|
||||
`${row.tax_deadline_type}:${row.tax_period}`,
|
||||
),
|
||||
)
|
||||
|
||||
// Generate new deadlines
|
||||
const deadlines: Array<{
|
||||
company_id: string
|
||||
@@ -182,6 +292,7 @@ export async function generateTaxDeadlinesForUser(
|
||||
}> = []
|
||||
|
||||
for (const config of applicableConfigs) {
|
||||
const horizonEnd = horizonEndFor(config.type, today)
|
||||
for (const year of years) {
|
||||
const instances = config.generateDates(year, settings)
|
||||
|
||||
@@ -189,8 +300,11 @@ export async function generateTaxDeadlinesForUser(
|
||||
// Create the raw deadline date
|
||||
const rawDate = new Date(instance.year, instance.month, instance.day)
|
||||
|
||||
// Adjust for banking days (skip weekends and holidays)
|
||||
const adjustedDate = adjustDeadlineToNextBankingDay(rawDate)
|
||||
// Adjust for banking days (skip weekends and holidays). EU-law
|
||||
// deadlines (OSS/IOSS) opt out: their dates stand on weekends.
|
||||
const adjustedDate = config.skipBankingDayAdjustment
|
||||
? rawDate
|
||||
: adjustDeadlineToNextBankingDay(rawDate)
|
||||
const dueDate = formatDateISO(adjustedDate)
|
||||
|
||||
// Skip if the deadline is in the past
|
||||
@@ -198,14 +312,23 @@ export async function generateTaxDeadlinesForUser(
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip rows beyond the rolling horizon; the daily backfill creates
|
||||
// them once they come into view.
|
||||
if (adjustedDate > horizonEnd) {
|
||||
continue
|
||||
}
|
||||
|
||||
const deadlineKey = `${config.type}:${instance.period}`
|
||||
if (completedKeys.has(deadlineKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine initial status based on days until deadline
|
||||
// Determine initial status based on days until deadline, keeping a
|
||||
// manually set in_progress from the row being replaced.
|
||||
const daysUntil = Math.ceil((adjustedDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const status: DeadlineStatus = daysUntil <= 14 ? 'action_needed' : 'upcoming'
|
||||
const status: DeadlineStatus = inProgressKeys.has(deadlineKey)
|
||||
? 'in_progress'
|
||||
: daysUntil <= 14 ? 'action_needed' : 'upcoming'
|
||||
|
||||
// Generate title from template
|
||||
const title = config.titleTemplate.replace('{periodLabel}', instance.periodLabel)
|
||||
@@ -393,12 +516,16 @@ export function getExpectedUpcomingDeadlineKeys(
|
||||
const keys = new Set<string>()
|
||||
|
||||
for (const config of getApplicableDeadlineConfigs(settings)) {
|
||||
const horizonEnd = horizonEndFor(config.type, today)
|
||||
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) {
|
||||
const rawDate = new Date(instance.year, instance.month, instance.day)
|
||||
const adjustedDate = config.skipBankingDayAdjustment
|
||||
? rawDate
|
||||
: adjustDeadlineToNextBankingDay(rawDate)
|
||||
// Same window as the generator: past rows and rows beyond the
|
||||
// rolling horizon are never expected.
|
||||
if (adjustedDate >= today && adjustedDate <= horizonEnd) {
|
||||
keys.add(deadlineIdentity(config.type, instance.period, formatDateISO(adjustedDate)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user