diff --git a/.claude/skills/swedish-sru-filing/SKILL.md b/.claude/skills/swedish-sru-filing/SKILL.md index 288e377d..9d5d7c94 100644 --- a/.claude/skills/swedish-sru-filing/SKILL.md +++ b/.claude/skills/swedish-sru-filing/SKILL.md @@ -170,7 +170,7 @@ Reserved for post names. **Forbidden in all string data values.** #NAMN Exempelbolaget AB #UPPGIFT 7011 20240101 #UPPGIFT 7012 20241231 -#UPPGIFT 7113 90000 +#UPPGIFT 7104 90000 #BLANKETTSLUT #BLANKETT INK2R-2024P4 #IDENTITET 165590001234 20250401 100001 diff --git a/.claude/skills/swedish-sru-filing/references/sru-codes.md b/.claude/skills/swedish-sru-filing/references/sru-codes.md index 9119d9ed..6803b629 100644 --- a/.claude/skills/swedish-sru-filing/references/sru-codes.md +++ b/.claude/skills/swedish-sru-filing/references/sru-codes.md @@ -22,14 +22,14 @@ Complete field code (fältkod) tables for INK2, INK2R, and INK2S blankett types, |---|---|---| | 7011 | N/A | Räkenskapsår fr.o.m. (YYYYMMDD) | | 7012 | N/A | Räkenskapsår t.o.m. (YYYYMMDD) | -| 7113 | 1.1 | Överskott av näringsverksamhet | +| 7104 | 1.1 | Överskott av näringsverksamhet | | 7114 | 1.2 | Underskott av näringsverksamhet | | 7132 | 1.4 | Underlag för särskild löneskatt på pensionskostnader | | 7133 | 1.5 | Negativt underlag särskild löneskatt | | 7153 | 1.6a | Avkastningsskatt 15% | | 7155 | 1.7a | Avkastningsskatt 30% | -Fields 7113/7114 correspond directly to INK2S fields 8020/8021. +Fields 7104/7114 correspond directly to INK2S fields 8020/8021. (Verified against Skatteverket's official 2025P4 field list: 1.1 is 7104, NOT 7113; 7113 does not exist on INK2 and Skatteverket rejects it with "är inte ett giltigt postnamn".) --- @@ -168,7 +168,7 @@ Fields 7113/7114 correspond directly to INK2S fields 8020/8021. | 7763 | 4.14a | Outnyttjat underskott från föregående beskattningsår | | 7664 | 4.14b | Reduktion av underskott (beloppsspärr/ackord) | | 7670 | 4.14c | Reduktion pga koncernbidragsspärr/fusionsspärr | -| 8020 | 4.15 | Överskott → överförs till punkt 1.1 (INK2 field 7113) | +| 8020 | 4.15 | Överskott → överförs till punkt 1.1 (INK2 field 7104) | | 8021 | 4.16 | Underskott → överförs till punkt 1.2 (INK2 field 7114) | | 7770 | 4.20 | Lån från aktieägare (fysisk person) vid beskattningsårets utgång | diff --git a/DECISIONS.md b/DECISIONS.md index d49f1fd8..6b907c29 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -826,6 +826,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended. [2026-08-07] Sandbox cleanup repaired via a dedicated gnubok.sandbox_cleanup transaction-local flag (respected by write_audit_log, audit_log_immutable, enforce_dimension_registry_guards, enforce_pending_operations_no_delete) instead of tombstoning sandbox users like the real account-deletion flow: sandbox data is synthetic demo content, not rakenskapsinformation, so full deletion is the correct GDPR/BFL posture and the audit-log DELETE bypass re-verifies per row that the company is a sandbox before letting anything through. Also revoked the default PUBLIC EXECUTE both cleanup RPCs had carried on prod since March (anon could call them via PostgREST), and validated the whole delete chain by replaying it against prod inside aborted transactions (21 sampled users across all seed eras) plus a committed staging run. [2026-08-07] QuickReviewDialog's 'none'-collapse closed with a deviation test, not a blanket map: 'none' as the SEEDED default (exempt categories) stays off the wire so untouched bank-fee bookings remain byte-identical, while 'none' as a user deviation goes as explicit 'exempt' (books no VAT line, records the classification the momsdeklaration should see, lands income on 3004). The batch dialog's blanket 'none'→'exempt' map from #1443 is correct THERE because its select has no seeded-per-category default. CategoryExpandedDialog carries the same collapse but has zero references (dead code): left untouched, deletion is hygiene for a separate sweep. +[2026-08-08] Provider migration errors surface via a new PROVIDER_API_MODULE_INACTIVE code instead of reusing PROVIDER_LICENSE_MISSING: the existing code's message is Fortnox-specific (re-order the Integration license) while Visma's 4002 "No access to module: api_standard" needs different remediation (activate the API under Appar och tillägg, check standardforetag), and both differ from AUTH_EXPIRED whose "reconnect" advice loops forever since OAuth succeeds against Visma's shared identity server. Orchestrator failures split fatal/non-fatal: connection-level codes (auth expired, license missing, module inactive) rethrow and abort the run because every remaining call is doomed, while per-step upstream/unknown failures keep log-and-continue (one bad step must not discard other steps' persisted rows) but are recorded on results.stepErrors so the result UI stops presenting an all-failed sync as "Allt ar uppdaterat". Root trigger: ElvaSmultron's Visma sync failed five times server-side, reported success with zero rows, and reached us as a bug report for a customer-side subscription setting. +[2026-08-08] INK2 huvudblankett field 1.1 (Overskott av naringsverksamhet) is SRU code 7104, not 7113: verified against Skatteverket's official 2025P4 faltkoder Excel (INK2_SKV2002-33-01-24-04.xls) after prod SRU files were rejected with "UPPGIFT 7113 ar inte ett giltigt postnamn" (Miles Scherrer, Burre Nacho AB). 7113 came from our own swedish-sru-filing skill reference, which was wrong and is fixed in the same change; every other emitted INK2/INK2R/INK2S code was cross-checked against the official lists and is valid. Only profitable companies were affected (7114 for underskott was already correct). [2026-08-08] Ruta 41 unblocked with a static ACCOUNT_RUTA mapping of 3231/3232/3233 (incl. 3232/3233, in the SKV 4700 mapping though not in our shipped BAS 2026 catalog) instead of starting the per-account VAT-treatment picker: the ElvaSmultron/Spiris support case needed omvänd-moms sales in the declaration now, and RC sales deliberately stay out of the ruta 05-08 vs 10-12 pairing checks because the buyer accounts for the VAT. The picker (any custom konto to any ruta, incl. purchase-side 4056-style Visma accounts to ruta 20) is #1457. [2026-08-08] Community registry source of truth moved into this repo (registry/ with entries + authors, validator wired into core-build): the site button "Lagg till en egen" pointed at erp-mafia/accounted while the registry MDX lived in the private website repo, so an external contributor had no path to PR (found by the first would-be contributor). The website now syncs FROM registry/ instead of owning the content. Entry bodies are gated to plain Markdown because the site renders them through MDX, which would execute JSX and import/export statements inside the website build. [2026-07-28] Transaction method (structured payment rail): the trailing channel phrase ("Överföring via internet", "Kortköp/uttag") is stripped from transactions.description at INGEST and by a one-shot BACKFILL, not merely hidden at render: description is the mutable working title, original_description keeps the full bank string, and every dedup surface (external_id: date+öre only; content bridge: prefix-containment over original_description ?? description, and a trailing strip leaves a prefix) is provably unaffected. transaction_method is text + CHECK (repo convention, no PG enums) beside verbatim bank_transaction_code / proprietary_bank_transaction_code evidence columns per data_quality_master Appendix B Layer-A; the dead `enrichment` jsonb was NOT reused (the Gokind lesson: opaque blobs with no readers die). mapping-engine now also matches original_description so user rules written against the full bank text keep firing on stripped rows. diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 1f60ccbd..de3f1f22 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge' import { RowStatus, type RowStatusDescriptor } from '@/components/ui/row-status' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog' import { DataListEmpty } from '@/components/ui/data-list' import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' import { FyPicker } from '@/components/common/FyPicker' @@ -48,8 +48,13 @@ import type { FiscalPeriod, Invoice } from '@/types' function NewInvoiceDialogLoading() { const t = useTranslations('invoices') + // Non-modal + veil, matching NewInvoiceDialog: a modal fallback would lock + // the whole route (body pointer-events: none, no close path) if this chunk + // ever hangs or 404s on a stale deploy, and would dead-click the agent + // sheet meanwhile. return ( - + + {t('new_invoice')}
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 33da3e9f..0f268dee 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -118,12 +118,19 @@ interface SkipReasons { noMatch?: number } +interface MigrationStepError { + step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'reconciliation' + code: string | null + message: string +} + interface MigrationResults { companyInfo?: { imported: boolean } customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string } suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string } salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string } supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string } + stepErrors?: MigrationStepError[] } import AccountMappingStep from '@/components/import/AccountMappingStep' import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types' @@ -1559,11 +1566,17 @@ function ResultStep({ (results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) || (results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)) ) - const nothingNew = sieResults.length === 0 && !entityImported + + // Steps that failed against the provider API. An empty sync with failed + // steps must never present as "Allt är uppdaterat": that reading sent a + // real subscription problem to the bug tracker as a sync bug. + const stepErrors = results?.stepErrors ?? [] + const apiFailed = stepErrors.length > 0 + const nothingNew = sieResults.length === 0 && !entityImported && !apiFailed // Overall status const overallIcon = anySieFailed ? 'error' as const : - (!allSieSucceeded || totalErrors > 0) ? 'warning' as const : 'success' as const + (apiFailed || !allSieSucceeded || totalErrors > 0) ? 'warning' as const : 'success' as const return (
@@ -1573,7 +1586,7 @@ function ResultStep({ {nothingNew ? 'Allt är uppdaterat' : - anySieFailed ? 'Migrering delvis genomförd' : + (anySieFailed || apiFailed) ? 'Migrering delvis genomförd' : !allSieSucceeded ? 'Migrering klar med anmärkningar' : 'Migrering klar'} @@ -1598,6 +1611,23 @@ function ResultStep({ + {/* ── Steps that failed against the provider API ── */} + {stepErrors.length > 0 && ( +
+ {groupStepErrors(stepErrors).map((group, i) => ( +
+ +
+

+ Kunde inte hämta: {group.steps.map((s) => STEP_ERROR_LABELS[s]).join(', ')} +

+

{group.message}

+
+
+ ))} +
+ )} + {/* ── Per-fiscal-year SIE breakdown ── */} {sieResults.length > 0 && (
@@ -1742,6 +1772,29 @@ function ResultStep({ ) } +const STEP_ERROR_LABELS: Record = { + companyInfo: 'Företagsinformation', + customers: 'Kunder', + suppliers: 'Leverantörer', + salesInvoices: 'Kundfakturor', + supplierInvoices: 'Leverantörsfakturor', + reconciliation: 'Avstämning av betalningar', +} + +/** + * Group step errors that share the same message (a provider outage hits every + * step identically) so the result shows one card per cause, not one per step. + */ +function groupStepErrors(errors: MigrationStepError[]): { message: string; steps: MigrationStepError['step'][] }[] { + const groups = new Map() + for (const e of errors) { + const steps = groups.get(e.message) ?? [] + steps.push(e.step) + groups.set(e.message, steps) + } + return [...groups.entries()].map(([message, steps]) => ({ message, steps })) +} + function formatSkipReasons( reasons?: SkipReasons, entityType?: 'customer' | 'supplier' | 'invoice', @@ -1899,13 +1952,19 @@ export default function ArcimMigrationWorkspace({ const data = await res.json().catch(() => ({})) // A dead connection (expired/revoked refresh token) is recoverable in // place: flag it so the UI offers "Återanslut" instead of a dead end. - // A missing Fortnox integration license shows the same CTA but keeps the - // SIE fallback, because re-auth loops until the license is re-ordered. + // A missing Fortnox integration license or an inactive Visma API + // module shows the same CTA but keeps the SIE fallback, because + // re-auth loops until the customer fixes the subscription (re-orders + // the license / activates the API module). const code = apiErrorCode(data) - if (code === 'PROVIDER_AUTH_EXPIRED' || code === 'PROVIDER_LICENSE_MISSING') { + if ( + code === 'PROVIDER_AUTH_EXPIRED' || + code === 'PROVIDER_LICENSE_MISSING' || + code === 'PROVIDER_API_MODULE_INACTIVE' + ) { setAuthExpired(true) } - if (code === 'PROVIDER_LICENSE_MISSING') { + if (code === 'PROVIDER_LICENSE_MISSING' || code === 'PROVIDER_API_MODULE_INACTIVE') { setLicenseMissing(true) } throw new Error(apiErrorMessage(data, `HTTP ${res.status}`)) @@ -2315,6 +2374,7 @@ export default function ArcimMigrationWorkspace({ migrationOptions.importSalesInvoices || migrationOptions.importSupplierInvoices + let hadStepErrors = false if (hasApiImport) { setMigrationStep('Importerar kunder, leverantörer och fakturor...') setMigrationProgress(55) @@ -2339,6 +2399,7 @@ export default function ArcimMigrationWorkspace({ const data = await res.json() setMigrationResults(data.results) + hadStepErrors = ((data.results as MigrationResults | undefined)?.stepErrors?.length ?? 0) > 0 } // Mark consent as fully accepted now that import is complete @@ -2353,10 +2414,18 @@ export default function ArcimMigrationWorkspace({ setMigrationProgress(100) setStep('result') - toast({ - title: 'Migrering klar', - description: 'Din bokföringsdata har importerats.', - }) + if (hadStepErrors) { + toast({ + title: 'Migrering delvis genomförd', + description: 'Vissa delar kunde inte hämtas från leverantören. Se detaljerna i resultatet.', + variant: 'destructive', + }) + } else { + toast({ + title: 'Migrering klar', + description: 'Din bokföringsdata har importerats.', + }) + } } catch (err) { setError(displayError(err)) setStep('result') diff --git a/components/invoices/NewInvoiceDialog.tsx b/components/invoices/NewInvoiceDialog.tsx index 40b9b437..182266b2 100644 --- a/components/invoices/NewInvoiceDialog.tsx +++ b/components/invoices/NewInvoiceDialog.tsx @@ -3,7 +3,7 @@ import dynamic from 'next/dynamic' import { useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' import { Button } from '@/components/ui/button' import { createClient } from '@/lib/supabase/client' @@ -119,8 +119,25 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null const copyInitial = copyLoad.sourceId === copyFromId ? copyLoad.initial : null const copyLoadFailed = copyLoad.sourceId === copyFromId && copyLoad.failed + // The dialog is non-modal so the agent sheet (a body-level sibling at + // z-[60]) stays clickable and focusable above it: Radix modal mode sets + // body pointer-events: none and traps focus, which left the visible chat + // input dead. Page modality is restored by hand instead: `inert` on the + // dash shell blocks pointer, keyboard, and AT access to the page behind, + // while the agent sheet (outside the shell) stays live. + useEffect(() => { + if (!open) return + const shell = document.getElementById('dash-shell') + if (!shell) return + shell.inert = true + return () => { + shell.inert = false + } + }, [open]) + return ( - + + ( + +