fix(arsredovisning): unblock the signing flow, accept foreign parent org nr, explain Fortnox underlag failures (#1738)
Batch from a real migration walkthrough (Fortnox -> Accounted, 2026-08-20): - Årsredovisning: the "Låst version" select was empty with no explanation because the only version was a draft and "Lås version för underskrift" is disabled while the four Lagstadgade upplysningar checkboxes and the content confirmation count as blockers. The select is now disabled with a hint that names the blocker count and links to Fullständighetskontroll, the four AR-NOTE-*-UNCONFIRMED issues carry remediation text, the lock button explains why it is grey, and "Markera som signerad" says what it still needs (locked version, bevisreferens, date). - Moderföretagets org.nr accepts a foreign registration identifier (CHE-123.456.789, HRB 12345, 923 609 016); personnummer shapes stay out. - Fortnox underlag discovery: log status, body and Fortnox's message on failure, show the message in the UI, treat a 400 with behörighet/scope text as scopes-required, and fall back to an unfiltered voucherfileconnections list when the financialyear filter answers 400. - Kontomapping: the Momskod column had min-w only; table-fixed collapsed it and its selects overflowed into Konfidens. Real w-72 now. - SIE import warnings pluralise correctly for one skipped voucher; the Verifikationsserie option says the source series is preserved. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
5a8dd21931
commit
e733ab7c43
@@ -1105,6 +1105,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -94,6 +94,7 @@ export default function ArsredovisningPage() {
|
||||
const [signerName, setSignerName] = useState('')
|
||||
const [signerRole, setSignerRole] = useState('Styrelseledamot')
|
||||
const [versions, setVersions] = useState<AnnualReportVersionSummary[]>([])
|
||||
const [blockingCount, setBlockingCount] = useState<number | null>(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 (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
@@ -558,6 +588,7 @@ export default function ArsredovisningPage() {
|
||||
hasUnsavedNarrative={hasUnsavedNarrative}
|
||||
narrativeRevision={narrativeRevision}
|
||||
onVersionsChanged={handleVersionsChanged}
|
||||
onBlockingCountChanged={setBlockingCount}
|
||||
/>
|
||||
|
||||
<section>
|
||||
@@ -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"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Svenskt org.nr NNNNNN-NNNN. Utländskt moderföretag: registreringsnumret som
|
||||
det står i hemlandets register.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-parent-city">Moderföretagets säte</Label>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -858,20 +893,32 @@ export default function ArsredovisningPage() {
|
||||
<Select
|
||||
value={selectedSignatureVersionId}
|
||||
onValueChange={setSelectedSignatureVersionId}
|
||||
disabled={lockedVersions.length === 0}
|
||||
>
|
||||
<SelectTrigger id="signature-version">
|
||||
<SelectValue placeholder="Välj version" />
|
||||
<SelectValue
|
||||
placeholder={lockedVersions.length === 0 ? 'Ingen låst version ännu' : 'Välj version'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versions
|
||||
.filter((version) => version.status === 'ready_for_signature')
|
||||
.map((version) => (
|
||||
<SelectItem key={version.id} value={version.id}>
|
||||
Version {version.version_number}: {version.content_hash.slice(0, 12)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{lockedVersions.map((version) => (
|
||||
<SelectItem key={version.id} value={version.id}>
|
||||
Version {version.version_number}: {version.content_hash.slice(0, 12)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{noLockedVersionHint && (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{noLockedVersionHint}{' '}
|
||||
<a
|
||||
href="#ar-fullstandighetskontroll"
|
||||
className="text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground"
|
||||
>
|
||||
Gå till Fullständighetskontroll
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="signature-method">Underskriftsmetod</Label>
|
||||
@@ -925,6 +972,9 @@ export default function ArsredovisningPage() {
|
||||
Inga undertecknare tillagda än.
|
||||
</p>
|
||||
)}
|
||||
{pendingSignatureCount > 0 && signReadinessHint && (
|
||||
<p className="text-xs leading-5 text-muted-foreground">{signReadinessHint}</p>
|
||||
)}
|
||||
{signatures.map((sig) => (
|
||||
<div
|
||||
key={sig.id}
|
||||
|
||||
+19
@@ -121,6 +121,25 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for a personnummer-shaped parent company org number', async () => {
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="ar-fullstandighetskontroll" className="scroll-mt-24">
|
||||
<div className="mb-1 flex items-center gap-2 px-1">
|
||||
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">{t('checks_title')}</h3>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
@@ -561,6 +569,13 @@ export function AnnualReportStudio({
|
||||
{t('lock_version')}
|
||||
</Button>
|
||||
</div>
|
||||
{(blockingIssues.length > 0 || hasUnsavedNarrative) && (
|
||||
<p className="text-right text-xs leading-5 text-muted-foreground">
|
||||
{hasUnsavedNarrative
|
||||
? t('lock_hint_unsaved')
|
||||
: t('lock_hint_blocked', { count: blockingIssues.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-3 border-t border-border py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Verifikationsserie</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">Serie för importerade verifikationer</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t('ext_arcim_option_series_help')}</p>
|
||||
</div>
|
||||
<Input
|
||||
className="w-16 text-center"
|
||||
@@ -1597,6 +1598,13 @@ function DocumentImportFollowUp({
|
||||
? t('ext_arcim_documents_discovery_error')
|
||||
: t('ext_arcim_documents_import_error')}
|
||||
</p>
|
||||
{state.problem?.providerMessage && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ext_arcim_documents_provider_message', {
|
||||
message: state.problem.providerMessage,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{state.problem?.requestId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ext_arcim_documents_error_reference', {
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -248,7 +248,10 @@ export default function AccountMappingStep({
|
||||
<TableHead className="w-64 max-w-64">Källnamn</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead className="w-64">Målkonto</TableHead>
|
||||
<TableHead className="min-w-72">{t('vat_treatment_column')}</TableHead>
|
||||
{/* 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). */}
|
||||
<TableHead className="w-72">{t('vat_treatment_column')}</TableHead>
|
||||
<TableHead className="w-24">Konfidens</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-32 min-w-32 border-l border-border bg-background text-right">
|
||||
{t('vat_treatment_confirm')}
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<script>')).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier('-123')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -289,30 +289,37 @@ export function validateAnnualReportCompleteness(
|
||||
push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
|
||||
}
|
||||
|
||||
const disclosureChecks: Array<[boolean, string, string]> = [
|
||||
// Each confirmation is a checkbox under "Lagstadgade upplysningar" on the
|
||||
// årsredovisning page, persisted by "Spara texten". Say so: a bare "är
|
||||
// inte bekräftad" left a real user hunting for the switch (2026-08-20).
|
||||
const disclosureChecks: Array<[boolean, string, string, string]> = [
|
||||
[
|
||||
disclosures.long_term_debt_over_five_years_confirmed,
|
||||
'AR-NOTE-LONG-DEBT-UNCONFIRMED',
|
||||
'Uppgiften om långfristiga skulder som förfaller efter mer än fem år är inte bekräftad.',
|
||||
'Kryssa i "Jag har kontrollerat uppgiften" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
|
||||
],
|
||||
[
|
||||
disclosures.securities_pledged_confirmed,
|
||||
'AR-NOTE-SECURITIES-UNCONFIRMED',
|
||||
'Uppgiften om ställda säkerheter är inte bekräftad.',
|
||||
'Kryssa i "Jag har kontrollerat ställda säkerheter" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
|
||||
],
|
||||
[
|
||||
disclosures.contingent_liabilities_confirmed,
|
||||
'AR-NOTE-CONTINGENT-UNCONFIRMED',
|
||||
'Uppgiften om eventualförpliktelser är inte bekräftad.',
|
||||
'Kryssa i "Jag har kontrollerat eventualförpliktelser" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
|
||||
],
|
||||
[
|
||||
disclosures.parent_company_confirmed,
|
||||
'AR-NOTE-PARENT-UNCONFIRMED',
|
||||
'Uppgiften om koncern- och moderföretagsförhållanden är inte bekräftad.',
|
||||
'Kryssa i "Jag har kontrollerat koncernförhållandet" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
|
||||
],
|
||||
]
|
||||
for (const [confirmed, code, message] of disclosureChecks) {
|
||||
if (!confirmed) push(issues, code, 'error', 'notes', message)
|
||||
for (const [confirmed, code, message, remediation] of disclosureChecks) {
|
||||
if (!confirmed) push(issues, code, 'error', 'notes', message, remediation)
|
||||
}
|
||||
|
||||
if (report.accounting_framework === 'k3') {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Validation for the moderföretag identifier in the årsredovisning note
|
||||
* (ÅRL 5 kap. 21 §: namn, organisationsnummer/motsvarande och säte).
|
||||
*
|
||||
* Two shapes are accepted:
|
||||
*
|
||||
* 1. Swedish organisationsnummer, NNNNNN-NNNN or the 12-digit 16NNNNNNNNNN
|
||||
* form, dash optional. The third digit must be 2-9: that is what separates
|
||||
* legal-entity numbers from personnummer (whose third digit is part of a
|
||||
* month, 0-1). Personnummer are out of scope for the disclosure and a GDPR
|
||||
* Art. 5(1)(c) data-minimisation concern if persisted, so both the 10-digit
|
||||
* and the century-prefixed 12-digit personnummer shapes are rejected.
|
||||
* 2. A foreign registration identifier as written in the home register:
|
||||
* CHE-123.456.789 (Switzerland), 923 609 016 (Norway), HRB 12345
|
||||
* (Germany), 1234567-8 (Finland), 12345678 (UK CRN, DK CVR) and so on.
|
||||
* Letters, digits, space, dot, comma, dash and slash, 2-40 characters.
|
||||
* A foreign 10- or 12-digit all-numeric value is treated as Swedish-shaped
|
||||
* and falls under rule 1; that false negative is accepted in exchange for
|
||||
* never storing a personnummer.
|
||||
*/
|
||||
|
||||
const FOREIGN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9 .,\-/]{1,39}$/
|
||||
|
||||
export function isValidParentCompanyIdentifier(raw: string): boolean {
|
||||
const value = raw.trim()
|
||||
if (!value) return false
|
||||
|
||||
const digitsOnly = value.replace(/[\s-]/g, '')
|
||||
const allDigits = /^\d+$/.test(digitsOnly)
|
||||
if (allDigits && (digitsOnly.length === 10 || digitsOnly.length === 12)) {
|
||||
if (digitsOnly.length === 12 && !digitsOnly.startsWith('16')) return false
|
||||
const core = digitsOnly.length === 12 ? digitsOnly.slice(2) : digitsOnly
|
||||
return /^\d{2}[2-9]\d{7}$/.test(core)
|
||||
}
|
||||
|
||||
return FOREIGN_IDENTIFIER.test(value)
|
||||
}
|
||||
|
||||
export const PARENT_COMPANY_IDENTIFIER_ERROR =
|
||||
'Ogiltigt organisationsnummer (svenskt NNNNNN-NNNN, ej personnummer). Utländskt moderföretag: ange registreringsnumret som det står i hemlandets register, t.ex. CHE-123.456.789.'
|
||||
@@ -2404,11 +2404,11 @@ export async function executeSIEImport(
|
||||
const totalSkipped = voucherResults.skippedEmpty + voucherResults.skippedSingleLine + voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped
|
||||
if (totalSkipped > 0) {
|
||||
const parts: string[] = []
|
||||
if (voucherResults.skippedEmpty > 0) parts.push(`${voucherResults.skippedEmpty} tomma`)
|
||||
if (voucherResults.skippedEmpty > 0) parts.push(`${voucherResults.skippedEmpty} ${voucherResults.skippedEmpty === 1 ? 'tom' : 'tomma'}`)
|
||||
if (voucherResults.skippedUnbalanced > 0) parts.push(`${voucherResults.skippedUnbalanced} obalanserade`)
|
||||
if (voucherResults.skippedUnmapped > 0) parts.push(`${voucherResults.skippedUnmapped} med ej mappade konton`)
|
||||
result.warnings.push(
|
||||
`${totalSkipped} verifikationer hoppades över (ofullständiga i källsystemet): ${parts.join(', ')}`
|
||||
`${totalSkipped} ${totalSkipped === 1 ? 'verifikation' : 'verifikationer'} hoppades över (${totalSkipped === 1 ? 'ofullständig' : 'ofullständiga'} i källsystemet): ${parts.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2419,7 +2419,7 @@ export async function executeSIEImport(
|
||||
.slice(0, 10)
|
||||
.map(d => d.voucherId)
|
||||
result.warnings.push(
|
||||
`${voucherResults.skippedSingleLine} enradsverifikationer hoppades över (kan vara periodiseringar/manuella justeringar): ${singleLineDetails.join(', ')}${voucherResults.skippedSingleLine > 10 ? '...' : ''}`
|
||||
`${voucherResults.skippedSingleLine} ${voucherResults.skippedSingleLine === 1 ? 'enradsverifikation' : 'enradsverifikationer'} hoppades över (kan vara periodiseringar/manuella justeringar): ${singleLineDetails.join(', ')}${voucherResults.skippedSingleLine > 10 ? '...' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,47 @@ describe('Fortnox attachments', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to one unfiltered listing when Fortnox rejects the financialyear filter with 400', async () => {
|
||||
const getPaginated = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, ''))
|
||||
.mockResolvedValueOnce([
|
||||
{ FileId: 'file-1', Name: 'kvitto.pdf', VoucherSeries: 'A', VoucherNumber: 12, VoucherYear: 3 },
|
||||
{ FileId: 'file-2', Name: 'faktura.png', VoucherSeries: 'B', VoucherNumber: 7, VoucherYear: 4 },
|
||||
{ FileId: 'file-9', Name: 'gammalt.pdf', VoucherSeries: 'A', VoucherNumber: 1, VoucherYear: 1 },
|
||||
]);
|
||||
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
|
||||
|
||||
await expect(fetchFortnoxFileConnections(client, 'token', [3, 4])).resolves.toEqual([
|
||||
{ fileId: 'file-1', name: 'kvitto.pdf', series: 'A', number: 12, financialYearId: 3 },
|
||||
{ fileId: 'file-2', name: 'faktura.png', series: 'B', number: 7, financialYearId: 4 },
|
||||
]);
|
||||
expect(getPaginated).toHaveBeenCalledTimes(2);
|
||||
expect(getPaginated).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
'/voucherfileconnections',
|
||||
'VoucherFileConnections',
|
||||
{ pageSize: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates a 400 from the unfiltered fallback and non-400 failures untouched', async () => {
|
||||
const scopeError = new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, 'behörighet');
|
||||
const getPaginated = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, ''))
|
||||
.mockRejectedValueOnce(scopeError);
|
||||
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
|
||||
await expect(fetchFortnoxFileConnections(client, 'token', [3])).rejects.toBe(scopeError);
|
||||
|
||||
const forbidden = new FortnoxApiError('forbidden', 403);
|
||||
const client403 = clientWith({
|
||||
getPaginated: vi.fn().mockRejectedValue(forbidden),
|
||||
} as Partial<FortnoxClient>);
|
||||
await expect(fetchFortnoxFileConnections(client403, 'token', [3])).rejects.toBe(forbidden);
|
||||
});
|
||||
|
||||
it('downloads through the archive path without fallback when it succeeds', async () => {
|
||||
const response = { bytes: new ArrayBuffer(2), contentType: 'application/pdf' };
|
||||
const getBinary = vi.fn().mockResolvedValue(response);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FortnoxApiError, fortnoxErrorMessage, isFortnoxPermissionError } from '../client';
|
||||
|
||||
describe('fortnoxErrorMessage', () => {
|
||||
it('reads ErrorInformation.message from a Fortnox error body', () => {
|
||||
const error = new FortnoxApiError(
|
||||
'Fortnox API error: 400 Bad Request',
|
||||
400,
|
||||
'{"ErrorInformation":{"error":1,"message":"Kan inte hitta kontot.","code":2000423}}',
|
||||
);
|
||||
expect(fortnoxErrorMessage(error)).toBe('Kan inte hitta kontot.');
|
||||
});
|
||||
|
||||
it('falls back to the raw body when it is not JSON and to null when empty', () => {
|
||||
expect(fortnoxErrorMessage(new FortnoxApiError('x', 500, 'Gateway timeout'))).toBe(
|
||||
'Gateway timeout',
|
||||
);
|
||||
expect(fortnoxErrorMessage(new FortnoxApiError('x', 500, ''))).toBeNull();
|
||||
expect(fortnoxErrorMessage(new Error('not fortnox'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFortnoxPermissionError', () => {
|
||||
it('treats 403 as a permission failure regardless of body', () => {
|
||||
expect(isFortnoxPermissionError(new FortnoxApiError('forbidden', 403))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a 400 with a behörighet/scope/licens message as a permission failure', () => {
|
||||
const error = new FortnoxApiError(
|
||||
'Fortnox API error: 400 Bad Request',
|
||||
400,
|
||||
'{"ErrorInformation":{"error":1,"message":"Du saknar behörighet till denna resurs.","code":2000663}}',
|
||||
);
|
||||
expect(isFortnoxPermissionError(error)).toBe(true);
|
||||
expect(
|
||||
isFortnoxPermissionError(
|
||||
new FortnoxApiError('x', 400, '{"ErrorInformation":{"message":"Invalid scope"}}'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not classify an ordinary 400 or a non-Fortnox error as permission', () => {
|
||||
expect(
|
||||
isFortnoxPermissionError(
|
||||
new FortnoxApiError('x', 400, '{"ErrorInformation":{"message":"Kan inte hitta kontot."}}'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isFortnoxPermissionError(new FortnoxApiError('x', 400, ''))).toBe(false);
|
||||
expect(isFortnoxPermissionError(new Error('boom'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,16 @@ export async function fetchFortnoxFinancialYears(
|
||||
return years;
|
||||
}
|
||||
|
||||
/** Fetch and deduplicate voucher file connections for each financial year. */
|
||||
/**
|
||||
* Fetch and deduplicate voucher file connections for each financial year.
|
||||
*
|
||||
* Fortnox is asked per financial year first. If it answers 400 to the
|
||||
* `financialyear` filter (seen live 2026-08-20: the resource took the filter
|
||||
* in the docs' POST example but rejected the list call), one unfiltered
|
||||
* listing is fetched instead and rows are selected on their own VoucherYear.
|
||||
* A 400 that is really a missing scope/licence surfaces again on the
|
||||
* unfiltered call and propagates to the caller's permission handling.
|
||||
*/
|
||||
export async function fetchFortnoxFileConnections(
|
||||
client: FortnoxClient,
|
||||
accessToken: string,
|
||||
@@ -61,21 +70,16 @@ export async function fetchFortnoxFileConnections(
|
||||
): Promise<FortnoxFileConnection[]> {
|
||||
const connections: FortnoxFileConnection[] = [];
|
||||
const seen = new Set<string>();
|
||||
const wantedYears = new Set(financialYearIds);
|
||||
|
||||
for (const financialYearId of new Set(financialYearIds)) {
|
||||
const rawConnections = await client.getPaginated<Record<string, unknown>>(
|
||||
accessToken,
|
||||
`/voucherfileconnections?financialyear=${financialYearId}`,
|
||||
'VoucherFileConnections',
|
||||
{ pageSize: PAGE_SIZE },
|
||||
);
|
||||
|
||||
const collect = (rawConnections: Record<string, unknown>[]) => {
|
||||
for (const raw of rawConnections) {
|
||||
const fileId = typeof raw.FileId === 'string' ? raw.FileId.trim() : '';
|
||||
const series = typeof raw.VoucherSeries === 'string' ? raw.VoucherSeries.trim() : '';
|
||||
const number = finiteInteger(raw.VoucherNumber);
|
||||
const itemFinancialYearId = finiteInteger(raw.VoucherYear);
|
||||
if (!fileId || !series || number == null || itemFinancialYearId == null) continue;
|
||||
if (!wantedYears.has(itemFinancialYearId)) continue;
|
||||
|
||||
const key = `${fileId}|${itemFinancialYearId}|${series}|${number}`;
|
||||
if (seen.has(key)) continue;
|
||||
@@ -90,6 +94,30 @@ export async function fetchFortnoxFileConnections(
|
||||
financialYearId: itemFinancialYearId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const financialYearId of wantedYears) {
|
||||
let rawConnections: Record<string, unknown>[];
|
||||
try {
|
||||
rawConnections = await client.getPaginated<Record<string, unknown>>(
|
||||
accessToken,
|
||||
`/voucherfileconnections?financialyear=${financialYearId}`,
|
||||
'VoucherFileConnections',
|
||||
{ pageSize: PAGE_SIZE },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof FortnoxApiError) || error.statusCode !== 400) throw error;
|
||||
collect(
|
||||
await client.getPaginated<Record<string, unknown>>(
|
||||
accessToken,
|
||||
'/voucherfileconnections',
|
||||
'VoucherFileConnections',
|
||||
{ pageSize: PAGE_SIZE },
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
collect(rawConnections);
|
||||
}
|
||||
|
||||
return connections;
|
||||
|
||||
@@ -17,6 +17,40 @@ export class FortnoxApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable message from a Fortnox error body
|
||||
* ({"ErrorInformation":{"error":1,"message":"...","code":2000423}}), or null
|
||||
* when the body is empty or not in that shape.
|
||||
*/
|
||||
export function fortnoxErrorMessage(error: unknown): string | null {
|
||||
if (!(error instanceof FortnoxApiError) || !error.body) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(error.body) as {
|
||||
ErrorInformation?: { message?: unknown; Message?: unknown };
|
||||
message?: unknown;
|
||||
};
|
||||
const message =
|
||||
parsed?.ErrorInformation?.message ?? parsed?.ErrorInformation?.Message ?? parsed?.message;
|
||||
return typeof message === 'string' && message.trim() ? message.trim().slice(0, 300) : null;
|
||||
} catch {
|
||||
const text = error.body.trim();
|
||||
return text ? text.slice(0, 300) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing scope or licence. Fortnox documents 403 for failed authorisation,
|
||||
* but live answers for an unlicensed/unscoped resource have also come back
|
||||
* as 400 with a behörighet/scope/licens message, so both are recognised.
|
||||
*/
|
||||
export function isFortnoxPermissionError(error: unknown): error is FortnoxApiError {
|
||||
if (!(error instanceof FortnoxApiError)) return false;
|
||||
if (error.statusCode === 403) return true;
|
||||
if (error.statusCode !== 400) return false;
|
||||
const text = `${error.body ?? ''} ${fortnoxErrorMessage(error) ?? ''}`;
|
||||
return /beh[öo]righet|scope|licens|licence|license|permission|unauthori[sz]ed/i.test(text);
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (isTimeoutError(error)) return true;
|
||||
if (error instanceof FortnoxApiError) {
|
||||
|
||||
@@ -5446,6 +5446,8 @@
|
||||
"ext_arcim_documents_retry_discovery": "Check again",
|
||||
"ext_arcim_documents_retry_import": "Try importing again",
|
||||
"ext_arcim_documents_error_reference": "Error reference: {requestId}",
|
||||
"ext_arcim_documents_provider_message": "Source system response: {message}",
|
||||
"ext_arcim_option_series_help": "Vouchers keep their series from the source system (A, D, E ...). The series here is only used for vouchers without one.",
|
||||
"ext_arcim_documents_result_description": "The document import is complete.",
|
||||
"ext_arcim_documents_imported": "Imported",
|
||||
"ext_arcim_documents_skipped": "Already present",
|
||||
@@ -7538,6 +7540,8 @@
|
||||
"signer_roster_confirmed": "Styrelse och VD är kontrollerade mot Bolagsverket",
|
||||
"create_snapshot": "Skapa versionsutkast",
|
||||
"lock_version": "Lås version för underskrift",
|
||||
"lock_hint_blocked": "{count, plural, one {Lås version för underskrift blir tillgänglig när det blockerande felet ovan är åtgärdat.} other {Lås version för underskrift blir tillgänglig när de # blockerande felen ovan är åtgärdade.}}",
|
||||
"lock_hint_unsaved": "Spara texten längre ner innan versionen kan låsas.",
|
||||
"versions_title": "Versioner och dokument",
|
||||
"versions_empty": "Inga versionsutkast har skapats ännu.",
|
||||
"version_label": "Version {number}",
|
||||
|
||||
@@ -5446,6 +5446,8 @@
|
||||
"ext_arcim_documents_retry_discovery": "Kontrollera igen",
|
||||
"ext_arcim_documents_retry_import": "Försök importera igen",
|
||||
"ext_arcim_documents_error_reference": "Felreferens: {requestId}",
|
||||
"ext_arcim_documents_provider_message": "Svar från källsystemet: {message}",
|
||||
"ext_arcim_option_series_help": "Verifikat behåller sin serie från källsystemet (A, D, E ...). Serien här används bara för verifikat som saknar serie.",
|
||||
"ext_arcim_documents_result_description": "Underlagsimporten är klar.",
|
||||
"ext_arcim_documents_imported": "Importerade",
|
||||
"ext_arcim_documents_skipped": "Fanns redan",
|
||||
@@ -7538,6 +7540,8 @@
|
||||
"signer_roster_confirmed": "Styrelse och VD är kontrollerade mot Bolagsverket",
|
||||
"create_snapshot": "Skapa versionsutkast",
|
||||
"lock_version": "Lås version för underskrift",
|
||||
"lock_hint_blocked": "{count, plural, one {Lås version för underskrift blir tillgänglig när det blockerande felet ovan är åtgärdat.} other {Lås version för underskrift blir tillgänglig när de # blockerande felen ovan är åtgärdade.}}",
|
||||
"lock_hint_unsaved": "Spara texten längre ner innan versionen kan låsas.",
|
||||
"versions_title": "Versioner och dokument",
|
||||
"versions_empty": "Inga versionsutkast har skapats ännu.",
|
||||
"version_label": "Version {number}",
|
||||
|
||||
Reference in New Issue
Block a user