diff --git a/DECISIONS.md b/DECISIONS.md index f1928f84..865e3bda 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1105,6 +1105,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] The CI build OOM is the TYPE-CHECK pass, not bundle growth: measured with tsc --extendedDiagnostics the repo needs ~4.19 GB at 506d030b and ~4.19 GB on a branch on top of it, i.e. a steady-state ceiling against Node 20 default old-space (~4 GB), not any one PR's regression. Fixed on main independently by raising the build heap to 8192, which this branch keeps; recording the measurement so the next person does not go hunting in a diff. Vercel builds already run with a larger heap and were never affected. [2026-08-20] Bokio getCompany accepts both the spec envelope and the live flat body: the published v1 spec (bokio/bokio-api company-api.yaml) wraps company-information in `companyInformation`, but api.bokio.se/v1 returned the company object flat on a 200 in prod (BokioResponseError in logs, customer script showed the same). Tolerating both instead of picking one means a spec/live drift in either direction can no longer turn a valid integration token into a connection failure. +[2026-08-20] Moderföretagets org.nr in the årsredovisning note accepts foreign registration identifiers (CHE-123.456.789, HRB 12345, 923 609 016) and only enforces the NNNNNN-NNNN third-digit-2-9 rule on 10/12-digit all-numeric values: ÅRL 5:21 asks for the parent's identifier as its home register writes it, and a Swiss holding was refused as "ej personnummer". Personnummer shapes stay rejected; a foreign 10/12-digit all-numeric id with third digit 0-1 is the accepted false negative. +[2026-08-20] Fortnox document discovery treats a 400 whose body mentions behörighet/scope/licens as "scopes required" (not only 403) and retries the voucherfileconnections list unfiltered when the financialyear filter 400s: the first live Fortnox underlag run (Boltonshield) failed three times with a bare "400 Bad Request" that no log line explained, so status, body and Fortnox's own message are now logged and shown in the UI instead of guessing which of the two causes it was. [2026-08-20] Detail pages (kundfaktura first, then the stale card-pile siblings: leverantörsfaktura, verifikat, kreditfaktura, avyttring, lön) adopt the register-detail document grammar from #1624 instead of card stacks: DetailSection/DefRow groups, one status element per the list pages' chips-mark-exceptions rule, one primary next step plus "Förhandsgranska" visible and everything else behind a ⋯ overflow menu, the line table on the dry-table idiom with the headline total in the serif. Considered keeping a two-column card sidebar with fewer cards; rejected because the card border carried no hierarchy the hairline kicker does not already carry, and a second column of stacked boxes is exactly what the founder called clutter. [2026-08-20] The bank-reconciliation card leads with `unexplained_difference` (difference minus the two unmatched-list totals) instead of the raw `difference`. Every krona of the difference is, by construction, (unmatched bank rows) - (unmatched vouchers), so the raw figure alarms about ordinary mid-year backlog while saying nothing; the residual is the only part that can mean something is wrong. Verified on prod: Arcim 1930 2025-07-17..2026-08-20 shows 70 884,49 difference and exactly 0,00 residual. diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx index 95e6aec6..8f49c18d 100644 --- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx @@ -94,6 +94,7 @@ export default function ArsredovisningPage() { const [signerName, setSignerName] = useState('') const [signerRole, setSignerRole] = useState('Styrelseledamot') const [versions, setVersions] = useState([]) + const [blockingCount, setBlockingCount] = useState(null) const [selectedSignatureVersionId, setSelectedSignatureVersionId] = useState('') const [signingMethod, setSigningMethod] = useState< 'paper_original' | 'advanced_e_signature' | 'bankid' @@ -509,6 +510,35 @@ export default function ArsredovisningPage() { // table. The save button below writes overrides; the URL stays clean. const pdfUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/pdf` + // Only versions locked via "Lås version för underskrift" can be signed. An + // empty "Låst version" select with no explanation was a dead end for a real + // user (2026-08-20): say what is missing and where the button lives. + const lockedVersions = versions.filter((version) => version.status === 'ready_for_signature') + const draftVersionCount = versions.filter((version) => version.status === 'draft').length + const noLockedVersionHint = (() => { + if (lockedVersions.length > 0) return null + const draftNote = draftVersionCount > 0 ? 'Versionsutkast kan inte signeras. ' : '' + if (blockingCount === null) { + return `${draftNote}Lås en version under Fullständighetskontroll så dyker den upp här.` + } + if (blockingCount > 0) { + const what = blockingCount === 1 ? 'det blockerande felet' : `de ${blockingCount} blockerande felen` + const done = blockingCount === 1 ? 'åtgärdat' : 'åtgärdade' + return `${draftNote}Knappen Lås version för underskrift är grå tills ${what} under Fullständighetskontroll är ${done}.` + } + return `${draftNote}Klicka på Lås version för underskrift under Fullständighetskontroll så dyker versionen upp här.` + })() + const pendingSignatureCount = signatures.filter( + (sig) => sig.status !== 'signed' && sig.status !== 'declined', + ).length + const signReadinessHint = !selectedSignatureVersionId + ? 'Välj en låst version ovan, sedan går det att markera underskrifter.' + : !SIGNATURE_EVIDENCE_REFERENCE_PATTERN.test(signatureEvidence.trim()) + ? 'Ange en bevisreferens (t.ex. archive:AR-2026-001) så aktiveras Markera som signerad.' + : !signatureDate + ? 'Ange underskriftsdatum så aktiveras Markera som signerad.' + : null + return (
@@ -751,8 +782,12 @@ export default function ArsredovisningPage() { id="ar-parent-orgnr" value={parentOrgNr} onChange={(e) => setParentOrgNr(e.target.value)} - placeholder="556677-8899" + placeholder="556677-8899 eller CHE-123.456.789" /> +

+ Svenskt org.nr NNNNNN-NNNN. Utländskt moderföretag: registreringsnumret som + det står i hemlandets register. +

@@ -760,7 +795,7 @@ export default function ArsredovisningPage() { id="ar-parent-city" value={parentCity} onChange={(e) => setParentCity(e.target.value)} - placeholder="Stockholm" + placeholder="Stockholm eller Zug, Schweiz" />
@@ -858,20 +893,32 @@ export default function ArsredovisningPage() { + {noLockedVersionHint && ( +

+ {noLockedVersionHint}{' '} + + Gå till Fullständighetskontroll + +

+ )}
@@ -925,6 +972,9 @@ export default function ArsredovisningPage() { Inga undertecknare tillagda än.

)} + {pendingSignatureCount > 0 && signReadinessHint && ( +

{signReadinessHint}

+ )} {signatures.map((sig) => (
{ + setupSupabase() + const res = await POST(postReq({ parent_company_org_number: '19850101-1234' }), idParams) + expect(res.status).toBe(400) + }) + + it('accepts a foreign parent company registration identifier', async () => { + const { enqueue } = setupSupabase() + enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check + enqueue({ data: null }) // no registrerad submission + enqueue({ data: { ...narrativeRow, parent_company_org_number: 'CHE-123.456.789' } }) // upsert + enqueue({ data: null }) // clear narrative confirmation + const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>( + await POST(postReq({ parent_company_org_number: 'CHE-123.456.789' }), idParams), + ) + expect(status).toBe(200) + expect(body.data.parent_company_org_number).toBe('CHE-123.456.789') + }) + it('returns 400 when the payload contains an unknown field', async () => { setupSupabase() const res = await POST( 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 49d3c63f..cc5336ae 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts @@ -7,6 +7,10 @@ import { getNarrative, upsertNarrative, } from '@/lib/bokslut/arsredovisning/narrative-service' +import { + isValidParentCompanyIdentifier, + PARENT_COMPANY_IDENTIFIER_ERROR, +} from '@/lib/bokslut/arsredovisning/parent-company-identifier' // Strip non-printable control characters that would corrupt PDF output or // mislead a human reader of the årsredovisning. Whitelist printable ASCII @@ -69,16 +73,15 @@ const PostSchema = z.object({ securities_pledged: sanitizedText(4000).nullable().optional(), contingent_liabilities: sanitizedText(4000).nullable().optional(), parent_company_name: sanitizedText(200).nullable().optional(), - // Swedish organisationsnummer NNNNNN-NNNN. Third digit ≥ 2 distinguishes - // legal-entity org numbers from personnummer (whose third digit forms part - // of a month, 0-1). ÅRL 5:13-15 disclosure is about parent legal entities, - // so personnummer-shaped values are out of scope and a GDPR Art.5(1)(c) - // data-minimisation concern if persisted. Empty string clears the override. + // Swedish organisationsnummer or a foreign parent's registration identifier + // (a Swiss holding's CHE-number was refused as "ej personnummer" on + // 2026-08-20). Rules live in parent-company-identifier.ts; personnummer- + // shaped values stay rejected. Empty string clears the override. parent_company_org_number: z .union([ z.literal(''), - z.string().regex(/^\d{2}[2-9]\d{3}-\d{4}$/, { - message: 'Ogiltigt organisationsnummer (NNNNNN-NNNN, ej personnummer)', + z.string().max(40).refine(isValidParentCompanyIdentifier, { + message: PARENT_COMPANY_IDENTIFIER_ERROR, }), ]) .nullable() diff --git a/components/bokslut/AnnualReportStudio.tsx b/components/bokslut/AnnualReportStudio.tsx index c5a12bb4..0ff66007 100644 --- a/components/bokslut/AnnualReportStudio.tsx +++ b/components/bokslut/AnnualReportStudio.tsx @@ -48,6 +48,8 @@ interface AnnualReportStudioProps { hasUnsavedNarrative: boolean narrativeRevision: string | null onVersionsChanged?: (versions: AnnualReportVersionSummary[]) => void + /** Blocking-issue count once the compliance check has loaded, null while loading. */ + onBlockingCountChanged?: (count: number | null) => void } type NullableBoolean = boolean | null @@ -91,6 +93,7 @@ export function AnnualReportStudio({ hasUnsavedNarrative, narrativeRevision, onVersionsChanged, + onBlockingCountChanged, }: AnnualReportStudioProps) { const t = useTranslations('annualReportStudio') const { toast } = useToast() @@ -143,6 +146,11 @@ export function AnnualReportStudio({ () => compliance?.validation.issues.filter((issue) => issue.severity === 'error') ?? [], [compliance], ) + // The signature section further down explains why "Låst version" is empty + // in terms of this count, so it must not read 0 while we are still loading. + useEffect(() => { + onBlockingCountChanged?.(compliance ? blockingIssues.length : null) + }, [compliance, blockingIssues.length, onBlockingCountChanged]) const digitalOnlyIssues = useMemo(() => { const generalCodes = new Set(compliance?.validation.issues.map((issue) => issue.code) ?? []) return ( @@ -496,7 +504,7 @@ export function AnnualReportStudio({
-
+

{t('checks_title')}

@@ -561,6 +569,13 @@ export function AnnualReportStudio({ {t('lock_version')}
+ {(blockingIssues.length > 0 || hasUnsavedNarrative) && ( +

+ {hasUnsavedNarrative + ? t('lock_hint_unsaved') + : t('lock_hint_blocked', { count: blockingIssues.length })} +

+ )}
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 852451ac..faf91963 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -1069,6 +1069,7 @@ function OptionsStep({ onStart: () => void onBack: () => void }) { + const t = useTranslations('extensions') const [showConfirm, setShowConfirm] = useState(false) const toggleOption = (key: keyof MigrationOptions) => { @@ -1155,7 +1156,7 @@ function OptionsStep({

Verifikationsserie

-

Serie för importerade verifikationer

+

{t('ext_arcim_option_series_help')}

+ {state.problem?.providerMessage && ( +

+ {t('ext_arcim_documents_provider_message', { + message: state.problem.providerMessage, + })} +

+ )} {state.problem?.requestId && (

{t('ext_arcim_documents_error_reference', { diff --git a/components/extensions/general/arcim-document-import-flow.ts b/components/extensions/general/arcim-document-import-flow.ts index 8895ab2f..9a692173 100644 --- a/components/extensions/general/arcim-document-import-flow.ts +++ b/components/extensions/general/arcim-document-import-flow.ts @@ -63,6 +63,8 @@ export interface ArcimDocumentImportProblem { requestId: string | null reconnectRequired: boolean message?: string + /** What the source system (Fortnox/Bokio) itself answered, when known. */ + providerMessage?: string } export function documentOAuthProblemFromReason( @@ -211,16 +213,25 @@ function problemFromPayload(payload: unknown): ArcimDocumentImportProblem { const error = (payload as { error?: unknown } | null)?.error const structured = error && typeof error === 'object' - ? (error as { code?: unknown; requestId?: unknown }) + ? (error as { code?: unknown; requestId?: unknown; details?: unknown }) : null const code = typeof structured?.code === 'string' ? structured.code : null const requestId = typeof structured?.requestId === 'string' ? structured.requestId : null + const details = + structured?.details && typeof structured.details === 'object' + ? (structured.details as { providerMessage?: unknown }) + : null + const providerMessage = + typeof details?.providerMessage === 'string' && details.providerMessage.trim() + ? details.providerMessage.trim() + : null return { code, requestId, reconnectRequired: code === PROVIDER_DOCUMENT_SCOPES_REQUIRED, + ...(providerMessage ? { providerMessage } : {}), } } diff --git a/components/import/AccountMappingStep.tsx b/components/import/AccountMappingStep.tsx index d4a10683..5e37d727 100644 --- a/components/import/AccountMappingStep.tsx +++ b/components/import/AccountMappingStep.tsx @@ -248,7 +248,10 @@ export default function AccountMappingStep({ Källnamn Målkonto - {t('vat_treatment_column')} + {/* table-fixed sizes columns from the header's width only: + min-w collapsed this column to nothing on laptop widths + and its selects overflowed into Konfidens (2026-08-20). */} + {t('vat_treatment_column')} Konfidens {t('vat_treatment_confirm')} diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index ab5e6dc4..9b70a680 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -36,6 +36,7 @@ import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' import type { ProviderName } from '@/lib/providers/types' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { classifyProviderError } from '@/lib/providers/with-provider-call' +import { FortnoxApiError, fortnoxErrorMessage } from '@/lib/providers/fortnox/client' import { createLogger } from '@/lib/logger' const moduleLog = createLogger('extensions/arcim-migration') @@ -1371,7 +1372,18 @@ export const arcimMigrationExtension: Extension = { }) return NextResponse.json({ success: true, dryRun, result }) } catch (error) { - log.error('arcim import-documents failed', error as Error) + // "400 Bad Request" alone told us nothing when a live Fortnox + // discovery failed (2026-08-20): keep status, Fortnox's own + // message and a body excerpt in the log, and hand the message + // to the UI so the user sees what the source system said. + const providerStatus = error instanceof FortnoxApiError ? error.statusCode : undefined + const providerMessage = fortnoxErrorMessage(error) + log.error('arcim import-documents failed', error as Error, { + providerStatus, + providerMessage, + providerBody: + error instanceof FortnoxApiError ? error.body?.slice(0, 500) : undefined, + }) if (error instanceof FortnoxDocumentScopesRequiredError) { return errorResponseFromCode( 'PROVIDER_DOCUMENT_SCOPES_REQUIRED', @@ -1380,7 +1392,11 @@ export const arcimMigrationExtension: Extension = { ) } return errorResponseFromCode('PROVIDER_IMPORT_DOCUMENTS_FAILED', moduleLog, { - details: { reason: error instanceof Error ? error.message : 'unknown' }, + details: { + reason: error instanceof Error ? error.message : 'unknown', + ...(providerStatus ? { providerStatus } : {}), + ...(providerMessage ? { providerMessage } : {}), + }, }) } }, diff --git a/extensions/general/arcim-migration/lib/import-documents.ts b/extensions/general/arcim-migration/lib/import-documents.ts index b4cef44f..cda82482 100644 --- a/extensions/general/arcim-migration/lib/import-documents.ts +++ b/extensions/general/arcim-migration/lib/import-documents.ts @@ -35,7 +35,11 @@ import { downloadBokioUpload, type BokioUpload, } from '@/lib/providers/bokio/attachments' -import { FortnoxApiError, FortnoxClient } from '@/lib/providers/fortnox/client' +import { + FortnoxApiError, + FortnoxClient, + isFortnoxPermissionError, +} from '@/lib/providers/fortnox/client' import { downloadFortnoxArchiveFile, fetchFortnoxFileConnections, @@ -213,7 +217,7 @@ function fortnoxSource( } }) } catch (error) { - if (error instanceof FortnoxApiError && error.statusCode === 403) { + if (isFortnoxPermissionError(error)) { throw new FortnoxDocumentScopesRequiredError() } throw error @@ -386,11 +390,7 @@ export async function importProviderDocuments( } } - if ( - provider === 'fortnox' && - finalError instanceof FortnoxApiError && - finalError.statusCode === 403 - ) { + if (provider === 'fortnox' && isFortnoxPermissionError(finalError)) { throw new FortnoxDocumentScopesRequiredError() } diff --git a/lib/bokslut/arsredovisning/__tests__/parent-company-identifier.test.ts b/lib/bokslut/arsredovisning/__tests__/parent-company-identifier.test.ts new file mode 100644 index 00000000..48626d5d --- /dev/null +++ b/lib/bokslut/arsredovisning/__tests__/parent-company-identifier.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { isValidParentCompanyIdentifier } from '../parent-company-identifier' + +describe('isValidParentCompanyIdentifier', () => { + it('accepts Swedish organisationsnummer with and without the dash', () => { + expect(isValidParentCompanyIdentifier('556677-8899')).toBe(true) + expect(isValidParentCompanyIdentifier('5566778899')).toBe(true) + expect(isValidParentCompanyIdentifier('16556677-8899')).toBe(true) + expect(isValidParentCompanyIdentifier(' 559460-5627 ')).toBe(true) + }) + + it('rejects personnummer-shaped values in both 10- and 12-digit form', () => { + expect(isValidParentCompanyIdentifier('850101-1234')).toBe(false) + expect(isValidParentCompanyIdentifier('8501011234')).toBe(false) + expect(isValidParentCompanyIdentifier('19850101-1234')).toBe(false) + expect(isValidParentCompanyIdentifier('198501011234')).toBe(false) + expect(isValidParentCompanyIdentifier('20120101-1234')).toBe(false) + }) + + it('accepts foreign registration identifiers as written in the home register', () => { + expect(isValidParentCompanyIdentifier('CHE-123.456.789')).toBe(true) + expect(isValidParentCompanyIdentifier('CHE-123.456.789 MWST')).toBe(true) + expect(isValidParentCompanyIdentifier('923 609 016')).toBe(true) + expect(isValidParentCompanyIdentifier('HRB 12345')).toBe(true) + expect(isValidParentCompanyIdentifier('1234567-8')).toBe(true) + expect(isValidParentCompanyIdentifier('12345678')).toBe(true) + expect(isValidParentCompanyIdentifier('NL12345678')).toBe(true) + }) + + it('rejects empty, over-long and control-character values', () => { + expect(isValidParentCompanyIdentifier('')).toBe(false) + expect(isValidParentCompanyIdentifier(' ')).toBe(false) + expect(isValidParentCompanyIdentifier('A'.repeat(41))).toBe(false) + expect(isValidParentCompanyIdentifier('CHE-123