From 87f0d5af4822c57ae308096cce8c91863100f06f Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:38:14 +0200 Subject: [PATCH] fix: GH issues batch: deadlines opt-ins, SKV reconnect, narrative edit, payment-link gating (#1076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 Signed-off-by: Emil * 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 Signed-off-by: Emil --------- Signed-off-by: Emil Co-authored-by: Claude Fable 5 --- DECISIONS.md | 12 + app/(dashboard)/pending/page.tsx | 14 +- app/(dashboard)/transactions/page.tsx | 47 +++- .../narrative/__tests__/route.test.ts | 155 +++++++++++ .../[id]/arsredovisning/narrative/route.ts | 32 ++- .../bookkeeping/journal-entries/[id]/route.ts | 12 +- .../journal-entries/__tests__/route.test.ts | 6 +- app/api/bookkeeping/journal-entries/route.ts | 22 +- app/api/invoices/[id]/send/route.ts | 13 +- .../[id]/commit/__tests__/route.test.ts | 12 +- .../pending-operations/[id]/commit/route.ts | 17 +- .../bulk-commit/__tests__/route.test.ts | 20 +- .../pending-operations/bulk-commit/route.ts | 44 +++- .../ku-signal/__tests__/route.test.ts | 98 +++++++ app/api/settings/ku-signal/route.ts | 66 +++++ .../rot-rut-signal/__tests__/route.test.ts | 85 ++++++ app/api/settings/rot-rut-signal/route.ts | 31 +++ .../[id]/book/__tests__/route.test.ts | 5 +- app/api/transactions/[id]/book/route.ts | 6 +- components/deadlines/DeadlineGroupCard.tsx | 191 ++++++++++++++ components/deadlines/DeadlineList.tsx | 60 ++++- components/invoices/InvoiceEditor.tsx | 25 +- components/reports/SkatteverketPanel.tsx | 32 ++- components/reports/views/index.tsx | 19 +- .../settings/InvoicePaymentLinkSettings.tsx | 62 +++++ .../settings/SkatteverketConnectPanel.tsx | 19 +- components/settings/TaxSettingsForm.tsx | 191 +++++++++++++- .../sections/InvoicingSettingsContent.tsx | 6 + .../settings/sections/TaxSettingsContent.tsx | 38 ++- .../components/CloudBackupCard.tsx | 3 + extensions/general/cloud-backup/lib/sync.ts | 2 +- .../connection-expired-notification.test.ts | 218 +++++++++++++++ .../__tests__/declaration-prep.test.ts | 44 +++- extensions/general/skatteverket/index.ts | 18 +- .../lib/connection-expired-notification.ts | 243 +++++++++++++++++ .../skatteverket/lib/declaration-prep.ts | 22 +- .../general/skatteverket/lib/vat-submit.ts | 2 + lib/api/schemas.ts | 10 + .../__tests__/get-error-message.test.ts | 61 +++++ .../__tests__/structured-errors.test.ts | 5 + lib/errors/get-error-message.ts | 46 ++-- lib/errors/structured-errors.ts | 51 ++++ .../__tests__/payment-links.test.ts | 95 ++++++- lib/extensions/payment-links.ts | 16 ++ lib/invoices/issue-credit-note.ts | 20 +- .../__tests__/full-archive-export.test.ts | 71 +++++ lib/reports/__tests__/vat-declaration.test.ts | 37 ++- lib/reports/archive-readme.ts | 21 +- lib/reports/full-archive-export.ts | 91 ++++++- lib/reports/vat-declaration.ts | 39 ++- lib/skatteverket/__tests__/format.test.ts | 30 +++ lib/skatteverket/format.ts | 11 +- lib/tax/__tests__/deadline-config.test.ts | 137 ++++++++++ lib/tax/__tests__/deadline-generator.test.ts | 118 ++++++++- lib/tax/deadline-config.ts | 249 ++++++++++++++++++ lib/tax/deadline-generator.ts | 145 +++++++++- messages/en.json | 43 ++- messages/sv.json | 43 ++- ...0260719101000_rot_rut_deadline_setting.sql | 13 + ...0260719102000_deadline_horizon_cleanup.sql | 32 +++ ...000_kontrolluppgifter_deadline_setting.sql | 13 + ...0719104000_long_tail_deadline_settings.sql | 25 ++ ...tification_type_skv_connection_expired.sql | 51 ++++ ...company_settings_invoice_payment_links.sql | 19 ++ tests/helpers.ts | 8 + types/index.ts | 37 ++- 66 files changed, 3260 insertions(+), 169 deletions(-) create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts create mode 100644 app/api/settings/ku-signal/__tests__/route.test.ts create mode 100644 app/api/settings/ku-signal/route.ts create mode 100644 app/api/settings/rot-rut-signal/__tests__/route.test.ts create mode 100644 app/api/settings/rot-rut-signal/route.ts create mode 100644 components/deadlines/DeadlineGroupCard.tsx create mode 100644 components/settings/InvoicePaymentLinkSettings.tsx create mode 100644 extensions/general/skatteverket/__tests__/connection-expired-notification.test.ts create mode 100644 extensions/general/skatteverket/lib/connection-expired-notification.ts create mode 100644 lib/skatteverket/__tests__/format.test.ts create mode 100644 supabase/migrations/20260719101000_rot_rut_deadline_setting.sql create mode 100644 supabase/migrations/20260719102000_deadline_horizon_cleanup.sql create mode 100644 supabase/migrations/20260719103000_kontrolluppgifter_deadline_setting.sql create mode 100644 supabase/migrations/20260719104000_long_tail_deadline_settings.sql create mode 100644 supabase/migrations/20260720090000_notification_type_skv_connection_expired.sql create mode 100644 supabase/migrations/20260720120000_company_settings_invoice_payment_links.sql diff --git a/DECISIONS.md b/DECISIONS.md index 226fda9f..7356f53c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -220,7 +220,19 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-17] Removed 'bokslut' deadline type (replaced by statutory 'arsstamma', ABL 7:10, 6 months): the 3-month milestone had no legal basis and its broken-FY date math was off by one month (May-start FY got 31 Aug; Nov-start rolled "Feb 31" into March). Completed bokslut rows kept for history; type removed from the union like the earlier 'moms'/'inkomstdeklaration' retirements. [2026-07-17] EU-trade/PS settings stay opt-in flags; a ledger-derived signal (postings on 3108/3308/3107, 15 months) only renders a suggestion callout in tax settings. Auto-flipping registration flags from ledger data would assert a Skatteverket registration we cannot know. [2026-07-19] Voucher-sequence resync run on prod via execute_sql BEFORE the migration merges: data-only idempotent DML (no schema_migrations orphan risk) and a user was hard-blocked on year-end; migration file 20260719100000 ships the same SQL so every environment replays it as a no-op. +[2026-07-19] Arsredovisning narrative save freezes only on submission status 'registrerad', not on period lock or 'avslutad': the period closes before the AR text is written (ARL 6 kap. document text, not journal data), and 'avslutad' means the case closed without registration so the text must stay editable for a refiling. +[2026-07-19] KU deadline signal reads 2898 (utdelning) + 2393/2893 (agarlan), deliberately NOT 2091 despite the #1028 audit suggesting it: every closed year moves balanserad vinst through 2091 (resultatdisposition), so it would flag nearly all AB and train users to ignore the callout. Precision over recall for suggestions. +[2026-07-19] rot_rut_begaran rows are data-driven (paid ROT/RUT invoice years, resolved inside generateTaxDeadlinesForUser) and therefore excluded from backfill expectation keys: expecting a row whose data the pure-settings detection path cannot see would make the nightly cron regenerate (and status-reset) the company every day. +[2026-07-19] No auto-complete of rot_rut_begaran when the HUS file is generated: the user still uploads it manually to skatteverket.se, so file generation is not filing. Completing at beslut import is the honest signal but exceeds this change; the row completes manually like other deadlines. +[2026-07-19] Deadline generation horizon: 183 days for recurring skattekonto types, 365 for annual; unlisted future types default to annual. Chose horizon-in-generator over UI-collapse per approved plan; regeneration now preserves manual in_progress status because the rolling window regenerates companies routinely. +[2026-07-19] Kvarskatt deadline excluded from the long-tail batch: its due date derives from the slutskattebesked date, which the app does not hold; shipping a guessed date is worse than none. Needs a user-entered besked date to model. +[2026-07-19] Intrastat modeled as the 10th Swedish banking day of the following month (SCB calendar pattern) with a help-text pointer to SCB's official calendar, rather than hardcoding SCB's published per-year dates. +[2026-07-20] skattekonto.connection.expired handler notifies by EMAIL to the token owner only (not company contact): only the owner can redo the BankID consent; kvittens-notification precedent for channel choice (push-notifications is a separate optional extension, cross-extension imports forbidden). +[2026-07-20] Connection-expired dedup episode key = (userId, skatteverket_tokens.created_at) hashed to a uuid, claim-first via notification_log 'skv_connection_expired' + partial unique index (20260720090000): a reconnect creates a new token row and legitimately re-arms the notification; the nightly cron re-observing the same dead token stays silent. +[2026-07-20] Transactions-page SKV reconnect banner reads /api/extensions/ext/skatteverket/status client-side (parallel to the row fetch) instead of extending the transaktioner payload: keeps the extension route contract untouched and fails closed (any non-200 hides the banner). +[2026-07-19] Per-year archive behandlingshistorik completed by record-id union, not by fixing write_audit_log company_id for journal_entry_lines: the trigger+backfill fix is a migration (out of this no-migration change) and would need RLS-visible backfill; the export-side OR filter delivers line rows on the service-role backup path today. Follow-up: migrate write_audit_log to derive company_id for line rows from the parent entry + backfill NULLs, then the plain company filter covers all scopes and the RLS-bound manual download too. [2026-07-20] Menu rename Fakturor -> Kundfakturor scoped to nav label + /invoices page title + command palette only (support tip 2026-07-19): API-key scope labels, AR-ledger xlsx sheet name, and customer/supplier detail section headings keep "Fakturor" since their surrounding context already disambiguates, and renaming API scope labels would churn a stable admin surface. +[2026-07-20] Invoice payment links became opt-in via company_settings.invoice_payment_links_enabled (default false, no grandfathering of Stripe-connected companies; Emil's call): the whole payment-link section (manual field + Stripe auto toggle) is hidden in the invoice editor unless enabled on the invoice settings page, and the gate is enforced server-side in maybeCreatePaymentLinkForInvoice (after the provider lookup so the extension-free core build stays DB-free) so dashboard/v1/MCP/recurring sends all obey it; an existing draft with a link still shows the section so old links stay clearable. [2026-07-20] MCP gnubok_book_salary_run walks the whole review->approved->paid->booked chain in ONE staged op instead of mirroring the dashboard's four separate clicks: the human approval of the pending operation (high-risk, confirmed=true) IS the authorization act, and a four-op chain over MCP would just be approval theater. Missing bank details downgrade from overridable block to warnings (dashboard force-approve semantics): the payment-file generators hard-block on them where it matters. [2026-07-20] No gnubok_archive_employee tool: gnubok_update_employee already takes is_active=false (soft-archive, BFL retention) and the v1 REST surface has the DELETE verb; a dedicated tool would only bloat the tools/list budget. [2026-07-20] Booking core extracted to lib/salary/book-run.ts and shared by the dashboard route + book_salary_run executor; the v1 book route intentionally keeps its own strict-mode mirror (optimistic locking, period pre-check, its own envelope) rather than being folded in. diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index e13fe5b5..be357680 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -38,6 +38,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Textarea } from '@/components/ui/textarea' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { createClient } from '@/lib/supabase/client' import { ClipboardCheck, @@ -761,8 +762,11 @@ export default function PendingOperationsPage() { setIsCommitting(true) try { const res = await fetch(`/api/pending-operations/${selectedOp.id}/commit`, { method: 'POST' }) - const json = await res.json() - if (!res.ok) throw new Error(json.error || 'Misslyckades') + const json = await res.json().catch(() => ({})) + // getErrorMessage handles both `{ error: string }` and the structured + // `{ error: { code, message } }` envelope (the latter would otherwise + // toast "[object Object]") and never surfaces raw English. + if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status })) toast({ title: 'Godkänd', description: selectedOp.title }) setShowCommitDialog(false) setSelectedOp(null) @@ -787,8 +791,8 @@ export default function PendingOperationsPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }), }) - const json = await res.json() - if (!res.ok) throw new Error(json.error || 'Misslyckades') + const json = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status })) const summary = json.data?.summary as | { committed: number; failed: number; skipped: number; rejected: number } @@ -849,7 +853,7 @@ export default function PendingOperationsPage() { }) if (!res.ok) { const json = await res.json().catch(() => ({})) - throw new Error(json.error || 'Misslyckades') + throw new Error(getErrorMessage(json, { statusCode: res.status })) } toast({ title: 'Avvisad', description: rejectOp.title }) setRejectOp(null) diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 30c47811..e532887f 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -22,7 +22,7 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, } from '@/components/ui/dropdown-menu' -import { ChevronDown, EyeOff, Layers, Search, Trash2, X } from 'lucide-react' +import { ChevronDown, EyeOff, Layers, Search, ShieldAlert, Trash2, X } from 'lucide-react' import TransactionForm from '@/components/transactions/TransactionForm' import BatchCategorySelector from '@/components/transactions/BatchCategorySelector' import TransactionStatusBar from '@/components/transactions/TransactionStatusBar' @@ -270,6 +270,11 @@ export default function TransactionsPage() { const [skvMatchTarget, setSkvMatchTarget] = useState( null, ) + // True when an SKV connection exists but is dead (needs_reconsent, or + // expired with no refresh left). Drives the reconnect banner: without it + // a user whose token died sees an empty skattekonto and has no reason to + // ever visit the settings panel where the reconnect prompt lives. + const [skvNeedsReconnect, setSkvNeedsReconnect] = useState(false) // Source filter for the merged inbox. Defaults to 'all' so users see // both sources unless they want to narrow down. @@ -361,6 +366,33 @@ export default function TransactionsPage() { const PAGE_SIZE = 200 const loadSkvRows = useCallback(async () => { + // Connection health, fetched alongside the rows: any failure (extension + // disabled, capability gate, not connected) just hides the banner. + void (async () => { + try { + const res = await fetch('/api/extensions/ext/skatteverket/status') + if (!res.ok) { + setSkvNeedsReconnect(false) + return + } + const s = (await res.json()) as { + connected?: boolean + disabled?: boolean + needsReconsent?: boolean + expired?: boolean + canRefresh?: boolean + } + setSkvNeedsReconnect( + Boolean( + s.connected && + !s.disabled && + (s.needsReconsent || (s.expired && !s.canRefresh)), + ), + ) + } catch { + setSkvNeedsReconnect(false) + } + })() try { const res = await fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner') if (!res.ok) { @@ -2093,6 +2125,19 @@ export default function TransactionsPage() { + {skvNeedsReconnect && ( +
+ +
+

{t('skv_reconnect_title')}

+

{t('skv_reconnect_body')}

+
+ +
+ )} + {/* Search + view dropdown */}
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts new file mode 100644 index 00000000..4ebb982c --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts @@ -0,0 +1,155 @@ +/** + * Tests for GET/POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative. + * + * Regression focus: the narrative must stay editable after the fiscal period + * is closed/locked (Verkställ bokslut). The normal flow closes the books + * BEFORE the årsredovisning text is written, so gating the save on the + * period lock made every legitimate save fail. Edits are refused only once + * a Bolagsverket submission for the period is registrerad. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, POST } from '../route' + +const idParams = { params: Promise.resolve({ id: 'period-1' }) } + +const narrativeRow = { + id: 'narrative-1', + company_id: 'company-1', + fiscal_period_id: 'period-1', + description: 'Bolaget bedriver konsultverksamhet.', + important_events: null, + resultatdisposition: null, + agm_date: null, + long_term_debt_over_five_years: null, + securities_pledged: null, + contingent_liabilities: null, + parent_company_name: null, + parent_company_org_number: null, + parent_company_city: null, + updated_at: '2026-01-15T10:00:00Z', +} + +function setupSupabase() { + const mock = createQueuedMockSupabase() + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: mock.supabase, + error: null, + }) + return mock +} + +beforeEach(() => { + vi.clearAllMocks() + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('GET /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET(createMockRequest('/x'), idParams) + expect(res.status).toBe(401) + }) + + it('returns 404 when the period does not belong to the company', async () => { + const { enqueue } = setupSupabase() + enqueue({ data: null }) // fiscal_periods ownership check + const res = await GET(createMockRequest('/x'), idParams) + expect(res.status).toBe(404) + }) + + it('returns the persisted narrative', async () => { + const { enqueue } = setupSupabase() + enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check + enqueue({ data: narrativeRow }) // getNarrative + const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>( + await GET(createMockRequest('/x'), idParams), + ) + expect(status).toBe(200) + expect(body.data.description).toBe('Bolaget bedriver konsultverksamhet.') + }) +}) + +describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', () => { + const postReq = (body: unknown) => createMockRequest('/x', { method: 'POST', body }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(postReq({ description: 'x' }), idParams) + expect(res.status).toBe(401) + }) + + it('returns 403 when the caller lacks write permission', async () => { + setupSupabase() + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'forbidden' }, { status: 403 }), + }) + const res = await POST(postReq({ description: 'x' }), idParams) + expect(res.status).toBe(403) + }) + + it('returns 400 on invalid payload', async () => { + setupSupabase() + const res = await POST(postReq({ agm_date: '2026-13-99' }), idParams) + expect(res.status).toBe(400) + }) + + it('returns 404 when the period does not belong to the company', async () => { + const { enqueue } = setupSupabase() + enqueue({ data: null }) // fiscal_periods ownership check + const res = await POST(postReq({ description: 'x' }), idParams) + expect(res.status).toBe(404) + }) + + it('saves the narrative for a closed/locked period (regression)', async () => { + const { enqueue } = setupSupabase() + // The ownership check no longer selects lock columns: a closed period + // (Verkställ bokslut done) must still accept narrative saves. + enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check + enqueue({ data: null }) // no registrerad submission + enqueue({ data: narrativeRow }) // upsert + const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>( + await POST(postReq({ description: 'Bolaget bedriver konsultverksamhet.' }), idParams), + ) + expect(status).toBe(200) + expect(body.data.description).toBe('Bolaget bedriver konsultverksamhet.') + }) + + it('refuses the save once the årsredovisning is registered at Bolagsverket', async () => { + const { enqueue } = setupSupabase() + enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check + enqueue({ data: { id: 'submission-1' } }) // registrerad submission exists + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await POST(postReq({ description: 'x' }), idParams), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('ARSREDOVISNING_REGISTERED') + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts index 3648a75c..69022f65 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts @@ -113,19 +113,41 @@ export const POST = withRouteContext( try { // Verify the fiscal period belongs to the authenticated company before // writing: defense-in-depth alongside RLS, gives a cleaner 404 than - // the RLS rejection envelope. Also refuse mutations on locked/closed - // periods (BFL 5 kap 5 §, räkenskapsinformation immutability). + // the RLS rejection envelope. + // + // Deliberately NOT gated on the bookkeeping period lock: the narrative + // is årsredovisning document text (ÅRL 6 kap.), not räkenskapsinformation + // in the journal, and the normal flow closes the period BEFORE the + // årsredovisning is written. The document freezes when it is filed: + // a Bolagsverket submission registered for this period makes the text + // read-only (what was uploaded is already immutable via the + // arsredovisning_submissions trigger + stored document). const { data: period } = await supabase .from('fiscal_periods') - .select('id, is_closed, locked_at, closing_entry_id') + .select('id') .eq('id', id) .eq('company_id', companyId) .maybeSingle() if (!period) { return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId }) } - if (period.is_closed || period.locked_at || period.closing_entry_id) { - return errorResponseFromCode('PERIOD_LOCKED', log, { requestId }) + // Only 'registrerad' freezes the text. 'avslutad' (case closed WITHOUT + // registration, e.g. withdrawn or rejected) deliberately stays + // editable: the document was never registered at Bolagsverket and a + // refiling needs amendable narrative text. + const { data: registered, error: registeredError } = await supabase + .from('arsredovisning_submissions') + .select('id') + .eq('company_id', companyId) + .eq('fiscal_period_id', id) + .eq('status', 'registrerad') + .limit(1) + .maybeSingle() + if (registeredError) { + throw new Error(`Failed to check submission status: ${registeredError.message}`) + } + if (registered) { + return errorResponseFromCode('ARSREDOVISNING_REGISTERED', log, { requestId }) } const data = await upsertNarrative(supabase, companyId, user.id, id, validation.data) return NextResponse.json({ data }) diff --git a/app/api/bookkeeping/journal-entries/[id]/route.ts b/app/api/bookkeeping/journal-entries/[id]/route.ts index efc61889..9aaa85ad 100644 --- a/app/api/bookkeeping/journal-entries/[id]/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/route.ts @@ -27,7 +27,12 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( .single() if (error) { - return NextResponse.json({ error: error.message }, { status: 404 }) + // PostgREST's "no rows" message is raw English; the user just needs to + // know the verifikat is gone (matches JOURNAL_ENTRY_NOT_FOUND registry). + return NextResponse.json( + { error: 'Verifikationen kunde inte hittas.' }, + { status: 404 } + ) } return NextResponse.json({ data }) @@ -106,8 +111,11 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( } catch (err) { const typed = bookkeepingErrorResponse(err) if (typed) return typed + // Untyped errors map to Swedish via getErrorMessage: the raw message is + // logged here and must never reach the user verbatim (issue #337). + logger.error('failed to update draft journal entry', { entryId: id, error: err }) return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to update journal entry' }, + { error: getErrorMessage(err, { context: 'journal_entry' }) }, { status: 400 }, ) } diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index bc462304..2b8c18e3 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -179,7 +179,8 @@ describe('GET /api/bookkeeping/journal-entries', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('DB error') + // Raw Supabase messages never reach the response field (issue #337). + expect(body.error).toBe('Verifikationerna kunde inte hämtas. Försök igen.') }) }) @@ -255,6 +256,7 @@ describe('POST /api/bookkeeping/journal-entries', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Unbalanced entry') + // Untyped engine errors map to the Swedish context fallback (issue #337). + expect(body.error).toBe('Kunde inte hantera verifikationen. Försök igen.') }) }) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 7b884c26..5855cfcf 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -6,6 +6,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateJournalEntrySchema } from '@/lib/api/schemas' import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' +import { getErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() @@ -13,7 +14,7 @@ ensureInitialized() // each param's comment) rather than a Zod schema; response shapes are legacy // `{ data, count }` / `{ error: string }` for the verifikat list UI. export const GET = withRouteContext('bookkeeping.journal_entries.list', async (request, ctx) => { - const { supabase, companyId } = ctx + const { supabase, companyId, log } = ctx const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -88,7 +89,11 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r }) if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) + log.error('failed to list fiscal period entries via RPC', error) + return NextResponse.json( + { error: 'Verifikationerna kunde inte hämtas. Försök igen.' }, + { status: 500 } + ) } const rows = data ?? [] @@ -180,7 +185,11 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r const { data, error, count } = await query if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) + log.error('failed to list journal entries', error) + return NextResponse.json( + { error: 'Verifikationerna kunde inte hämtas. Försök igen.' }, + { status: 500 } + ) } return NextResponse.json({ data, count }) @@ -189,7 +198,7 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r export const POST = withRouteContext( 'bookkeeping.journal_entries.create', async (request, ctx) => { - const { supabase, companyId, user } = ctx + const { supabase, companyId, user, log } = ctx const validation = await validateBody(request, CreateJournalEntrySchema) if (!validation.success) return validation.response @@ -206,8 +215,11 @@ export const POST = withRouteContext( } catch (err) { const typed = bookkeepingErrorResponse(err) if (typed) return typed + // Untyped errors map to Swedish via getErrorMessage: the raw message is + // logged here and must never reach the user verbatim (issue #337). + log.error('failed to create journal entry', err as Error) return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, + { error: getErrorMessage(err, { context: 'journal_entry' }) }, { status: 400 } ) } diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 7b4c82aa..a1480a68 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -268,7 +268,13 @@ export const POST = withRouteContext( const ccAddress = company.email || user.email const partialFailures: Array<{ step: string; reason: string }> = [] if (paymentLinkFailure) { - partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure }) + // The failure string is a raw provider/DB message: log it, but the + // response field is user-visible and must stay Swedish (issue #337). + opLog.warn('payment link creation failed on send', { reason: paymentLinkFailure }) + partialFailures.push({ + step: 'payment_link', + reason: 'Betalningslänken kunde inte skapas. Fakturan skickades utan betalningslänk.', + }) } let statusFlipped = isCreditDeliveryRetry @@ -387,7 +393,10 @@ export const POST = withRouteContext( if (updateError) { opLog.warn('failed to update invoice status to sent', updateError) - partialFailures.push({ step: 'status_update', reason: updateError.message }) + partialFailures.push({ + step: 'status_update', + reason: 'Fakturans status kunde inte uppdateras till skickad.', + }) } else if (!flipRows || flipRows.length === 0) { opLog.warn('invoice already flipped to sent by a concurrent request; skipping bookkeeping follow-ups') partialFailures.push({ diff --git a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts index c767a9a4..1ad975bc 100644 --- a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts @@ -100,7 +100,9 @@ describe('POST /api/pending-operations/:id/commit', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(409) - expect(body.error).toMatch(/already (committed|claimed|resolved)/i) + // The executor's English error string maps to the Swedish HTTP-409 + // fallback: raw English never reaches the toast (issue #337). + expect(body.error).toBe('En konflikt uppstod. Ladda om sidan och försök igen.') }) describe('categorize_transaction', () => { @@ -190,7 +192,10 @@ describe('POST /api/pending-operations/:id/commit', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(409) - expect(body.error).toContain('already has a journal entry') + // Known-pattern translation of the executor's English message (#337). + expect(body.error).toBe( + 'Transaktionen är redan bokförd. Ångra kategoriseringen om du vill ändra den.', + ) }) }) @@ -286,7 +291,8 @@ describe('POST /api/pending-operations/:id/commit', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toContain('Customer not found') + // English executor message → Swedish HTTP-404 fallback (issue #337). + expect(body.error).toBe('Resursen kunde inte hittas.') }) }) }) diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index f3ef0432..6583dfbc 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -3,13 +3,14 @@ import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' import { commitPendingOperation } from '@/lib/pending-operations/commit' import { bookkeepingErrorResponse, AccountsNotInChartError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors' +import { getErrorMessage } from '@/lib/errors/get-error-message' import type { PendingOperation } from '@/types' ensureInitialized() export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'pending_operation.commit', - async (_request, { supabase, user, companyId }, { params }) => { + async (_request, { supabase, user, companyId, log }, { params }) => { const { id } = await params const { data: op, error: fetchError } = await supabase @@ -48,9 +49,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( ) if (structured) return structured } + // The executor's error strings mix Swedish user messages with raw + // Supabase/Zod English; the pending page toasts this field verbatim, so + // map it here. Swedish passes through untouched, English falls to the + // status-appropriate Swedish fallback (issue #337). Raw stays in logs. + const status = result.http_status ?? 500 + log.warn('pending operation commit failed', { + operationId: id, + rawError: result.error, + status, + }) return NextResponse.json( - { error: result.error }, - { status: result.http_status ?? 500 } + { error: getErrorMessage(result.error ?? null, { statusCode: status }) }, + { status } ) } catch (err) { const typed = bookkeepingErrorResponse(err) diff --git a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts index f6676fc8..056c049c 100644 --- a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts +++ b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts @@ -150,7 +150,8 @@ describe('POST /api/pending-operations/bulk-commit', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('db connection lost') + // Raw Supabase messages never reach the response field (issue #337). + expect(body.error).toBe('Åtgärderna kunde inte hämtas. Försök igen.') }) it('reports per-item not-found as failed without calling commit', async () => { @@ -170,7 +171,7 @@ describe('POST /api/pending-operations/bulk-commit', () => { expect(status).toBe(200) expect(body.data.results).toEqual([ - { id: VALID_ID_1, status: 'failed', error: 'Operation not found' }, + { id: VALID_ID_1, status: 'failed', error: 'Åtgärden kunde inte hittas.' }, ]) expect(body.data.summary).toEqual({ total: 1, @@ -204,7 +205,7 @@ describe('POST /api/pending-operations/bulk-commit', () => { expect(status).toBe(200) expect(body.data.results).toEqual([ - { id: VALID_ID_1, status: 'skipped', error: 'Already committed' }, + { id: VALID_ID_1, status: 'skipped', error: 'Redan hanterad (godkänd)' }, { id: VALID_ID_2, status: 'skipped', @@ -296,8 +297,9 @@ describe('POST /api/pending-operations/bulk-commit', () => { }>(response) expect(status).toBe(200) + // English executor message → Swedish HTTP-409 fallback (issue #337). expect(body.data.results).toEqual([ - { id: VALID_ID_1, status: 'rejected', error: 'Resource already deleted' }, + { id: VALID_ID_1, status: 'rejected', error: 'En konflikt uppstod. Ladda om sidan och försök igen.' }, ]) expect(body.data.summary).toEqual({ total: 1, @@ -341,12 +343,14 @@ describe('POST /api/pending-operations/bulk-commit', () => { }>(response) expect(status).toBe(200) + // English executor strings map to the status-appropriate Swedish + // fallbacks; skip/not-found strings are now Swedish at the source (#337). expect(body.data.results).toEqual([ { id: VALID_ID_1, status: 'committed' }, - { id: VALID_ID_2, status: 'failed', error: 'boom' }, - { id: VALID_ID_3, status: 'skipped', error: 'Already rejected' }, - { id: VALID_ID_4, status: 'rejected', error: 'gone' }, - { id: VALID_ID_5, status: 'failed', error: 'Operation not found' }, + { id: VALID_ID_2, status: 'failed', error: 'Ett oväntat serverfel uppstod. Försök igen senare.' }, + { id: VALID_ID_3, status: 'skipped', error: 'Redan hanterad (avvisad)' }, + { id: VALID_ID_4, status: 'rejected', error: 'Resursen kunde inte hittas.' }, + { id: VALID_ID_5, status: 'failed', error: 'Åtgärden kunde inte hittas.' }, ]) expect(body.data.summary).toEqual({ total: 5, diff --git a/app/api/pending-operations/bulk-commit/route.ts b/app/api/pending-operations/bulk-commit/route.ts index 61dd9718..6af10854 100644 --- a/app/api/pending-operations/bulk-commit/route.ts +++ b/app/api/pending-operations/bulk-commit/route.ts @@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { PendingOperationsBulkSchema } from '@/lib/api/schemas' import { commitPendingOperation } from '@/lib/pending-operations/commit' +import { getErrorMessage } from '@/lib/errors/get-error-message' import type { PendingOperation } from '@/types' ensureInitialized() @@ -14,9 +15,17 @@ interface BulkCommitItemResult { error?: string } +// Swedish display labels for already-handled operations; the raw enum values +// are English and must not reach the user-visible per-item error strings. +const STATUS_LABELS_SV: Record = { + committing: 'godkänns just nu', + committed: 'godkänd', + rejected: 'avvisad', +} + export const POST = withRouteContext( 'pending_operation.bulk_commit', - async (request, { user, supabase, companyId }) => { + async (request, { user, supabase, companyId, log }) => { const validated = await validateBody(request, PendingOperationsBulkSchema) if (!validated.success) return validated.response const { ids } = validated.data @@ -28,7 +37,11 @@ export const POST = withRouteContext( .eq('company_id', companyId) if (fetchError) { - return NextResponse.json({ error: fetchError.message }, { status: 500 }) + log.error('failed to fetch pending operations for bulk commit', fetchError) + return NextResponse.json( + { error: 'Åtgärderna kunde inte hämtas. Försök igen.' }, + { status: 500 } + ) } const opsById = new Map((ops ?? []).map((op) => [op.id, op as PendingOperation])) @@ -37,11 +50,15 @@ export const POST = withRouteContext( for (const id of ids) { const op = opsById.get(id) if (!op) { - results.push({ id, status: 'failed', error: 'Operation not found' }) + results.push({ id, status: 'failed', error: 'Åtgärden kunde inte hittas.' }) continue } if (op.status !== 'pending') { - results.push({ id, status: 'skipped', error: `Already ${op.status}` }) + results.push({ + id, + status: 'skipped', + error: `Redan hanterad (${STATUS_LABELS_SV[op.status] ?? op.status})`, + }) continue } if (op.risk_level === 'high') { @@ -60,10 +77,23 @@ export const POST = withRouteContext( }) if (result.status === 'committed') { results.push({ id, status: 'committed' }) - } else if (result.status === 'rejected' && result.auto_rejected) { - results.push({ id, status: 'rejected', error: result.error ?? 'Avvisad' }) } else { - results.push({ id, status: 'failed', error: result.error ?? 'Misslyckades' }) + // The executor's error strings mix Swedish user messages with raw + // Supabase/Zod English: map to Swedish before they land in the + // user-visible per-item results (issue #337). Raw stays in logs. + log.warn('bulk commit item failed', { + operationId: id, + rawError: result.error, + status: result.http_status, + }) + const mapped = getErrorMessage(result.error ?? null, { + statusCode: result.http_status ?? 500, + }) + if (result.status === 'rejected' && result.auto_rejected) { + results.push({ id, status: 'rejected', error: mapped }) + } else { + results.push({ id, status: 'failed', error: mapped }) + } } } diff --git a/app/api/settings/ku-signal/__tests__/route.test.ts b/app/api/settings/ku-signal/__tests__/route.test.ts new file mode 100644 index 00000000..225c6d71 --- /dev/null +++ b/app/api/settings/ku-signal/__tests__/route.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for /api/settings/ku-signal — ledger-derived kontrolluppgifter signal. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const fetchEntryLinesMock = vi.fn() +vi.mock('@/lib/bookkeeping/entry-lines', () => ({ + fetchEntryLines: (...args: unknown[]) => fetchEntryLinesMock(...args), +})) + +import { GET } from '../route' + +function supabaseWithEntityType(entityType: string | null) { + return { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn(async () => ({ data: entityType ? { entity_type: entityType } : null })), + })), + })), + })), + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/settings/ku-signal', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET(createMockRequest('/api/settings/ku-signal'), {}) + expect(res.status).toBe(401) + }) + + it('reports a KU signal when an aktiebolag has utdelning/ägarlån postings', async () => { + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: supabaseWithEntityType('aktiebolag'), + error: null, + }) + fetchEntryLinesMock.mockResolvedValue([{ account_number: '2898' }]) + + const { status, body } = await parseJsonResponse<{ data: { has_ku_signal: boolean } }>( + await GET(createMockRequest('/api/settings/ku-signal'), {}) + ) + expect(status).toBe(200) + expect(body.data.has_ku_signal).toBe(true) + }) + + it('reports no signal for an empty result', async () => { + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: supabaseWithEntityType('aktiebolag'), + error: null, + }) + fetchEntryLinesMock.mockResolvedValue([]) + + const { status, body } = await parseJsonResponse<{ data: { has_ku_signal: boolean } }>( + await GET(createMockRequest('/api/settings/ku-signal'), {}) + ) + expect(status).toBe(200) + expect(body.data.has_ku_signal).toBe(false) + }) + + it('never signals for enskild firma, even with matching postings', async () => { + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: supabaseWithEntityType('enskild_firma'), + error: null, + }) + fetchEntryLinesMock.mockResolvedValue([{ account_number: '2893' }]) + + const { status, body } = await parseJsonResponse<{ data: { has_ku_signal: boolean } }>( + await GET(createMockRequest('/api/settings/ku-signal'), {}) + ) + expect(status).toBe(200) + expect(body.data.has_ku_signal).toBe(false) + // The ledger query is skipped entirely for non-AB companies. + expect(fetchEntryLinesMock).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/settings/ku-signal/route.ts b/app/api/settings/ku-signal/route.ts new file mode 100644 index 00000000..314632c2 --- /dev/null +++ b/app/api/settings/ku-signal/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' + +/** + * Accounts whose postings indicate a kontrolluppgifter obligation: + * 2898 outtagen vinstutdelning (KU31 on utdelning), 2393/2893 lån från + * närstående (KU20 on interest paid to the owner). 2091 is deliberately NOT + * a signal: every closed year moves balanserad vinst, so it would flag + * nearly all aktiebolag. + */ +const KU_SIGNAL_ACCOUNTS = ['2898', '2393', '2893'] + +/** How far back to look for KU-relevant postings. */ +const LOOKBACK_MONTHS = 15 + +/** + * GET /api/settings/ku-signal + * + * Derived suggestion signal for the tax settings page: an aktiebolag whose + * ledger shows utdelning or ägarlån postings likely owes kontrolluppgifter + * on 31 January (SFL 24 kap. 1 §), but KU31 is never covered by AGI so the + * obligation is easy to miss. The settings UI uses this to prompt the user + * to enable the KU deadline; it never flips the flag itself (mirrors the + * EU-trade signal from the periodisk sammanställning suggestion). + */ +export const GET = withRouteContext( + 'settings.ku_signal', + async (_request, { supabase, companyId, log }) => { + // Kontrolluppgifter for utdelning/ägarlån only exist for aktiebolag; an + // enskild firma has no shareholder to report on. + const { data: settings, error: settingsError } = await supabase + .from('company_settings') + .select('entity_type') + .eq('company_id', companyId) + .maybeSingle() + if (settingsError) { + // Best-effort signal: treat as "no signal" but keep the failure + // observable for forensics. + log.warn('ku-signal: company_settings lookup failed', { + code: settingsError.code, + }) + } + + if (settings?.entity_type !== 'aktiebolag') { + return NextResponse.json({ data: { has_ku_signal: false } }) + } + + const since = new Date() + since.setMonth(since.getMonth() - LOOKBACK_MONTHS) + const sinceStr = since.toISOString().split('T')[0] + + const lines = await fetchEntryLines<{ account_number: string }>({ + supabase, + lineColumns: 'account_number', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .in('status', ['posted', 'reversed']) + .gte('entry_date', sinceStr), + filterLines: (q: EntryLinesQuery) => q.in('account_number', KU_SIGNAL_ACCOUNTS), + }) + + return NextResponse.json({ data: { has_ku_signal: lines.length > 0 } }) + }, +) diff --git a/app/api/settings/rot-rut-signal/__tests__/route.test.ts b/app/api/settings/rot-rut-signal/__tests__/route.test.ts new file mode 100644 index 00000000..e7688b05 --- /dev/null +++ b/app/api/settings/rot-rut-signal/__tests__/route.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for /api/settings/rot-rut-signal — invoice-derived ROT/RUT signal. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +function supabaseWithInvoiceCount(count: number | null, error: unknown = null) { + return { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + gt: vi.fn(async () => ({ count, error })), + })), + })), + })), + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/settings/rot-rut-signal', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET(createMockRequest('/api/settings/rot-rut-signal'), {}) + expect(res.status).toBe(401) + }) + + it('reports a signal when invoices with ROT/RUT deductions exist', async () => { + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: supabaseWithInvoiceCount(2), + error: null, + }) + const { status, body } = await parseJsonResponse<{ data: { has_rot_rut: boolean } }>( + await GET(createMockRequest('/api/settings/rot-rut-signal'), {}) + ) + expect(status).toBe(200) + expect(body.data.has_rot_rut).toBe(true) + }) + + it('reports no signal when no such invoices exist', async () => { + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: supabaseWithInvoiceCount(0), + error: null, + }) + const { status, body } = await parseJsonResponse<{ data: { has_rot_rut: boolean } }>( + await GET(createMockRequest('/api/settings/rot-rut-signal'), {}) + ) + expect(status).toBe(200) + expect(body.data.has_rot_rut).toBe(false) + }) + + it('fails closed (no suggestion) on a query error', async () => { + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: supabaseWithInvoiceCount(null, { message: 'boom' }), + error: null, + }) + const { status, body } = await parseJsonResponse<{ data: { has_rot_rut: boolean } }>( + await GET(createMockRequest('/api/settings/rot-rut-signal'), {}) + ) + expect(status).toBe(200) + expect(body.data.has_rot_rut).toBe(false) + }) +}) diff --git a/app/api/settings/rot-rut-signal/route.ts b/app/api/settings/rot-rut-signal/route.ts new file mode 100644 index 00000000..44ea16ed --- /dev/null +++ b/app/api/settings/rot-rut-signal/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' + +/** + * GET /api/settings/rot-rut-signal + * + * Derived suggestion signal for the tax settings page: a company with + * invoices carrying ROT/RUT deductions must request the Skatteverket payout + * by 31 January after the payment year (Lag 2009:194 8 §), and missing the + * date forfeits the payout on account 1513. The settings UI uses this to + * prompt the user to enable the ROT/RUT deadline; it never flips the flag + * itself (mirrors the EU-trade and KU signals). + */ +export const GET = withRouteContext( + 'settings.rot_rut_signal', + async (_request, { supabase, companyId, log }) => { + const { count, error } = await supabase + .from('invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .gt('deduction_total', 0) + + // Best-effort signal: on error just hide the suggestion, but keep the + // failure observable for forensics. + if (error) { + log.warn('rot-rut-signal: invoice count failed', { code: error.code }) + } + const hasRotRut = !error && (count ?? 0) > 0 + return NextResponse.json({ data: { has_rot_rut: hasRotRut } }) + }, +) diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index 3bad15d4..b8ca9903 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -175,7 +175,10 @@ describe('POST /api/transactions/[id]/book', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Entry is not balanced') + // Untyped engine errors map to the Swedish context fallback; the raw + // English message must never reach the response field (issue #337). + expect(body.error).toBe('Kunde inte hantera transaktionen. Försök igen.') + expect(body.error).not.toContain('not balanced') }) it('creates journal entry and links to transaction (happy path)', async () => { diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index f00e408b..def75bbd 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -8,6 +8,7 @@ import { validateBody } from '@/lib/api/validate' import { BookTransactionSchema } from '@/lib/api/schemas' import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { appendProcessingHistory } from '@/lib/processing-history/append' import type { Transaction } from '@/types' @@ -138,8 +139,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( } catch (err) { const typed = bookkeepingErrorResponse(err) if (typed) return typed + // Untyped errors map to Swedish via getErrorMessage: the raw message is + // logged here and must never reach the user verbatim (issue #337). + log.error('failed to create journal entry on book', err as Error) return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, + { error: getErrorMessage(err, { context: 'transaction' }) }, { status: 400 } ) } diff --git a/components/deadlines/DeadlineGroupCard.tsx b/components/deadlines/DeadlineGroupCard.tsx new file mode 100644 index 00000000..5e59fdf7 --- /dev/null +++ b/components/deadlines/DeadlineGroupCard.tsx @@ -0,0 +1,191 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { useTranslations } from 'next-intl' +import { Deadline } from '@/types' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { isDeadlineOverdue, parseDate } from '@/lib/calendar/utils' +import { Check, Pencil } from 'lucide-react' + +/** + * System tax deadlines that legally share the skattekonto date ("den 12:e"): + * for a small monthly-moms employer, moms + AGI (+ debiterad preliminärskatt) + * all fall due the same day. Rendering them as one grouped card instead of + * 2-3 identical-date rows keeps the list scannable. + */ +export const SKATTEKONTO_GROUP_TYPES = new Set([ + 'moms_monthly', + 'moms_quarterly', + 'moms_yearly', + 'f_skatt', + 'arbetsgivardeklaration', + 'skatteinbetalning', +]) + +export function isSkattekontoDeadline(d: Deadline): boolean { + return ( + !d.is_completed && + d.source === 'system' && + d.tax_deadline_type !== null && + SKATTEKONTO_GROUP_TYPES.has(d.tax_deadline_type) + ) +} + +const SWEDISH_MONTHS_SHORT = [ + 'jan', 'feb', 'mar', 'apr', 'maj', 'jun', + 'jul', 'aug', 'sep', 'okt', 'nov', 'dec', +] + +interface DeadlineGroupCardProps { + /** Two or more skattekonto deadlines sharing the same due_date. */ + deadlines: Deadline[] + onToggle: (deadline: Deadline) => void + onEdit?: (deadline: Deadline) => void +} + +/** + * One card for all skattekonto obligations on a shared due date, with the + * date block rendered once and each obligation as a sub-row that keeps its + * own "Markera klar" confirmation. Visual language mirrors DeadlineCard. + */ +export function DeadlineGroupCard({ deadlines, onToggle, onEdit }: DeadlineGroupCardProps) { + const t = useTranslations('deadlines') + const [confirmingId, setConfirmingId] = useState(null) + const confirmRef = useRef(null) + + const first = deadlines[0] + const dueDate = parseDate(first.due_date) + const dayNum = dueDate.getDate() + const monthStr = SWEDISH_MONTHS_SHORT[dueDate.getMonth()] + const overdue = deadlines.some(isDeadlineOverdue) + + useEffect(() => { + if (!confirmingId) return + function handleClickOutside(e: MouseEvent) { + if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) { + setConfirmingId(null) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [confirmingId]) + + const confirming = deadlines.find((d) => d.id === confirmingId) ?? null + + return ( +
+
+
+ {/* Shared date block */} +
+ + {dayNum} + + + {monthStr} + +
+ +
+ + {/* Group content */} +
+
+

{t('group_skattekonto_title')}

+ + {t('group_same_day', { count: deadlines.length })} + +
+ +
+ {deadlines.map((deadline) => ( +
{ + if (!confirmingId && onEdit) onEdit(deadline) + }} + className={cn( + 'group flex items-center gap-3 rounded-md py-1.5 -mx-2 px-2 transition-colors', + onEdit && !confirmingId && 'cursor-pointer hover:bg-accent/40', + )} + > +

{deadline.title}

+ + {onEdit && !confirmingId && ( + + )} +
+ ))} +
+
+
+ + {/* Inline confirmation bar (one at a time, mirrors DeadlineCard) */} +
+
+ {confirming && ( +
+

+ {t.rich('group_confirm_question', { + title: confirming.title, + hl: (chunks) => ( + {chunks} + ), + })} +

+
+ + +
+
+ )} +
+
+
+
+ ) +} diff --git a/components/deadlines/DeadlineList.tsx b/components/deadlines/DeadlineList.tsx index 1bbfeb0c..3a91a960 100644 --- a/components/deadlines/DeadlineList.tsx +++ b/components/deadlines/DeadlineList.tsx @@ -4,6 +4,7 @@ import { useState, useMemo } from 'react' import { Deadline, DeadlineType } from '@/types' import { Button } from '@/components/ui/button' import { DeadlineCard } from './DeadlineCard' +import { DeadlineGroupCard, isSkattekontoDeadline } from './DeadlineGroupCard' import { DeadlineFilters } from './DeadlineFilters' import { DeadlineForm } from './DeadlineForm' import { isDeadlineOverdue } from '@/lib/calendar/utils' @@ -96,6 +97,38 @@ export function DeadlineList({ { key: 'completed', label: 'Klara', items: groupedDeadlines.completed }, ].filter(s => s.items.length > 0) + // Skattekonto obligations legally share the same due date (moms + AGI + + // preliminärskatt all fall on "den 12:e"): collapse 2+ such rows into one + // grouped card per date instead of near-identical rows. Other deadlines + // render as before, in their original order. + type ListEntry = + | { kind: 'single'; deadline: Deadline } + | { kind: 'group'; date: string; items: Deadline[] } + + const toEntries = (items: Deadline[]): ListEntry[] => { + const byDate = new Map() + for (const d of items) { + if (!isSkattekontoDeadline(d)) continue + const group = byDate.get(d.due_date) ?? [] + group.push(d) + byDate.set(d.due_date, group) + } + const emittedDates = new Set() + const entries: ListEntry[] = [] + for (const d of items) { + const group = byDate.get(d.due_date) + if (isSkattekontoDeadline(d) && group && group.length >= 2) { + if (!emittedDates.has(d.due_date)) { + emittedDates.add(d.due_date) + entries.push({ kind: 'group', date: d.due_date, items: group }) + } + continue + } + entries.push({ kind: 'single', deadline: d }) + } + return entries + } + return (
{/* Toolbar */} @@ -159,15 +192,24 @@ export function DeadlineList({
- {items.map((deadline) => ( - - ))} + {toEntries(items).map((entry) => + entry.kind === 'group' ? ( + + ) : ( + + ), + )}
))} diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 09a0e736..857f593a 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -127,10 +127,19 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat // self-billing invoice we received (mottagen självfaktura, ML 17 kap 15§). // Self-billing is never available when editing an existing draft. const [mode, setMode] = useState<'invoice' | 'self_billed'>('invoice') + // Company-wide opt-in from the invoice settings page: the whole payment + // link section (manual field + Stripe auto toggle) stays hidden until the + // company enables it. The send routes enforce the same setting server-side + // (maybeCreatePaymentLinkForInvoice), so this is presentation, not the gate. + const [paymentLinksEnabled, setPaymentLinksEnabled] = useState(false) + // An already-linked invoice keeps showing the section even when the + // setting is off, so the user can still see or clear the old link. + const hasExistingPaymentLink = Boolean(initial?.payment_link_url) // Active Stripe connection: drives the "auto payment link" toggle in the // payment link section. Absent extension or no connection → toggle hidden. const [stripeConnected, setStripeConnected] = useState(false) useEffect(() => { + if (!paymentLinksEnabled) return if (!ENABLED_EXTENSION_IDS.has('stripe')) return let cancelled = false fetch('/api/extensions/ext/stripe/status') @@ -142,7 +151,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat return () => { cancelled = true } - }, []) + }, [paymentLinksEnabled]) const schema = useMemo(() => { const itemSchema = z.object({ @@ -586,7 +595,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat if (!company?.id) return const { data } = await supabase .from('company_settings') - .select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered, dimensions_enabled') + .select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered, dimensions_enabled, invoice_payment_links_enabled') .eq('company_id', company.id) .single() if (data?.invoice_default_notes) { @@ -617,6 +626,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat } // Gates the dimension affordances (header default + per-row override). setDimensionsEnabled(data?.dimensions_enabled === true) + // Gates the payment-link section (opt-in on the invoice settings page). + setPaymentLinksEnabled(data?.invoice_payment_links_enabled === true) } // First-invoice detection (issue #520): captured at page load so the @@ -2199,10 +2210,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat />
- {/* Online payment link (manual MVP): pasted per invoice from - the user's PSP dashboard. Only real invoices: proformas - and delivery notes carry no payment request. */} - {watchDocumentType === 'invoice' && ( + {/* Online payment link: manual paste or the Stripe auto + toggle. Only real invoices: proformas and delivery notes + carry no payment request. Hidden unless the company has + opted in on the invoice settings page, except when the + draft already carries a link (still viewable/clearable). */} + {watchDocumentType === 'invoice' && (paymentLinksEnabled || hasExistingPaymentLink) && (
(null) @@ -156,7 +170,7 @@ function SkatteverketPanelInner({ // one period must never render as if it belonged to another. Without this, // the visibilitychange auto-check could stamp "Deklarationen har lämnats // in" for a different period than the one being signed. - const periodKey = `${periodType}:${year}:${period}` + const periodKey = `${periodType}:${effectiveYear}:${period}:${fiscalPeriodId ?? ''}` const [appliedPeriodKey, setAppliedPeriodKey] = useState(periodKey) if (appliedPeriodKey !== periodKey) { setAppliedPeriodKey(periodKey) @@ -271,7 +285,7 @@ function SkatteverketPanelInner({ const res = await fetch('/api/extensions/ext/skatteverket/declaration/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ periodType, year, period }), + body: JSON.stringify({ periodType, year: effectiveYear, period, fiscalPeriodId }), }) const result = await res.json() if (applyApiError(result)) { @@ -324,7 +338,7 @@ function SkatteverketPanelInner({ const res = await fetch('/api/extensions/ext/skatteverket/declaration/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ periodType, year, period }), + body: JSON.stringify({ periodType, year: effectiveYear, period, fiscalPeriodId }), }) const result = await res.json() if (applyApiError(result)) { @@ -358,8 +372,8 @@ function SkatteverketPanelInner({ }, []) const getRedovisningsperiod = useCallback((): string => { - return formatRedovisningsperiod(periodType, year, period) - }, [periodType, year, period]) + return formatRedovisningsperiod(periodType, year, period, fiscalYearEnd) + }, [periodType, year, period, fiscalYearEnd]) const handleLock = async () => { setActionLoading('lock') @@ -416,7 +430,7 @@ function SkatteverketPanelInner({ const res = await fetch('/api/extensions/ext/skatteverket/declaration/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ periodType, year, period }), + body: JSON.stringify({ periodType, year: effectiveYear, period, fiscalPeriodId }), }) const result = await res.json() @@ -512,7 +526,7 @@ function SkatteverketPanelInner({ const res = await fetch( `/api/extensions/ext/skatteverket/declaration/submitted?redovisare=${encodeURIComponent( await getRedovisare() - )}&redovisningsperiod=${getRedovisningsperiod()}&periodType=${periodType}&year=${year}&period=${period}` + )}&redovisningsperiod=${getRedovisningsperiod()}&periodType=${periodType}&year=${effectiveYear}&period=${period}` ) const result = await res.json() if (applyApiError(result)) { @@ -532,7 +546,7 @@ function SkatteverketPanelInner({ } finally { setActionLoading(null) } - }, [applyApiError, getRedovisare, getRedovisningsperiod, periodType, year, period]) + }, [applyApiError, getRedovisare, getRedovisningsperiod, periodType, effectiveYear, period]) // While a signing link is outstanding, re-check submission status when the // user returns to this tab: signing happens on Skatteverket's site, so the @@ -617,7 +631,7 @@ function SkatteverketPanelInner({ const res = await fetch( `/api/extensions/ext/skatteverket/declaration/decided?redovisare=${encodeURIComponent( await getRedovisare() - )}&redovisningsperiod=${getRedovisningsperiod()}&periodType=${periodType}&year=${year}&period=${period}` + )}&redovisningsperiod=${getRedovisningsperiod()}&periodType=${periodType}&year=${effectiveYear}&period=${period}` ) const result = await res.json() if (applyApiError(result)) { diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index df085981..610987f0 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -1334,8 +1334,11 @@ export function VatDeclarationView() { const [period, setPeriod] = useState(currentQuarter) // Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar // year — picked inline in yearly mode. Monthly/quarterly are calendar - // periods and need no fiscal year. + // periods and need no fiscal year. The period's end date rides along so + // the Skatteverket panel can target the FY-end month (broken fiscal years + // do not end in December). const [fiscalPeriodId, setFiscalPeriodId] = useState('') + const [fiscalPeriodEnd, setFiscalPeriodEnd] = useState(null) // Latest fetch outcome, tagged with the fetch key it was requested under. // loading / error / data are all derived by comparing that tag with the // current key, so the fetch effect never sets state synchronously. @@ -1565,7 +1568,10 @@ export function VatDeclarationView() { // calendar year. setFiscalPeriodId(id || '')} + onChange={(id, fp) => { + setFiscalPeriodId(id || '') + setFiscalPeriodEnd(fp?.period_end ?? null) + }} includeAllOption={false} hideFuturePeriods /> @@ -1913,6 +1919,15 @@ export function VatDeclarationView() { periodType={periodType} year={year} period={period} + fiscalPeriodId={isYearly ? fiscalPeriodId : undefined} + fiscalYearEnd={ + isYearly && fiscalPeriodEnd + ? { + year: Number(fiscalPeriodEnd.slice(0, 4)), + month: Number(fiscalPeriodEnd.slice(5, 7)), + } + : undefined + } hasData={data !== null} localBlocked={checksBlocked} /> diff --git a/components/settings/InvoicePaymentLinkSettings.tsx b/components/settings/InvoicePaymentLinkSettings.tsx new file mode 100644 index 00000000..f91d2981 --- /dev/null +++ b/components/settings/InvoicePaymentLinkSettings.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { useCallback, useState } from 'react' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { useToast } from '@/components/ui/use-toast' +import type { CompanySettings } from '@/types' + +interface InvoicePaymentLinkSettingsProps { + settings: CompanySettings + onUpdate: (updates: Partial) => void +} + +/** + * Opt-in for the invoice payment-link feature. Off (the default) hides the + * payment-link field in the invoice editor and stops automatic Stripe link + * creation on send; the send routes enforce the same setting server-side. + */ +export function InvoicePaymentLinkSettings({ settings, onUpdate }: InvoicePaymentLinkSettingsProps) { + const t = useTranslations('settings_payment_links') + const { toast } = useToast() + const [isSaving, setIsSaving] = useState(false) + + const saveToggle = useCallback(async (value: boolean) => { + setIsSaving(true) + try { + const response = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invoice_payment_links_enabled: value }), + }) + if (!response.ok) throw new Error() + onUpdate({ invoice_payment_links_enabled: value }) + } catch { + toast({ title: t('toast_save_failed'), variant: 'destructive' }) + } finally { + setIsSaving(false) + } + }, [onUpdate, toast, t]) + + return ( +
+

+ {t('heading')} +

+ +
+
+ +

{t('enable_help')}

+
+ +
+
+ ) +} diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index d84bdf4f..67544cf7 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -260,13 +260,20 @@ function SkatteverketPersonalConnectionCard() {

{t('connect_intro')}

- {/* The skahmst consent-page note only matters when the user can - actually reach that page: hidden while the feature is gated. */} + {/* The consent-page notes only matter when the user can actually + reach that page: hidden while the feature is gated. */} {hasSkatteverket && ( -
- {t.rich('skahmst_note', { - code: (chunks) => {chunks}, - })} +
+ {/* Pre-empt the most common broken connect: a behörighet left + unticked on SKV's consent page. Before this note the + "godkänn alla" guidance only appeared AFTER a failed + attempt (missing_scope_message). */} +

{t('connect_approve_all')}

+

+ {t.rich('skahmst_note', { + code: (chunks) => {chunks}, + })} +

)} {!hasSkatteverket && ( diff --git a/components/settings/TaxSettingsForm.tsx b/components/settings/TaxSettingsForm.tsx index a23af95f..dc064202 100644 --- a/components/settings/TaxSettingsForm.tsx +++ b/components/settings/TaxSettingsForm.tsx @@ -12,9 +12,18 @@ interface TaxSettingsFormProps { settings: CompanySettings /** Ledger-derived signal: EU sales postings exist (3108/3308/3107). */ euSalesDetected?: boolean + /** Ledger-derived signal: utdelning/ägarlån postings exist (2898/2393/2893). */ + kuSignalDetected?: boolean + /** Invoice-derived signal: invoices with ROT/RUT deductions exist. */ + rotRutSignalDetected?: boolean } -export function TaxSettingsForm({ settings, euSalesDetected = false }: TaxSettingsFormProps) { +export function TaxSettingsForm({ + settings, + euSalesDetected = false, + kuSignalDetected = false, + rotRutSignalDetected = false, +}: TaxSettingsFormProps) { const t = useTranslations('settings_tax_form') const [vatRegistered, setVatRegistered] = useState(settings.vat_registered ?? false) const [fSkatt, setFSkatt] = useState(settings.f_skatt ?? true) @@ -31,6 +40,15 @@ export function TaxSettingsForm({ settings, euSalesDetected = false }: TaxSettin ) const [hasEuTrade, setHasEuTrade] = useState(settings.vat_has_eu_trade ?? false) const [psEnabled, setPsEnabled] = useState(settings.periodisk_sammanstallning_enabled ?? false) + const [kuEnabled, setKuEnabled] = useState(settings.kontrolluppgifter_enabled ?? false) + const [rotRutEnabled, setRotRutEnabled] = useState(settings.rot_rut_enabled ?? false) + const [ossEnabled, setOssEnabled] = useState(settings.oss_enabled ?? false) + const [iossEnabled, setIossEnabled] = useState(settings.ioss_enabled ?? false) + const [intrastatEnabled, setIntrastatEnabled] = useState(settings.intrastat_enabled ?? false) + const [punktskattEnabled, setPunktskattEnabled] = useState(settings.punktskatt_enabled ?? false) + const [fyllnadEnabled, setFyllnadEnabled] = useState( + settings.fyllnadsinbetalning_enabled ?? false, + ) const isEnskildFirma = settings.entity_type === 'enskild_firma' @@ -422,6 +440,80 @@ export function TaxSettingsForm({ settings, euSalesDetected = false }: TaxSettin )} + {/* Kontrolluppgifter (KU) */} +
+

+ {t('kontrolluppgifter_heading')} +

+ + {kuSignalDetected && !kuEnabled && ( +
+

{t('ku_suggestion_title')}

+

+ {t('ku_suggestion_help')} +

+
+ )} + +
+ setKuEnabled(v === true)} + /> + +
+ +

+ {t('kontrolluppgifter_help')} +

+
+
+
+ + {/* ROT/RUT */} +
+

+ {t('rot_rut_heading')} +

+ + {rotRutSignalDetected && !rotRutEnabled && ( +
+

{t('rot_rut_suggestion_title')}

+

+ {t('rot_rut_suggestion_help')} +

+
+ )} + +
+ setRotRutEnabled(v === true)} + /> + +
+ +

+ {t('rot_rut_help')} +

+
+
+
+ {/* Preliminary tax */}

@@ -443,6 +535,103 @@ export function TaxSettingsForm({ settings, euSalesDetected = false }: TaxSettin

+ + {/* Long-tail deadlines: explicit opt-in only */} +
+

+ {t('more_deadlines_heading')} +

+

+ {t('more_deadlines_help')} +

+ + {vatRegistered && ( + <> +
+ setOssEnabled(v === true)} + /> + +
+ +

{t('oss_help')}

+
+
+ +
+ setIossEnabled(v === true)} + /> + +
+ +

{t('ioss_help')}

+
+
+ +
+ setIntrastatEnabled(v === true)} + /> + +
+ +

{t('intrastat_help')}

+
+
+ + )} + +
+ setPunktskattEnabled(v === true)} + /> + +
+ +

{t('punktskatt_help')}

+
+
+ +
+ setFyllnadEnabled(v === true)} + /> + +
+ +

{t('fyllnadsinbetalning_help')}

+
+
+
) } diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx index 58213173..b90e7e40 100644 --- a/components/settings/sections/InvoicingSettingsContent.tsx +++ b/components/settings/sections/InvoicingSettingsContent.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl' import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm' import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm' +import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings' import { InvoiceEmailTextsSettings } from '@/components/settings/InvoiceEmailTextsSettings' import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard' import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings' @@ -79,6 +80,11 @@ export function InvoicingSettingsContent() {
+ {/* Payment link opt-in: saves individually via toggle switch */} +
+ +
+ {/* PDF settings: saves individually via toggle switches */}
diff --git a/components/settings/sections/TaxSettingsContent.tsx b/components/settings/sections/TaxSettingsContent.tsx index 9466ec0d..4a3d1a4c 100644 --- a/components/settings/sections/TaxSettingsContent.tsx +++ b/components/settings/sections/TaxSettingsContent.tsx @@ -26,6 +26,13 @@ export function TaxSettingsContent() { // sammanställning obligation the opt-in flags may not reflect. Suggestion // only; the user confirms via the ordinary checkboxes. const [euSalesDetected, setEuSalesDetected] = useState(false) + // Same pattern for kontrolluppgifter: postings on 2898 (utdelning) or + // 2393/2893 (ägarlån) imply a KU obligation on 31 January that AGI never + // covers. Suggestion only; the user confirms via the checkbox. + const [kuSignalDetected, setKuSignalDetected] = useState(false) + // And for ROT/RUT: invoices with deductions mean a begäran om utbetalning + // must reach Skatteverket by 31 January after the payment year. + const [rotRutSignalDetected, setRotRutSignalDetected] = useState(false) useEffect(() => { let cancelled = false fetch('/api/settings/eu-trade-signal') @@ -36,6 +43,22 @@ export function TaxSettingsContent() { .catch(() => { // Best-effort signal: a failed fetch just hides the suggestion. }) + fetch('/api/settings/ku-signal') + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { + if (!cancelled && json?.data?.has_ku_signal) setKuSignalDetected(true) + }) + .catch(() => { + // Best-effort signal: a failed fetch just hides the suggestion. + }) + fetch('/api/settings/rot-rut-signal') + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { + if (!cancelled && json?.data?.has_rot_rut) setRotRutSignalDetected(true) + }) + .catch(() => { + // Best-effort signal: a failed fetch just hides the suggestion. + }) return () => { cancelled = true } @@ -105,6 +128,14 @@ export function TaxSettingsContent() { // means false rather than "keep stored value". employer_seasonal: employerRegistered && formData.get('employer_seasonal') === 'true', preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null, + kontrolluppgifter_enabled: formData.get('kontrolluppgifter_enabled') === 'true', + rot_rut_enabled: formData.get('rot_rut_enabled') === 'true', + // OSS/IOSS/Intrastat presuppose VAT registration; retire them with it. + oss_enabled: vatRegistered && formData.get('oss_enabled') === 'true', + ioss_enabled: vatRegistered && formData.get('ioss_enabled') === 'true', + intrastat_enabled: vatRegistered && formData.get('intrastat_enabled') === 'true', + punktskatt_enabled: formData.get('punktskatt_enabled') === 'true', + fyllnadsinbetalning_enabled: formData.get('fyllnadsinbetalning_enabled') === 'true', } return { updates, @@ -127,7 +158,12 @@ export function TaxSettingsContent() { {showSkatteverket && } - +
) diff --git a/extensions/general/cloud-backup/components/CloudBackupCard.tsx b/extensions/general/cloud-backup/components/CloudBackupCard.tsx index 57c8501a..c6c6522f 100644 --- a/extensions/general/cloud-backup/components/CloudBackupCard.tsx +++ b/extensions/general/cloud-backup/components/CloudBackupCard.tsx @@ -242,6 +242,9 @@ export default function CloudBackupCard() {

{t('ext_cloud_backup_card_tagline')}

+

+ {t('ext_cloud_backup_legal_note')} +

diff --git a/extensions/general/cloud-backup/lib/sync.ts b/extensions/general/cloud-backup/lib/sync.ts index 80d55b46..a8a8fa93 100644 --- a/extensions/general/cloud-backup/lib/sync.ts +++ b/extensions/general/cloud-backup/lib/sync.ts @@ -49,7 +49,7 @@ export const ROOT_FOLDER_NAME = 'gnubok' */ export const SIZE_LIMIT_BYTES = 300 * 1024 * 1024 /** Bump to force a re-upload of every file when the archive format changes. */ -export const ARCHIVE_FORMAT_VERSION = 2 +export const ARCHIVE_FORMAT_VERSION = 3 export type SyncFailureReason = | 'not_connected' diff --git a/extensions/general/skatteverket/__tests__/connection-expired-notification.test.ts b/extensions/general/skatteverket/__tests__/connection-expired-notification.test.ts new file mode 100644 index 00000000..6648de3d --- /dev/null +++ b/extensions/general/skatteverket/__tests__/connection-expired-notification.test.ts @@ -0,0 +1,218 @@ +/** + * Connection-expired email: the atomic claim-then-send dedup. + * + * One email per consent episode: the reference key is derived from + * (userId, token.created_at), claimed through notification_log under type + * 'skv_connection_expired' (partial unique index from migration + * 20260720090000). A re-observed expiry of the same token never notifies + * twice; a reconnect (new token row, new created_at) resets the episode. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createHash } from 'crypto' +import type { SupabaseClient } from '@supabase/supabase-js' + +const mockIsConfigured = vi.fn() +const mockSendEmail = vi.fn() +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }), +})) + +import { sendConnectionExpiredNotification } from '../lib/connection-expired-notification' + +interface RecordedOp { + table: string + op: 'select' | 'insert' | 'delete' + payload?: Record + filters: Record +} + +const TOKEN_CREATED_AT = '2026-06-30T13:09:50.729013+00:00' + +function expectedReferenceUuid(userId: string, createdAt: string): string { + const hex = createHash('sha256') + .update(`skv-connection-expired|${userId}|${createdAt}`) + .digest('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` +} + +/** + * Hand-rolled mock (instead of createQueuedMockSupabase) because the + * assertions need the recorded operation ORDER and payloads: claim insert + * before send, delete filters on release. + */ +function makeSupabase(opts: { + token?: { created_at: string } | null + alreadyRow?: { id: string } | null + member?: Record | null + insertError?: { code?: string; message: string } | null +} = {}) { + const ops: RecordedOp[] = [] + const from = (table: string) => { + const call: RecordedOp = { table, op: 'select', filters: {} } + ops.push(call) + const builder: Record = {} + Object.assign(builder, { + select: () => builder, + insert: (payload: Record) => { + call.op = 'insert' + call.payload = payload + return Promise.resolve({ data: null, error: opts.insertError ?? null }) + }, + delete: () => { + call.op = 'delete' + return builder + }, + eq: (key: string, value: unknown) => { + call.filters[key] = value + return builder + }, + maybeSingle: async () => { + if (table === 'skatteverket_tokens') { + const token = + opts.token === undefined ? { created_at: TOKEN_CREATED_AT } : opts.token + return { data: token, error: null } + } + if (table === 'notification_log') return { data: opts.alreadyRow ?? null, error: null } + if (table === 'company_members') { + const member = + opts.member === undefined + ? { user_id: 'user-1', profiles: { email: 'user@example.com' } } + : opts.member + return { data: member, error: null } + } + return { data: null, error: null } + }, + then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }), + }) + return builder + } + return { supabase: { from } as unknown as SupabaseClient, ops } +} + +const baseInput = { companyId: 'company-1', userId: 'user-1' } + +beforeEach(() => { + vi.clearAllMocks() + mockIsConfigured.mockReturnValue(true) + mockSendEmail.mockResolvedValue({ success: true }) +}) + +describe('sendConnectionExpiredNotification', () => { + it('claims the notification_log row BEFORE sending the email', async () => { + const { supabase, ops } = makeSupabase() + let claimsAtSendTime = -1 + mockSendEmail.mockImplementation(async () => { + claimsAtSendTime = ops.filter((o) => o.op === 'insert').length + return { success: true } + }) + + const result = await sendConnectionExpiredNotification(supabase, baseInput) + + expect(result).toEqual({ sent: true }) + expect(claimsAtSendTime).toBe(1) + const insert = ops.find((o) => o.op === 'insert') + expect(insert?.table).toBe('notification_log') + expect(insert?.payload).toMatchObject({ + user_id: 'user-1', + company_id: 'company-1', + notification_type: 'skv_connection_expired', + reference_id: expectedReferenceUuid('user-1', TOKEN_CREATED_AT), + delivery_status: 'sent', + }) + }) + + it('sends the reconnect instructions to the token owner', async () => { + const { supabase } = makeSupabase() + + await sendConnectionExpiredNotification(supabase, baseInput) + + expect(mockSendEmail).toHaveBeenCalledTimes(1) + const mail = mockSendEmail.mock.calls[0][0] as { + to: string + subject: string + text: string + } + expect(mail.to).toBe('user@example.com') + expect(mail.subject).toContain('Skatteverket') + expect(mail.text).toContain('BankID') + expect(mail.text).toContain('alla behörigheter') + expect(mail.text).toContain('/settings/tax') + }) + + it('skips via the fast path when the episode was already notified', async () => { + const { supabase } = makeSupabase({ alreadyRow: { id: 'log-1' } }) + + const result = await sendConnectionExpiredNotification(supabase, baseInput) + + expect(result).toEqual({ sent: false, reason: 'duplicate' }) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('treats a 23505 unique violation on the claim as a duplicate', async () => { + const { supabase } = makeSupabase({ + insertError: { code: '23505', message: 'duplicate key value' }, + }) + + const result = await sendConnectionExpiredNotification(supabase, baseInput) + + expect(result).toEqual({ sent: false, reason: 'duplicate' }) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('skips the send when the claim insert fails for another reason', async () => { + const { supabase } = makeSupabase({ + insertError: { code: '57014', message: 'canceling statement' }, + }) + + const result = await sendConnectionExpiredNotification(supabase, baseInput) + + expect(result).toEqual({ sent: false, reason: 'claim_failed' }) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('does nothing when the email service is not configured', async () => { + mockIsConfigured.mockReturnValue(false) + const { supabase, ops } = makeSupabase() + + const result = await sendConnectionExpiredNotification(supabase, baseInput) + + expect(result).toEqual({ sent: false, reason: 'email_not_configured' }) + expect(ops).toHaveLength(0) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('skips when no token row exists (already disconnected)', async () => { + const { supabase } = makeSupabase({ token: null }) + + const result = await sendConnectionExpiredNotification(supabase, baseInput) + + expect(result).toEqual({ sent: false, reason: 'no_token' }) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('skips without claiming when the token owner is no longer a member', async () => { + const { supabase, ops } = makeSupabase({ member: null }) + + const result = await sendConnectionExpiredNotification(supabase, baseInput) + + expect(result).toEqual({ sent: false, reason: 'no_recipient' }) + expect(ops.find((o) => o.op === 'insert')).toBeUndefined() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('releases the claim when the email send reports failure', async () => { + mockSendEmail.mockResolvedValue({ success: false, error: 'smtp down' }) + const { supabase, ops } = makeSupabase() + + const result = await sendConnectionExpiredNotification(supabase, baseInput) + + expect(result).toEqual({ sent: false, reason: 'send_failed' }) + const del = ops.find((o) => o.op === 'delete') + expect(del?.table).toBe('notification_log') + expect(del?.filters).toMatchObject({ + user_id: 'user-1', + notification_type: 'skv_connection_expired', + reference_id: expectedReferenceUuid('user-1', TOKEN_CREATED_AT), + }) + }) +}) diff --git a/extensions/general/skatteverket/__tests__/declaration-prep.test.ts b/extensions/general/skatteverket/__tests__/declaration-prep.test.ts index b8439fa7..ea355992 100644 --- a/extensions/general/skatteverket/__tests__/declaration-prep.test.ts +++ b/extensions/general/skatteverket/__tests__/declaration-prep.test.ts @@ -10,8 +10,10 @@ import { createQueuedMockSupabase } from '@/tests/helpers' import type { VatDeclarationRutor } from '@/types' const mockCalculateVatDeclaration = vi.fn() +const mockResolvePeriodDates = vi.fn() vi.mock('@/lib/reports/vat-declaration', () => ({ calculateVatDeclaration: (...a: unknown[]) => mockCalculateVatDeclaration(...a), + resolvePeriodDates: (...a: unknown[]) => mockResolvePeriodDates(...a), })) import { buildMomsuppgift, buildAgiUnderlag, resolveRedovisare } from '../lib/declaration-prep' @@ -66,7 +68,47 @@ describe('buildMomsuppgift', () => { expect(result.momsuppgift.momsForsaljningUtgaendeHog).toBe(250) expect(result.momsuppgift.ingaendeMomsAvdrag).toBe(100) expect(result.momsuppgift.summaMoms).toBe(150) - expect(mockCalculateVatDeclaration).toHaveBeenCalledWith(expect.anything(), 'company-1', 'monthly', 2025, 3) + expect(mockCalculateVatDeclaration).toHaveBeenCalledWith( + expect.anything(), 'company-1', 'monthly', 2025, 3, 'accrual', { fiscalPeriodId: undefined }, + ) + // Sub-annual periods are calendar periods: no fiscal-period lookup. + expect(mockResolvePeriodDates).not.toHaveBeenCalled() + }) + + it('targets the FY-end month for a broken-FY yearly filer (SFL 26 kap 10-11 §§)', async () => { + mockCalculateVatDeclaration.mockResolvedValue({ rutor: zeroRutor() }) + // Räkenskapsår 2025-07-01 → 2026-06-30: redovisningsperiod is 202606, not 202612. + mockResolvePeriodDates.mockResolvedValue({ start: '2025-07-01', end: '2026-06-30' }) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } }) // resolveRedovisare + + const result = await buildMomsuppgift(supabase as never, 'company-1', { + periodType: 'yearly', year: 2026, period: 1, fiscalPeriodId: 'fp-1', + }) + + expect(result.redovisningsperiod).toBe('202606') + expect(mockResolvePeriodDates).toHaveBeenCalledWith( + expect.anything(), 'company-1', 'yearly', 2026, 1, 'fp-1', + ) + // The figures must describe the same räkenskapsår as the period id. + expect(mockCalculateVatDeclaration).toHaveBeenCalledWith( + expect.anything(), 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'fp-1' }, + ) + }) + + it('keeps the calendar-year fallback for yearly without a fiscal period', async () => { + mockCalculateVatDeclaration.mockResolvedValue({ rutor: zeroRutor() }) + mockResolvePeriodDates.mockResolvedValue({ start: '2025-01-01', end: '2025-12-31' }) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } }) + + const result = await buildMomsuppgift(supabase as never, 'company-1', { + periodType: 'yearly', year: 2025, period: 1, + }) + + expect(result.redovisningsperiod).toBe('202512') }) }) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 1a691481..c2417e75 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -47,6 +47,7 @@ import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYN import { runPostConnectRefresh } from './lib/post-connect-refresh' import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking' import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email' +import { handleSkattekontoConnectionExpired } from './lib/connection-expired-notification' import { findMatchCandidates, findMatchSuggestionsBulk, @@ -957,8 +958,9 @@ export const skatteverketExtension: Extension = { periodType?: VatPeriodType year?: number period?: number + fiscalPeriodId?: string } - const { periodType, year, period } = body + const { periodType, year, period, fiscalPeriodId } = body if (!periodType || !year || !period) { return NextResponse.json( { error: 'Saknar obligatoriska fält: periodType, year, period' }, @@ -968,7 +970,7 @@ export const skatteverketExtension: Extension = { const result = await submitVatDeclarationChain( ctx, - { periodType, year, period }, + { periodType, year, period, fiscalPeriodId }, { validate: true } ) @@ -2213,6 +2215,10 @@ export const skatteverketExtension: Extension = { eventType: 'skattekonto.drift_detected', handler: handleSkattekontoDriftDetected, }, + { + eventType: 'skattekonto.connection.expired', + handler: handleSkattekontoConnectionExpired, + }, ], // Registry-resolved commit services for the MCP submit tools. The core @@ -2241,17 +2247,18 @@ async function parseDeclarationRequest( ctx: ExtensionContext ): Promise { const body = await request.json() - const { periodType, year, period } = body as { + const { periodType, year, period, fiscalPeriodId } = body as { periodType: VatPeriodType year: number period: number + fiscalPeriodId?: string } if (!periodType || !year || !period) { throw new Error('Saknar obligatoriska fält: periodType, year, period') } - return buildMomsuppgift(ctx.supabase, ctx.companyId, { periodType, year, period }) + return buildMomsuppgift(ctx.supabase, ctx.companyId, { periodType, year, period, fiscalPeriodId }) } /** @@ -2456,12 +2463,13 @@ async function commitSubmitVatDeclaration( const periodType = params.period_type as VatPeriodType const year = params.year as number const period = params.period as number + const fiscalPeriodId = params.fiscal_period_id as string | undefined const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket') try { // The staged figures were already reviewed at approval time, so the // chain starts at the utkast write (no kontrollera pre-step here). - const result = await submitVatDeclarationChain(ctx, { periodType, year, period }) + const result = await submitVatDeclarationChain(ctx, { periodType, year, period, fiscalPeriodId }) if (!result.ok) { return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: result.httpStatus, diff --git a/extensions/general/skatteverket/lib/connection-expired-notification.ts b/extensions/general/skatteverket/lib/connection-expired-notification.ts new file mode 100644 index 00000000..ebdd9cd7 --- /dev/null +++ b/extensions/general/skatteverket/lib/connection-expired-notification.ts @@ -0,0 +1,243 @@ +/** + * Connection-expired notification (email). + * + * Handler for `skattekonto.connection.expired`: emitted by syncSkattekonto + * when a terminal auth error (REFRESH_EXHAUSTED / SESSION_EXPIRED / + * TOKEN_CORRUPTED) kills the personal SKV token. Before this handler the + * event was emitted but unconsumed: the needs_reconsent state was only + * visible on the settings panel, which users have no reason to revisit, so + * a dead connection silently meant "skattekonto never syncs again". Prod + * accumulated ~70 companies in exactly that state. + * + * Email is the delivery channel (same rationale as kvittens-notification: + * push-notifications is a separate, optional extension and cross-extension + * imports are not allowed). The recipient is the token OWNER: only they can + * redo the BankID consent, so a company contact address would be actionable + * for the wrong person. The owner must still be an active member of the + * company. + * + * Deduplication: one email per consent episode. The reference key is derived + * from (userId, token.created_at): a reconnect creates a new token row with + * a new created_at, so a later expiry notifies again, while the nightly cron + * re-observing the same dead token stays silent. Claim-first through + * notification_log under type 'skv_connection_expired', made atomic by the + * partial unique index in migration 20260720090000 (mirrors the kvittens + * pattern). + * + * Best-effort by contract: a notification failure must never fail the sync + * (or cron) that observed the expiry. The body carries no financial data. + */ +import { createHash } from 'crypto' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { EventPayload } from '@/lib/events/types' +import { createServiceClient } from '@/lib/supabase/server' +import { getEmailService } from '@/lib/email/service' +import { createLogger } from '@/lib/logger' + +const log = createLogger('skv-connection-expired-notification') + +export async function handleSkattekontoConnectionExpired( + payload: EventPayload<'skattekonto.connection.expired'>, +): Promise { + // Service-role client, NOT the registry-built ctx: the primary emitter is + // the nightly cron, whose request carries no user cookies, so the ctx the + // registry lazily builds there is an anonymous client that RLS turns into + // "no token, no member, nothing to do". Same rationale as the + // document-extraction handler. + const supabase = createServiceClient() + await sendConnectionExpiredNotification(supabase, { + companyId: payload.companyId, + userId: payload.userId, + }) +} + +export interface ConnectionExpiredInput { + companyId: string + /** The token-owning user: the only person who can redo the BankID consent. */ + userId: string +} + +export async function sendConnectionExpiredNotification( + supabase: SupabaseClient, + input: ConnectionExpiredInput, +): Promise<{ sent: boolean; reason?: string }> { + try { + const email = getEmailService() + if (!email.isConfigured()) return { sent: false, reason: 'email_not_configured' } + + // Episode key: the token row's created_at. No token row means nothing to + // reconnect (already disconnected): skip. + const { data: token } = await supabase + .from('skatteverket_tokens') + .select('created_at') + .eq('user_id', input.userId) + .maybeSingle() + const tokenCreatedAt = (token as { created_at?: string | null } | null)?.created_at + if (!tokenCreatedAt) return { sent: false, reason: 'no_token' } + + const referenceUuid = toReferenceUuid( + `skv-connection-expired|${input.userId}|${tokenCreatedAt}`, + ) + + // Dedup fast path: cheap read that skips the work below on re-observed + // expiries. NOT the enforcement: the claim insert further down is. + const { data: already } = await supabase + .from('notification_log') + .select('id') + .eq('user_id', input.userId) + .eq('notification_type', 'skv_connection_expired') + .eq('reference_id', referenceUuid) + .maybeSingle() + if (already) return { sent: false, reason: 'duplicate' } + + const recipient = await resolveMemberEmail(supabase, input.companyId, input.userId) + if (!recipient) { + log.info('no authorised recipient for connection-expired email', { + companyId: input.companyId, + }) + return { sent: false, reason: 'no_recipient' } + } + + // Claim before sending: the partial unique index on notification_log + // (user_id, reference_id) where notification_type = + // 'skv_connection_expired' makes this atomic. Of two overlapping + // emitters exactly one wins the insert; the loser gets a unique + // violation and skips the send. + const { error: claimError } = await supabase.from('notification_log').insert({ + user_id: input.userId, + company_id: input.companyId, + notification_type: 'skv_connection_expired', + reference_id: referenceUuid, + days_before: 0, + delivery_status: 'sent', + }) + if (claimError) { + if (claimError.code === '23505') return { sent: false, reason: 'duplicate' } + // Without a claim we cannot guarantee single delivery: skip the send + // (fail closed on the never-twice guarantee) and let a later + // observation retry. + log.warn('connection-expired claim insert failed', { + companyId: input.companyId, + error: claimError.message, + }) + return { sent: false, reason: 'claim_failed' } + } + + const appUrl = (process.env.NEXT_PUBLIC_APP_URL || 'https://gnubok.se').replace(/\/$/, '') + const settingsLink = `${appUrl}/settings/tax` + + const subject = 'Anslutningen till Skatteverket behöver förnyas' + const text = [ + 'Din anslutning till Skatteverket har gått ut. Hämtningen av skattekontots saldo och transaktioner är pausad tills du ansluter igen.', + '', + 'Så här återansluter du:', + `1. Gå till Inställningar > Skatt i Accounted: ${settingsLink}`, + '2. Klicka på "Anslut igen" och legitimera dig med BankID.', + '3. Godkänn alla behörigheter på Skatteverkets samtyckessida.', + '', + 'När du anslutit hämtas skattekontot direkt.', + ].join('\n') + const html = [ + '

Din anslutning till Skatteverket har gått ut. Hämtningen av skattekontots saldo och transaktioner är pausad tills du ansluter igen.

', + '

Så här återansluter du:

', + '
    ', + `
  1. Gå till Inställningar > Skatt i Accounted.
  2. `, + '
  3. Klicka på "Anslut igen" och legitimera dig med BankID.
  4. ', + '
  5. Godkänn alla behörigheter på Skatteverkets samtyckessida.
  6. ', + '
', + '

När du anslutit hämtas skattekontot direkt.

', + ].join('') + + let result: Awaited> + try { + result = await email.sendEmail({ to: recipient, subject, text, html }) + } catch (sendErr) { + // Release the claim so a later observation can retry the send. + await releaseClaim(supabase, input.userId, referenceUuid) + throw sendErr + } + if (!result.success) { + log.warn('connection-expired email send failed', { + companyId: input.companyId, + error: result.error, + }) + await releaseClaim(supabase, input.userId, referenceUuid) + return { sent: false, reason: 'send_failed' } + } + + return { sent: true } + } catch (err) { + log.warn('connection-expired notification failed', { + companyId: input.companyId, + error: err instanceof Error ? err.message : String(err), + }) + return { sent: false, reason: 'error' } + } +} + +/** + * notification_log.reference_id is a uuid column; the episode key is a + * composite string. Map it to a deterministic uuid-shaped SHA-256 digest so + * the same episode always resolves to the same claim row (same approach as + * kvittens-notification). + */ +function toReferenceUuid(referenceKey: string): string { + const hex = createHash('sha256').update(referenceKey).digest('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` +} + +/** Remove a claim whose email never went out, so a later run can retry. */ +async function releaseClaim( + supabase: SupabaseClient, + userId: string, + referenceUuid: string, +): Promise { + try { + await supabase + .from('notification_log') + .delete() + .eq('user_id', userId) + .eq('notification_type', 'skv_connection_expired') + .eq('reference_id', referenceUuid) + } catch (err) { + // A stuck claim only suppresses a retry of this one email: log and move on. + log.warn('failed to release connection-expired claim', { + userId, + referenceUuid, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +/** + * The recipient must still be an active member of the company (mirrors the + * kvittens rule): a token owner who has since been removed from the company + * must not receive connection nudges for it. + */ +async function resolveMemberEmail( + supabase: SupabaseClient, + companyId: string, + userId: string, +): Promise { + const { data: member } = await supabase + .from('company_members') + .select('user_id, profiles!inner(email)') + .eq('company_id', companyId) + .eq('user_id', userId) + .maybeSingle() + if (!member) return null + + type ProfileRef = { email?: string | null } | { email?: string | null }[] | null + const profiles = (member as { profiles: ProfileRef }).profiles + const profile = Array.isArray(profiles) ? profiles[0] : profiles + return profile?.email ?? null +} + +function escapeHtml(input: string): string { + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/extensions/general/skatteverket/lib/declaration-prep.ts b/extensions/general/skatteverket/lib/declaration-prep.ts index 7296c0d2..98b1f315 100644 --- a/extensions/general/skatteverket/lib/declaration-prep.ts +++ b/extensions/general/skatteverket/lib/declaration-prep.ts @@ -1,6 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { VatPeriodType } from '@/types' -import { calculateVatDeclaration } from '@/lib/reports/vat-declaration' +import { calculateVatDeclaration, resolvePeriodDates } from '@/lib/reports/vat-declaration' import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './mappers' import type { SkatteverketMomsuppgift } from '../types' @@ -63,12 +63,24 @@ export async function resolveRedovisare( export async function buildMomsuppgift( supabase: SupabaseClient, companyId: string, - input: { periodType: VatPeriodType; year: number; period: number }, + input: { periodType: VatPeriodType; year: number; period: number; fiscalPeriodId?: string }, ): Promise { - const { periodType, year, period } = input + const { periodType, year, period, fiscalPeriodId } = input const redovisare = await resolveRedovisare(supabase, companyId) - const redovisningsperiod = formatRedovisningsperiod(periodType, year, period) + + // Helårsmoms is filed per räkenskapsår (SFL 26 kap 10-11 §§): the SKV + // redovisningsperiod is the FY-end month, which for a broken fiscal year is + // not December. Resolve the fiscal period's actual bounds so the period + // identifier and the figures below always describe the same räkenskapsår. + let fiscalYearEnd: { year: number; month: number } | undefined + if (periodType === 'yearly') { + const { end } = await resolvePeriodDates( + supabase, companyId, periodType, year, period, fiscalPeriodId, + ) + fiscalYearEnd = { year: Number(end.slice(0, 4)), month: Number(end.slice(5, 7)) } + } + const redovisningsperiod = formatRedovisningsperiod(periodType, year, period, fiscalYearEnd) // Calculate VAT declaration from the general ledger const declaration = await calculateVatDeclaration( @@ -77,6 +89,8 @@ export async function buildMomsuppgift( periodType, year, period, + 'accrual', + { fiscalPeriodId }, ) const momsuppgift = rutorToMomsuppgift(declaration.rutor) diff --git a/extensions/general/skatteverket/lib/vat-submit.ts b/extensions/general/skatteverket/lib/vat-submit.ts index 7cb41194..dc29d190 100644 --- a/extensions/general/skatteverket/lib/vat-submit.ts +++ b/extensions/general/skatteverket/lib/vat-submit.ts @@ -28,6 +28,8 @@ export interface VatSubmitChainParams { periodType: VatPeriodType year: number period: number + /** Räkenskapsår for helårsmoms: broken FYs end in their own month, not December. */ + fiscalPeriodId?: string } export type VatSubmitChainResult = diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f1657b91..a778b423 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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). diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts index 0054ffe4..e2898afd 100644 --- a/lib/errors/__tests__/get-error-message.test.ts +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -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' diff --git a/lib/errors/__tests__/structured-errors.test.ts b/lib/errors/__tests__/structured-errors.test.ts index a8b2ce9a..0cc291ac 100644 --- a/lib/errors/__tests__/structured-errors.test.ts +++ b/lib/errors/__tests__/structured-errors.test.ts @@ -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) }) }) diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index 0b34f597..92d3981c 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -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?, ... } } diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index ba96b016..dada78fe 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -199,6 +199,14 @@ const BOOKKEEPING: Record = { 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 = { '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 = { 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 = { @@ -2837,6 +2863,30 @@ const DIMENSION: Record = { }, } +// ───────────────────────────────────────────────────────────────── +// 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 = { + 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 = { ...BOLAGSVERKET, ...ASSETS, ...DIMENSION, + ...NODE_SYSTEM, } export function getErrorEntry(code: string): StructuredErrorEntry | undefined { diff --git a/lib/extensions/__tests__/payment-links.test.ts b/lib/extensions/__tests__/payment-links.test.ts index 521109ad..374d8e60 100644 --- a/lib/extensions/__tests__/payment-links.test.ts +++ b/lib/extensions/__tests__/payment-links.test.ts @@ -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 { 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), log, + mockClient().client, 'company-1', 'user-1', + eligibleInvoice(overrides as Partial), 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() }) diff --git a/lib/extensions/payment-links.ts b/lib/extensions/payment-links.ts index 5d12af05..4638d0c6 100644 --- a/lib/extensions/payment-links.ts +++ b/lib/extensions/payment-links.ts @@ -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![CREATE_INVOICE_PAYMENT_LINK_SERVICE]( supabase, diff --git a/lib/invoices/issue-credit-note.ts b/lib/invoices/issue-credit-note.ts index 4f476b11..504d4007 100644 --- a/lib/invoices/issue-credit-note.ts +++ b/lib/invoices/issue-credit-note.ts @@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events' import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service' +import { getErrorMessage } from '@/lib/errors/get-error-message' import type { Logger } from '@/lib/logger' import type { AccountingMethod, CreditNote, EntityType } from '@/types' @@ -39,8 +40,15 @@ export interface IssueCreditNoteResult { failures: CreditNoteIssueFailure[] } -function errorMessage(error: unknown): 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 0) { @@ -252,7 +260,7 @@ export async function issueCreditNote(input: IssueCreditNoteInput): Promise ({ 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', () => { diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index dbdb068a..6796076a 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }> function makeBuilder() { const b: Record = {} - 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([]) diff --git a/lib/reports/archive-readme.ts b/lib/reports/archive-readme.ts index e000f1a0..3474af39 100644 --- a/lib/reports/archive-readme.ts +++ b/lib/reports/archive-readme.ts @@ -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.' ) } diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 3c57f1c7..ec4bf478 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -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 { + 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() + 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(({ 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, diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 2996e240..a9d16a84 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -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) diff --git a/lib/skatteverket/__tests__/format.test.ts b/lib/skatteverket/__tests__/format.test.ts new file mode 100644 index 00000000..443d4b24 --- /dev/null +++ b/lib/skatteverket/__tests__/format.test.ts @@ -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') + }) +}) diff --git a/lib/skatteverket/format.ts b/lib/skatteverket/format.ts index ebdbdeda..9dbe6260 100644 --- a/lib/skatteverket/format.ts +++ b/lib/skatteverket/format.ts @@ -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 } diff --git a/lib/tax/__tests__/deadline-config.test.ts b/lib/tax/__tests__/deadline-config.test.ts index cc0df6b8..0e3d439d 100644 --- a/lib/tax/__tests__/deadline-config.test.ts +++ b/lib/tax/__tests__/deadline-config.test.ts @@ -23,6 +23,13 @@ function makeSettings(overrides: Partial = {}): 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') diff --git a/lib/tax/__tests__/deadline-generator.test.ts b/lib/tax/__tests__/deadline-generator.test.ts index d8992631..423aaf1b 100644 --- a/lib/tax/__tests__/deadline-generator.test.ts +++ b/lib/tax/__tests__/deadline-generator.test.ts @@ -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> | null = null @@ -44,6 +65,7 @@ function makeRecordingSupabase(opts: { const from = vi.fn(() => { const chain: Record> = {} let isDelete = false + let isInProgressQuery = false const self = () => chain chain.insert = vi.fn((rows: Array>) => { 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', () => { diff --git a/lib/tax/deadline-config.ts b/lib/tax/deadline-config.ts index 5017b57d..3da9973e 100644 --- a/lib/tax/deadline-config.ts +++ b/lib/tax/deadline-config.ts @@ -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', diff --git a/lib/tax/deadline-generator.ts b/lib/tax/deadline-generator.ts index 6c72f56b..a457fc6f 100644 --- a/lib/tax/deadline-generator.ts +++ b/lib/tax/deadline-generator.ts @@ -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([ + '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() 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))) } } diff --git a/messages/en.json b/messages/en.json index 2b0564c1..5c04dac7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -523,7 +523,13 @@ "generate_none_title": "No deadlines created", "generate_none_description": "No new deadlines to create. Check that your tax settings are filled in if you expected more.", "generate_failed_title": "Could not create deadlines", - "generate_failed_description": "Tax deadlines could not be created right now." + "generate_failed_description": "Tax deadlines could not be created right now.", + "group_skattekonto_title": "Skattekonto", + "group_same_day": "{count} deadlines on the same day", + "group_mark_done": "Mark as done", + "group_confirm_question": "Mark {title} as done?", + "group_cancel": "Cancel", + "group_confirm": "Confirm" }, "bureau": { "title": "Bureau", @@ -1557,6 +1563,12 @@ "reminder_days_level_2": "Second reminder (days)", "reminder_days_level_3": "Final reminder (days)" }, + "settings_payment_links": { + "heading": "Payment link", + "enable_label": "Payment link on invoices", + "enable_help": "Shows the payment link field in the invoice editor. With Stripe connected, a payment link is created automatically when the invoice is sent.", + "toast_save_failed": "Could not save" + }, "settings_pdf_print": { "coming_soon": "Coming soon", "heading": "Print & PDF", @@ -1787,9 +1799,31 @@ "eu_trade_suggestion_help": "There are booked EU sales (accounts 3108, 3308 or 3107) in the last 15 months, but EU trade or the EU sales list is not enabled below. The EU sales list (periodisk sammanställning) must be filed monthly or quarterly when selling to businesses in other EU countries; the late fee is 1,250 kr per report.", "employer_seasonal_label": "Seasonal employer (säsongsregistrerad)", "employer_seasonal_help": "Files the employer declaration only for months with salary payments, plus December if no salary was paid during the year.", + "kontrolluppgifter_heading": "Income statements (kontrolluppgifter)", + "kontrolluppgifter_label": "Remind about income statements (KU)", + "kontrolluppgifter_help": "Creates an annual deadline on 31 January for income statements (KU10, KU20, KU31). Covers among other things dividends to owners and interest on owner loans, which are never included in the employer declaration.", + "ku_suggestion_title": "Your bookkeeping suggests income statements (KU) are due", + "ku_suggestion_help": "There are booked dividends or owner loans (accounts 2898, 2393 or 2893) in the last 15 months. Dividends and interest to owners are reported with a kontrolluppgift by 31 January; they are not part of the employer declaration. The late fee is 500 kr per statement. Enable the reminder below if this applies to your company.", + "rot_rut_heading": "ROT & RUT", + "rot_rut_label": "Remind about the ROT/RUT payout request", + "rot_rut_help": "Creates a deadline on 31 January for the ROT/RUT payout request (begäran om utbetalning). The reminder is only created for years with paid ROT/RUT invoices. Missing the date forfeits the Skatteverket payout.", + "rot_rut_suggestion_title": "There are invoices with ROT/RUT deductions", + "rot_rut_suggestion_help": "The ROT/RUT payout request must reach Skatteverket by 31 January the year after the customer paid (lag 2009:194). Missing the date forfeits the entire payout. Enable the reminder below to have the date tracked automatically.", "preliminary_tax_heading": "Preliminary tax", "preliminary_tax_monthly_label": "Monthly preliminary tax (F-skatt)", - "preliminary_tax_monthly_help": "Monthly amount per Skatteverket's debited preliminary tax decision. Payment reminders are only created when an amount is set. Leave empty if no preliminary tax is debited." + "preliminary_tax_monthly_help": "Monthly amount per Skatteverket's debited preliminary tax decision. Payment reminders are only created when an amount is set. Leave empty if no preliminary tax is debited.", + "more_deadlines_heading": "More deadlines", + "more_deadlines_help": "Deadlines that only apply to some companies. Enable the ones that apply to you and the dates are tracked automatically.", + "oss_label": "OSS declaration (quarterly)", + "oss_help": "For EU B2C sales above the EUR 10,000 threshold. Due the last day of the month after the quarter; the date does not move for weekends.", + "ioss_label": "IOSS declaration (monthly)", + "ioss_help": "For distance sales of imported goods (import scheme). Due the last day of the following month; the date does not move for weekends.", + "intrastat_label": "Intrastat (monthly)", + "intrastat_help": "SCB's intra-EU trade in goods report, for companies above the thresholds. Due around the tenth working day of the following month. Check SCB's calendar for exact dates.", + "punktskatt_label": "Excise duty declaration (monthly)", + "punktskatt_help": "For companies liable for excise duties (e.g. alcohol, tobacco, energy). Follows the ordinary tax declaration dates.", + "fyllnadsinbetalning_label": "Extra preliminary tax payment (fyllnadsinbetalning)", + "fyllnadsinbetalning_help": "Extra preliminary tax payment to avoid interest on back taxes. Amounts over 30,000 kr by the 12th of the second month after the fiscal year, the rest by the 3rd of the fifth month." }, "settings_backup_download": { "create_backup_title": "Create backup", @@ -1973,6 +2007,7 @@ "disabled_message": "The Skatteverket integration is temporarily disabled. Contact support.", "connect_intro": "Connect to Skatteverket with BankID to submit VAT declarations, employer declarations and fetch the tax account balance. When you connect, we immediately fetch the company's tax account balance and transactions and check for pending employer declaration receipts (kvittenser) at Skatteverket.", "skahmst_note": "On Skatteverket's consent page one of the permissions appears as skahmst (Rubrik saknas): that's the scope name for tax account balance and transactions (Skattekonto HuvudMan STatus). Skatteverket has not yet published a Swedish description. It's safe to approve.", + "connect_approve_all": "Approve all permissions on Skatteverket's consent page when you connect. If a permission is left unchecked we cannot, for example, fetch the tax account.", "connect_with_bankid": "Connect with BankID", "expired": "Expired", "connected": "Connected", @@ -4093,6 +4128,7 @@ "ext_cloud_backup_no_documents_note": "without documents", "ext_cloud_backup_last_sync_no_documents": "Latest copy was made without document files", "ext_cloud_backup_card_tagline": "Backup to your own Drive.", + "ext_cloud_backup_legal_note": "The Drive copy is an extra backup. The legal archive (7 years under the Swedish Bookkeeping Act) remains in Accounted.", "ext_cloud_backup_loading": "Loading…", "ext_cloud_backup_account_label": "Account", "ext_cloud_backup_last_sync_label": "Last sync", @@ -4247,6 +4283,9 @@ "source_skatteverket": "Skatteverket ({count})", "search_placeholder": "Search transactions...", "no_search_results": "No transactions match your search.", + "skv_reconnect_title": "The Skatteverket connection needs to be renewed", + "skv_reconnect_body": "Tax account transactions are not fetched until you reconnect with BankID and approve all permissions.", + "skv_reconnect_cta": "Reconnect", "dialog_choose_template": "Choose template", "dialog_match_invoice": "Match with invoice", "dialog_add_transaction": "Add transaction", diff --git a/messages/sv.json b/messages/sv.json index 31b5e2cc..e023bb24 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -523,7 +523,13 @@ "generate_none_title": "Inga deadlines skapades", "generate_none_description": "Inga nya deadlines att skapa. Kontrollera att skatteinställningarna är ifyllda om du väntade dig fler.", "generate_failed_title": "Kunde inte skapa deadlines", - "generate_failed_description": "Det gick inte att skapa skattedeadlines just nu." + "generate_failed_description": "Det gick inte att skapa skattedeadlines just nu.", + "group_skattekonto_title": "Skattekonto", + "group_same_day": "{count} deadlines samma dag", + "group_mark_done": "Markera klar", + "group_confirm_question": "Markera {title} som klar?", + "group_cancel": "Avbryt", + "group_confirm": "Bekräfta" }, "bureau": { "title": "Byrå", @@ -1557,6 +1563,12 @@ "reminder_days_level_2": "Andra påminnelsen (dagar)", "reminder_days_level_3": "Slutlig påminnelse (dagar)" }, + "settings_payment_links": { + "heading": "Betalningslänk", + "enable_label": "Betalningslänk på fakturor", + "enable_help": "Visar betalningslänksfältet i fakturaredigeraren. Med Stripe anslutet skapas en betalningslänk automatiskt när fakturan skickas.", + "toast_save_failed": "Kunde inte spara" + }, "settings_pdf_print": { "coming_soon": "Kommer snart", "heading": "Utskrift & PDF", @@ -1787,9 +1799,31 @@ "eu_trade_suggestion_help": "Det finns bokförda EU-försäljningar (konto 3108, 3308 eller 3107) de senaste 15 månaderna, men EU-handel eller periodisk sammanställning är inte aktiverad nedan. Periodisk sammanställning ska lämnas varje månad eller kvartal vid försäljning till företag i andra EU-länder; förseningsavgiften är 1 250 kr per rapport.", "employer_seasonal_label": "Säsongsregistrerad arbetsgivare", "employer_seasonal_help": "Lämnar arbetsgivardeklaration bara för månader med löneutbetalning, samt för december om ingen lön betalats under året.", + "kontrolluppgifter_heading": "Kontrolluppgifter", + "kontrolluppgifter_label": "Påminn om kontrolluppgifter (KU)", + "kontrolluppgifter_help": "Skapar en årlig deadline den 31 januari för kontrolluppgifter (KU10, KU20, KU31). Gäller bland annat utdelning till ägare och ränta på ägarlån, som aldrig täcks av arbetsgivardeklarationen.", + "ku_suggestion_title": "Bokföringen tyder på att kontrolluppgifter behöver lämnas", + "ku_suggestion_help": "Det finns bokförd utdelning eller ägarlån (konto 2898, 2393 eller 2893) de senaste 15 månaderna. Utdelning och ränta till ägare rapporteras med kontrolluppgift senast den 31 januari; de ingår inte i arbetsgivardeklarationen. Förseningsavgiften är 500 kr per kontrolluppgift. Aktivera påminnelsen nedan om detta gäller ditt bolag.", + "rot_rut_heading": "ROT & RUT", + "rot_rut_label": "Påminn om ROT/RUT-begäran om utbetalning", + "rot_rut_help": "Skapar en deadline den 31 januari för begäran om utbetalning av ROT/RUT-avdrag. Påminnelsen skapas bara för år som har betalda ROT/RUT-fakturor. Missas datumet förloras utbetalningen från Skatteverket.", + "rot_rut_suggestion_title": "Det finns fakturor med ROT/RUT-avdrag", + "rot_rut_suggestion_help": "Begäran om utbetalning för ROT/RUT-avdrag måste vara hos Skatteverket senast den 31 januari året efter att kunden betalade (lag 2009:194). Missas datumet förloras hela utbetalningen. Aktivera påminnelsen nedan så bevakas datumet automatiskt.", "preliminary_tax_heading": "Preliminärskatt", "preliminary_tax_monthly_label": "Månatlig preliminärskatt (F-skatt)", - "preliminary_tax_monthly_help": "Månadsbelopp enligt Skatteverkets beslut om debiterad preliminärskatt. Betalningspåminnelser skapas bara när ett belopp är angivet. Lämna tomt om ingen preliminärskatt är debiterad." + "preliminary_tax_monthly_help": "Månadsbelopp enligt Skatteverkets beslut om debiterad preliminärskatt. Betalningspåminnelser skapas bara när ett belopp är angivet. Lämna tomt om ingen preliminärskatt är debiterad.", + "more_deadlines_heading": "Fler deadlines", + "more_deadlines_help": "Deadlines som bara gäller vissa företag. Aktivera de som gäller dig så bevakas datumen automatiskt.", + "oss_label": "OSS-deklaration (kvartalsvis)", + "oss_help": "För EU-försäljning till konsumenter över tröskeln 10 000 euro. Lämnas senast sista dagen i månaden efter kvartalet; datumet flyttas inte fram vid helg.", + "ioss_label": "IOSS-deklaration (månadsvis)", + "ioss_help": "För distansförsäljning av importerade varor (importordningen). Lämnas senast sista dagen i månaden efter; datumet flyttas inte fram vid helg.", + "intrastat_label": "Intrastat (månadsvis)", + "intrastat_help": "SCB:s rapport för varuhandel inom EU, för företag över tröskelvärdena. Lämnas omkring tionde arbetsdagen i månaden efter. Kontrollera exakta datum i SCB:s kalender.", + "punktskatt_label": "Punktskattedeklaration (månadsvis)", + "punktskatt_help": "För punktskattepliktiga företag (t.ex. alkohol, tobak, energi). Följer samma datum som skattedeklarationen.", + "fyllnadsinbetalning_label": "Fyllnadsinbetalning", + "fyllnadsinbetalning_help": "Extra inbetalning av preliminärskatt för att undvika kostnadsränta på kvarskatt. Belopp över 30 000 kr senast den 12:e i andra månaden efter beskattningsåret, resten senast den 3:e i femte månaden." }, "settings_backup_download": { "create_backup_title": "Skapa backup", @@ -1973,6 +2007,7 @@ "disabled_message": "Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.", "connect_intro": "Anslut till Skatteverket med BankID för att skicka momsdeklaration, arbetsgivardeklaration och hämta saldot på skattekontot. När du ansluter hämtar vi direkt företagets skattekontosaldo och transaktioner och kontrollerar om det finns väntande kvittenser för arbetsgivardeklarationer hos Skatteverket.", "skahmst_note": "På Skatteverkets samtyckessida visas en av behörigheterna som skahmst (Rubrik saknas): det är scope-namnet för skattekontots saldo och transaktioner (Skattekonto HuvudMan STatus). Skatteverket har inte publicerat en svensk beskrivning för den ännu. Det är ofarligt att godkänna.", + "connect_approve_all": "Godkänn alla behörigheter på Skatteverkets samtyckessida när du ansluter. Lämnas en behörighet obockad kan vi till exempel inte hämta skattekontot.", "connect_with_bankid": "Anslut med BankID", "expired": "Utgången", "connected": "Ansluten", @@ -4093,6 +4128,7 @@ "ext_cloud_backup_no_documents_note": "utan underlag", "ext_cloud_backup_last_sync_no_documents": "Senaste kopian togs utan underlagsfiler", "ext_cloud_backup_card_tagline": "Säkerhetskopia till din egen Drive.", + "ext_cloud_backup_legal_note": "Drive-kopian är en extra säkerhetskopia. Det lagliga arkivet (7 år enligt bokföringslagen) finns kvar i Accounted.", "ext_cloud_backup_loading": "Laddar…", "ext_cloud_backup_account_label": "Konto", "ext_cloud_backup_last_sync_label": "Senaste synk", @@ -4247,6 +4283,9 @@ "source_skatteverket": "Skatteverket ({count})", "search_placeholder": "Sök transaktion...", "no_search_results": "Inga transaktioner matchar din sökning.", + "skv_reconnect_title": "Anslutningen till Skatteverket behöver förnyas", + "skv_reconnect_body": "Skattekontots transaktioner hämtas inte förrän du anslutit igen med BankID och godkänt alla behörigheter.", + "skv_reconnect_cta": "Anslut igen", "dialog_choose_template": "Välj mall", "dialog_match_invoice": "Matcha med faktura", "dialog_add_transaction": "Lägg till transaktion", diff --git a/supabase/migrations/20260719101000_rot_rut_deadline_setting.sql b/supabase/migrations/20260719101000_rot_rut_deadline_setting.sql new file mode 100644 index 00000000..1672f0f0 --- /dev/null +++ b/supabase/migrations/20260719101000_rot_rut_deadline_setting.sql @@ -0,0 +1,13 @@ +-- ROT/RUT begäran om utbetalning deadline opt-in. +-- +-- A begäran must reach Skatteverket by 31 January the year after the buyer +-- paid (Lag 2009:194 8 §); missing the date forfeits the payout sitting on +-- account 1513. The flag is opt-in and confirmed by the user in tax +-- settings; the settings page suggests it when invoices with ROT/RUT +-- deductions exist. Deadline rows are only generated for years that +-- actually have paid ROT/RUT invoices (payment dates, not invoice dates). +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS rot_rut_enabled BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN public.company_settings.rot_rut_enabled IS + 'Opt-in: generate the ROT/RUT begäran om utbetalning deadline, due 31 January after the payment year (Lag 2009:194 8 §)'; diff --git a/supabase/migrations/20260719102000_deadline_horizon_cleanup.sql b/supabase/migrations/20260719102000_deadline_horizon_cleanup.sql new file mode 100644 index 00000000..203936f4 --- /dev/null +++ b/supabase/migrations/20260719102000_deadline_horizon_cleanup.sql @@ -0,0 +1,32 @@ +-- One-time cleanup for the rolling generation horizon. +-- +-- Deadline rows used to be generated through the end of next calendar year +-- (~17 months of future rows in mid-year). The generator now caps recurring +-- skattekonto obligations at ~6 months ahead and annual obligations at 12 +-- months; the daily backfill cron creates rows as they enter the window. +-- Existing pending rows beyond the horizon would otherwise linger until the +-- company's next full regeneration, so delete them here. Completed and +-- dismissed rows are untouched (filing progress and opt-outs survive), and +-- every deleted row is recreated automatically once its due date comes back +-- into view. +DELETE FROM public.deadlines +WHERE source = 'system' + AND deadline_type = 'tax' + AND is_completed = FALSE + AND dismissed_at IS NULL + AND ( + ( + tax_deadline_type IN ( + 'moms_monthly', 'moms_quarterly', 'f_skatt', + 'arbetsgivardeklaration', 'skatteinbetalning', 'periodisk_sammanstallning' + ) + AND due_date > (CURRENT_DATE + INTERVAL '183 days') + ) + OR ( + tax_deadline_type NOT IN ( + 'moms_monthly', 'moms_quarterly', 'f_skatt', + 'arbetsgivardeklaration', 'skatteinbetalning', 'periodisk_sammanstallning' + ) + AND due_date > (CURRENT_DATE + INTERVAL '365 days') + ) + ); diff --git a/supabase/migrations/20260719103000_kontrolluppgifter_deadline_setting.sql b/supabase/migrations/20260719103000_kontrolluppgifter_deadline_setting.sql new file mode 100644 index 00000000..2663975a --- /dev/null +++ b/supabase/migrations/20260719103000_kontrolluppgifter_deadline_setting.sql @@ -0,0 +1,13 @@ +-- Kontrolluppgifter (KU10/KU20/KU31) deadline opt-in. +-- +-- Kontrolluppgifter are due 31 January after the income year (SFL 24 kap. +-- 1 §). KU31 (utdelning) is never covered by AGI, so a fåmansbolag paying +-- utdelning must file it separately; the app already generates KU10 XML but +-- had no reminder. The flag is opt-in and confirmed by the user in tax +-- settings; the settings page suggests it from ledger signals (2898 +-- utdelning, 2393/2893 ägarlån) but never flips it automatically. +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS kontrolluppgifter_enabled BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN public.company_settings.kontrolluppgifter_enabled IS + 'Opt-in: generate the annual kontrolluppgifter (KU) deadline, due 31 January (SFL 24 kap. 1 §)'; diff --git a/supabase/migrations/20260719104000_long_tail_deadline_settings.sql b/supabase/migrations/20260719104000_long_tail_deadline_settings.sql new file mode 100644 index 00000000..544f2063 --- /dev/null +++ b/supabase/migrations/20260719104000_long_tail_deadline_settings.sql @@ -0,0 +1,25 @@ +-- Long-tail deadline opt-ins ("Fler deadlines" in tax settings). +-- +-- Each flag enables a niche statutory deadline type that applies to a small +-- minority of companies, so all are explicit opt-in and default false: +-- - oss_enabled: OSS quarterly declaration (ML 22 kap. / Art. 369f VAT +-- directive), due the last day of the month after the quarter. EU-law +-- deadline: never moves to the next banking day. +-- - ioss_enabled: IOSS monthly declaration (Art. 369s), due the last day +-- of the following month, same no-shift rule. +-- - intrastat_enabled: SCB Intrastat monthly report, ~10th working day of +-- the following month. +-- - punktskatt_enabled: monthly punktskattedeklaration on the ordinary +-- skattedeklaration schedule (SFL 26 kap.). +-- - fyllnadsinbetalning_enabled: extra preliminary tax payments to avoid +-- kostnadsränta (SFL 62 kap. 8 §, 65 kap.): parts over 30 000 kr by the +-- 12th of the second month after FY end, the rest by the 3rd of the +-- fifth month. +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS oss_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS ioss_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS intrastat_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS punktskatt_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS fyllnadsinbetalning_enabled BOOLEAN NOT NULL DEFAULT FALSE; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260720090000_notification_type_skv_connection_expired.sql b/supabase/migrations/20260720090000_notification_type_skv_connection_expired.sql new file mode 100644 index 00000000..aaffad8c --- /dev/null +++ b/supabase/migrations/20260720090000_notification_type_skv_connection_expired.sql @@ -0,0 +1,51 @@ +-- Extend notification_log's notification_type CHECK with +-- 'skv_connection_expired' (email nudge when the Skatteverket connection +-- dies and skattekonto sync is paused until the user reconnects with +-- BankID). +-- +-- Background: SKV personal tokens live ~65 minutes. When one dies, the only +-- prior surface for the needs_reconsent state was the settings panel, which +-- users have no reason to revisit: prod has ~70 companies whose skattekonto +-- never synced because nothing told them to reconnect. The handler for +-- skattekonto.connection.expired (previously emitted but unconsumed) now +-- sends one email per consent episode. + +ALTER TABLE public.notification_log + DROP CONSTRAINT IF EXISTS notification_log_notification_type_check; + +ALTER TABLE public.notification_log + ADD CONSTRAINT notification_log_notification_type_check + CHECK (notification_type IN ( + 'tax_deadline', + 'invoice_due', + 'invoice_overdue', + 'period_locked', + 'period_year_closed', + 'invoice_sent', + 'receipt_extracted', + 'receipt_matched', + 'missing_underlag', + 'skv_kvittens', + 'skv_connection_expired' + )) NOT VALID; + +ALTER TABLE public.notification_log + VALIDATE CONSTRAINT notification_log_notification_type_check; + +-- Atomic claim-then-send dedup, same mechanism as the kvittens index +-- (20260712113000): the handler inserts the log row FIRST and only sends +-- when the insert won; overlapping emitters (nightly cron vs manual sync) +-- get a 23505 and skip. Scoped per type: other notification types +-- legitimately log multiple rows per reference. +-- +-- No defensive duplicate cleanup needed: the type is new in this migration, +-- so the CHECK above guarantees no existing rows can carry it. +-- +-- Plain CREATE INDEX (not CONCURRENTLY): Supabase branching applies +-- migrations inside a transaction, where CONCURRENTLY is not allowed. +-- notification_log is small and append-only; the brief lock is fine. +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_log_skv_conn_expired_dedup + ON public.notification_log (user_id, reference_id) + WHERE notification_type = 'skv_connection_expired'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260720120000_company_settings_invoice_payment_links.sql b/supabase/migrations/20260720120000_company_settings_invoice_payment_links.sql new file mode 100644 index 00000000..57916eaf --- /dev/null +++ b/supabase/migrations/20260720120000_company_settings_invoice_payment_links.sql @@ -0,0 +1,19 @@ +-- Invoice payment links become an explicit opt-in on the invoice settings +-- page. Previously the payment-link field was always visible in the invoice +-- editor and a Stripe payment link was auto-created on every send for any +-- company with an active Stripe connection (payment_link_auto defaults on). +-- +-- When invoice_payment_links_enabled is false the editor hides the whole +-- payment-link section and the send routes skip link creation entirely +-- (gated server-side in maybeCreatePaymentLinkForInvoice, so v1/MCP and +-- recurring sends obey it too). Default false for everyone, deliberately +-- including companies already connected to Stripe: they opt back in from +-- the invoice settings page. + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS invoice_payment_links_enabled boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN public.company_settings.invoice_payment_links_enabled IS + 'Opt-in for the invoice payment-link feature: shows the payment-link field in the invoice editor and enables automatic Stripe payment links on send. Default off; toggled on the invoice settings page.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 9c9a1035..ca1d840c 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -528,6 +528,13 @@ export function makeCompanySettings( vat_filing_method: 'electronic', periodisk_sammanstallning_enabled: false, 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, tax_contact_name: null, tax_contact_phone: null, tax_contact_email: null, @@ -591,6 +598,7 @@ export function makeCompanySettings( invoice_header_text: null, invoice_footer_text: null, invoice_email_texts: null, + invoice_payment_links_enabled: false, send_invoice_reminders: true, reminder_days_level_1: 15, reminder_days_level_2: 30, diff --git a/types/index.ts b/types/index.ts index 965ae12a..a94ab14f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -235,6 +235,20 @@ export interface CompanySettings { vat_filing_method: TaxFilingMethod periodisk_sammanstallning_enabled: boolean periodisk_sammanstallning_filing_method: TaxFilingMethod + // Annual kontrolluppgifter (KU10/KU20/KU31) reminder, due 31 January. + kontrolluppgifter_enabled: boolean + // ROT/RUT begäran om utbetalning reminder, due 31 January after the + // payment year (Lag 2009:194 8 §). Rows are only generated for years + // that actually have paid ROT/RUT invoices. + rot_rut_enabled: boolean + // Long-tail deadlines, explicit opt-in only ("Fler deadlines" in tax + // settings). OSS/IOSS are EU-law deadlines that never move to the next + // banking day. + oss_enabled: boolean + ioss_enabled: boolean + intrastat_enabled: boolean + punktskatt_enabled: boolean + fyllnadsinbetalning_enabled: boolean // Tax contact (SKV-filings, periodisk sammanställning, AGI, etc.) tax_contact_name: string | null @@ -311,6 +325,12 @@ export interface CompanySettings { invoice_late_fee_text: string | null invoice_credit_terms_text: string | null + // Opt-in for the invoice payment-link feature (default false): shows the + // payment-link field in the invoice editor and enables automatic Stripe + // payment links on send. Enforced server-side in + // lib/extensions/payment-links.ts, not just in the UI. + invoice_payment_links_enabled: boolean + // Invoice branding (per-company colors, font, optional header/footer text). // Defaults preserve the legacy hardcoded palette so unbranded companies // render identically to the pre-branding template. @@ -2182,6 +2202,13 @@ export type TaxDeadlineType = | 'arsredovisning' | 'arsstamma' | 'periodisk_sammanstallning' + | 'kontrolluppgifter' + | 'rot_rut_begaran' + | 'oss_quarterly' + | 'ioss_monthly' + | 'intrastat_monthly' + | 'punktskatt_monthly' + | 'fyllnadsinbetalning' // Deadline status workflow export type DeadlineStatus = @@ -2299,6 +2326,7 @@ export type NotificationType = | 'invoice_sent' | 'missing_underlag' | 'skv_kvittens' + | 'skv_connection_expired' // Notification log entry export interface NotificationLog { @@ -2359,7 +2387,14 @@ export const TAX_DEADLINE_TYPE_LABELS: Record = { inkomstdeklaration_ab: 'Inkomstdeklaration AB', arsredovisning: 'Årsredovisning', arsstamma: 'Årsstämma', - periodisk_sammanstallning: 'Periodisk sammanställning' + periodisk_sammanstallning: 'Periodisk sammanställning', + kontrolluppgifter: 'Kontrolluppgifter (KU)', + rot_rut_begaran: 'ROT/RUT-begäran om utbetalning', + oss_quarterly: 'OSS-deklaration', + ioss_monthly: 'IOSS-deklaration', + intrastat_monthly: 'Intrastat', + punktskatt_monthly: 'Punktskattedeklaration', + fyllnadsinbetalning: 'Fyllnadsinbetalning' } // ============================================================