diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index e3f30677..a568eacc 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -1490,6 +1490,7 @@ function openingBalanceSentence(ob: NonNullable 0) parts.push(`${d.skippedVouchers.empty} tomma`) if (d.skippedVouchers.unbalanced > 0) parts.push(`${d.skippedVouchers.unbalanced} obalanserade`) if (d.skippedVouchers.singleLine > 0) parts.push(`${d.skippedVouchers.singleLine} enradiga`) - if (d.skippedVouchers.unmapped > 0) parts.push(`${d.skippedVouchers.unmapped} med ej kopplade konton`) + if (d.skippedVouchers.unmapped > 0) { + // Name the accounts (issue #2212): a count alone sends the user to diff + // the general ledger against the source system by hand. + const perAccount = (d.skippedVouchers.unmappedAccounts ?? []) + .map((a) => `konto ${a.account}: ${a.vouchers}`) + .join(', ') + parts.push( + `${d.skippedVouchers.unmapped} med ej kopplade konton${perAccount ? ` (${perAccount})` : ''}` + ) + } warningSentences.push( `${d.skippedVouchers.total} verifikationer hoppades över (${parts.join(', ')}): saldon har justerats automatiskt via omföringsverifikation.` ) @@ -1526,6 +1536,12 @@ function FiscalYearLine({ result, index }: { result: ImportResult; index: number ) const infoLines: string[] = [] + // Accounts the import inserted into the chart (self-mapped source accounts + // the company did not have). Said out loud so "did the import go right?" + // has an answer on screen instead of in a chart-of-accounts diff. + if (result.accountsCreated && result.accountsCreated > 0) { + infoLines.push(t('ext_arcim_accounts_created', { count: result.accountsCreated })) + } if (d?.openingBalance) infoLines.push(openingBalanceSentence(d.openingBalance)) if (d?.migrationAdjustment?.created) { infoLines.push( diff --git a/components/import/AccountMappingStep.tsx b/components/import/AccountMappingStep.tsx index 3c4c9166..763efd2e 100644 --- a/components/import/AccountMappingStep.tsx +++ b/components/import/AccountMappingStep.tsx @@ -31,6 +31,7 @@ import { Filter, } from 'lucide-react' import type { AccountMapping } from '@/lib/import/types' +import { isValidBASRange } from '@/lib/import/account-mapper' import type { BASAccount } from '@/types' import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions' import { @@ -61,7 +62,7 @@ interface AccountMappingStepProps { onBack: () => void } -type FilterType = 'all' | 'unmapped' | 'vat_review' | 'low_confidence' | 'manual' +type FilterType = 'all' | 'unmapped' | 'new_account' | 'vat_review' | 'low_confidence' | 'manual' const PAGE_SIZE = 50 @@ -84,6 +85,19 @@ export default function AccountMappingStep({ }) const [currentPage, setCurrentPage] = useState(1) + // Targets the dropdown can name: the caller's list (the company chart, or + // chart + BAS). A mapped target outside it is an account the import will + // CREATE (syncMappedAccounts inserts every missing target, class and type + // derived from the number). Such a row is mapped and valid, but a Select + // whose value matches no option renders blank, which is how a self-mapped + // Fortnox account outside BAS looked unmapped and unmappable (issue #2212). + // Every target outside this set is therefore rendered as an explicit + // "created on import" option. + const knownTargets = useMemo( + () => new Set(basAccounts.map((a) => a.account_number)), + [basAccounts], + ) + // Filter and search mappings const filteredMappings = useMemo(() => { let result = mappings @@ -93,6 +107,9 @@ export default function AccountMappingStep({ case 'unmapped': result = result.filter((m) => !m.targetAccount) break + case 'new_account': + result = result.filter((m) => m.targetAccount && !knownTargets.has(m.targetAccount)) + break case 'low_confidence': result = result.filter((m) => m.targetAccount && m.confidence < 0.7) break @@ -117,7 +134,7 @@ export default function AccountMappingStep({ } return result - }, [mappings, filter, searchTerm]) + }, [mappings, filter, searchTerm, knownTargets]) // Pagination const totalPages = Math.ceil(filteredMappings.length / PAGE_SIZE) @@ -140,11 +157,20 @@ export default function AccountMappingStep({ // Calculate stats const stats = useMemo(() => { const unmapped = mappings.filter((m) => !m.targetAccount).length + const newAccounts = mappings.filter((m) => m.targetAccount && !knownTargets.has(m.targetAccount)).length const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.7).length const manual = mappings.filter((m) => m.isOverride).length const vatReview = mappings.filter((m) => m.requiresVatTreatmentReview && !m.vatTreatmentReviewed).length - return { unmapped, lowConfidence, manual, vatReview } - }, [mappings]) + return { unmapped, newAccounts, lowConfidence, manual, vatReview } + }, [mappings, knownTargets]) + + // After the mapper's self-map rule, an unmapped row is always a number + // outside 1000-8999: nothing can be created for it, so the user must pick a + // target. Name them so the disabled Continue button is not the only signal. + const unmappedAccounts = useMemo( + () => mappings.filter((m) => !m.targetAccount).map((m) => m.sourceAccount), + [mappings], + ) const canContinue = stats.unmapped === 0 && stats.vatReview === 0 @@ -168,7 +194,8 @@ export default function AccountMappingStep({ Kontomappning Varje konto i SIE-filen kopplas till ett konto i din kontoplan. - De flesta matchas automatiskt: granska de osäkra nedan. + De flesta matchas automatiskt: granska de osäkra nedan.{' '} + {t('mapping_new_accounts_note')} @@ -190,6 +217,14 @@ export default function AccountMappingStep({ {stats.unmapped} ej mappade + 0 ? 'secondary' : 'outline'} + className="cursor-pointer" + onClick={() => handleFilterChange('new_account')} + > + + {t('new_account_filter', { count: stats.newAccounts })} + 0 ? 'secondary' : 'outline'} className="cursor-pointer" @@ -215,6 +250,12 @@ export default function AccountMappingStep({ + {unmappedAccounts.length > 0 && ( +

+ {t('unmapped_out_of_range_help', { accounts: unmappedAccounts.join(', ') })} +

+ )} + {/* Search and filter */}
@@ -234,6 +275,7 @@ export default function AccountMappingStep({ Visa alla Ej mappade + {t('new_account_filter', { count: stats.newAccounts })} {t('vat_review_filter', { count: stats.vatReview })} Osäkra Manuellt satta @@ -274,7 +316,11 @@ export default function AccountMappingStep({ > {mapping.sourceAccount} - + {mapping.sourceName ? ( + + ) : ( + {t('source_name_missing')} + )} @@ -284,10 +330,15 @@ export default function AccountMappingStep({ value={mapping.targetAccount || 'none'} onValueChange={(value) => { const account = basAccounts.find((a) => a.account_number === value) + // A target outside the list is created on import + // under the file's name (the identity option) or + // the name the row already carries. + const createdName = + value === mapping.sourceAccount ? mapping.sourceName : mapping.targetName onMappingChange( mapping.sourceAccount, value === 'none' ? '' : value, - account?.account_name || '' + account?.account_name ?? createdName ?? '' ) }} > @@ -296,6 +347,15 @@ export default function AccountMappingStep({ -- Välj konto -- + {createdOptionsFor(mapping, knownTargets).map((option) => ( + + {option.value} + {option.name} + + ({t('new_account_option')}) + + + ))} {Object.entries(accountsByClass).map(([className, accounts]) => (
@@ -495,6 +555,37 @@ export default function AccountMappingStep({ ) } +/** + * The "created on import" options a row can select: its own number (when + * that is in the auto-create range and not already in the chart) and, if the + * row currently points at some other target the list cannot name, that + * target too, so the current value always renders. Ordered so the identity + * option comes first. + */ +function createdOptionsFor( + mapping: AccountMapping, + knownTargets: ReadonlySet, +): Array<{ value: string; name: string }> { + const options: Array<{ value: string; name: string }> = [] + if (!knownTargets.has(mapping.sourceAccount) && isValidBASRange(mapping.sourceAccount)) { + options.push({ + value: mapping.sourceAccount, + name: mapping.sourceName || `Konto ${mapping.sourceAccount}`, + }) + } + if ( + mapping.targetAccount && + mapping.targetAccount !== mapping.sourceAccount && + !knownTargets.has(mapping.targetAccount) + ) { + options.push({ + value: mapping.targetAccount, + name: mapping.targetName || `Konto ${mapping.targetAccount}`, + }) + } + return options +} + function TruncatedSourceName({ sourceName }: { sourceName: string }) { const [open, setOpen] = useState(false) diff --git a/extensions/general/arcim-migration/__tests__/mapping-resolution.test.ts b/extensions/general/arcim-migration/__tests__/mapping-resolution.test.ts new file mode 100644 index 00000000..fb695b16 --- /dev/null +++ b/extensions/general/arcim-migration/__tests__/mapping-resolution.test.ts @@ -0,0 +1,97 @@ +/** + * Issue #2212: the guided import's mapping step offered no target for a + * Fortnox account that exists in neither the company chart nor BAS (4599), + * and the user could not tell whether the import handled it. + * + * These tests run the exact resolution /sie-data runs (buildMappingTargets + * then suggestMappings) and pin the contract the mapping step now renders: + * - an in-range source account the chart lacks resolves to ITSELF and is + * created on import with the class and type the importer derives from + * the number (classifyAccount: the same helper syncMappedAccounts uses); + * - that holds with or without a #KONTO name; + * - a number outside 1000-8999 stays unresolved and blocks the step. + */ +import { describe, expect, it } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { buildMappingTargets } from '../lib/mapping-targets' +import { suggestMappings, validateMappings, isValidBASRange } from '@/lib/import/account-mapper' +import { classifyAccount } from '@/lib/bookkeeping/account-classifier' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' + +/** A company whose chart holds only the given rows (one page, then empty). */ +function supabaseWithChart(rows: Array>): SupabaseClient { + let served = false + const builder = { + select: () => builder, + eq: () => builder, + order: () => builder, + range: () => Promise.resolve({ data: served ? [] : ((served = true), rows), error: null }), + } + return { from: () => builder } as unknown as SupabaseClient +} + +describe('guided import: resolving a source account the target chart lacks', () => { + it('4599 is in neither BAS nor an empty chart, so no dropdown target exists for it', async () => { + expect(getBASReference('4599')).toBeUndefined() + const targets = await buildMappingTargets(supabaseWithChart([]), 'company-1') + expect(targets.find((t) => t.account_number === '4599')).toBeUndefined() + }) + + // The user's case: a #KONTO row with a name and no postings in any exported + // year. It resolves to itself; the import creates it under the file's name. + it('self-maps a named #KONTO-only account onto its own number', async () => { + const targets = await buildMappingTargets(supabaseWithChart([]), 'company-1') + const [mapping] = suggestMappings([{ number: '4599', name: 'Justering inköp' }], targets) + + expect(mapping.targetAccount).toBe('4599') + expect(mapping.targetName).toBe('Justering inköp') + expect(mapping.matchType).toBe('bas_range') + expect(validateMappings([mapping]).valid).toBe(true) + }) + + // The other route to the same screen: an account referenced by #TRANS/#IB + // without a #KONTO row arrives nameless. It must resolve the same way; the + // importer names it "Konto 4599" (account-sync fallback, tested there). + it('self-maps a nameless account referenced only by transactions', async () => { + const targets = await buildMappingTargets(supabaseWithChart([]), 'company-1') + const [mapping] = suggestMappings([{ number: '4599', name: '' }], targets) + + expect(mapping.targetAccount).toBe('4599') + expect(mapping.matchType).toBe('bas_range') + expect(validateMappings([mapping]).valid).toBe(true) + }) + + // The type the created account gets is derived from the number range by + // the importer's own classifier, never asked of the user. + it('derives the created account type from the number range', () => { + expect(classifyAccount('4599')).toEqual({ account_type: 'expense', normal_balance: 'debit' }) + expect(classifyAccount('1932')).toEqual({ account_type: 'asset', normal_balance: 'debit' }) + expect(classifyAccount('2093')).toEqual({ account_type: 'equity', normal_balance: 'credit' }) + }) + + // Nothing can be created for a number outside BAS: the step must block and + // the user must pick a chart account for its postings. + it('leaves an out-of-range account unresolved so the step blocks', async () => { + const targets = await buildMappingTargets(supabaseWithChart([]), 'company-1') + const [mapping] = suggestMappings([{ number: '9100', name: 'Internt konto' }], targets) + + expect(mapping.targetAccount).toBe('') + expect(isValidBASRange('9100')).toBe(false) + expect(validateMappings([mapping])).toMatchObject({ valid: false, unmappedAccounts: ['9100'] }) + }) + + // A company that already renamed the account keeps its own row as the + // target (exact match on the chart), so nothing is created twice. + it('prefers the company row when the account already exists in the chart', async () => { + const targets = await buildMappingTargets( + supabaseWithChart([{ account_number: '4599', account_name: 'Eget namn', account_class: 4 }]), + 'company-1', + ) + const [mapping] = suggestMappings([{ number: '4599', name: 'Justering inköp' }], targets) + + expect(mapping.targetAccount).toBe('4599') + expect(mapping.targetName).toBe('Eget namn') + expect(mapping.matchType).toBe('exact') + }) +}) diff --git a/lib/import/__tests__/account-mapper.test.ts b/lib/import/__tests__/account-mapper.test.ts index c08975d2..fde55988 100644 --- a/lib/import/__tests__/account-mapper.test.ts +++ b/lib/import/__tests__/account-mapper.test.ts @@ -108,6 +108,20 @@ describe('suggestMappings', () => { expect(result[0].matchType).toBe('bas_range') }) + // Issue #2212: an account referenced only by #TRANS/#IB arrives without a + // #KONTO name. Refusing the self-map left it unmapped with no self-target to + // pick, while the parser had already promised it would be created. + it('self-maps a nameless in-range account (referenced without #KONTO)', () => { + const source = [makeSIEAccount('4599', '')] + const result = suggestMappings(source, basAccounts) + + expect(result).toHaveLength(1) + expect(result[0].targetAccount).toBe('4599') + expect(result[0].targetName).toBe('') + expect(result[0].matchType).toBe('bas_range') + expect(result[0].confidence).toBe(0.7) + }) + it('does not self-map accounts outside BAS range (9000+)', () => { const source = [makeSIEAccount('9100', 'Internt konto')] const result = suggestMappings(source, basAccounts) diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 2ddf7332..ba95d0fd 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -6,6 +6,7 @@ import { ensureFiscalPeriod, precheckFiscalPeriod, importVouchers, + summarizeUnmappedSkips, computeVoucherNumberRanges, linkOpeningBalanceEntryToPeriod, companyHasPriorActivity, @@ -1845,3 +1846,25 @@ describe('precheckFiscalPeriod', () => { ).rejects.toThrow(precheck.verdict === 'conflict' ? precheck.message : 'unreachable') }) }) + +describe('summarizeUnmappedSkips', () => { + // Issue #2212: the result step must name WHICH accounts excluded vouchers, + // not just how many vouchers were excluded. + it('counts vouchers per unmapped account, most excluded first', () => { + const summary = summarizeUnmappedSkips([ + { reason: 'unmapped', unmappedAccounts: ['0099'] }, + { reason: 'unmapped', unmappedAccounts: ['0099', '9100'] }, + { reason: 'unmapped', unmappedAccounts: ['9100', '9100'] }, + { reason: 'unbalanced' }, + { reason: 'single_line', unmappedAccounts: ['0099'] }, + ]) + expect(summary).toEqual([ + { account: '0099', vouchers: 2 }, + { account: '9100', vouchers: 2 }, + ]) + }) + + it('is empty when nothing was skipped for a missing mapping', () => { + expect(summarizeUnmappedSkips([{ reason: 'empty' }])).toEqual([]) + }) +}) diff --git a/lib/import/account-mapper.ts b/lib/import/account-mapper.ts index 52e893a6..ae22d666 100644 --- a/lib/import/account-mapper.ts +++ b/lib/import/account-mapper.ts @@ -47,8 +47,13 @@ export function isSystemAccount(accountNumber: string): boolean { /** * Check if an account number is in the valid BAS range (1000-8999). * Standard Swedish BAS accounts are 4-digit numbers in classes 1-8. + * + * This is the auto-create boundary: a source account in this range can + * always be carried into the chart under its own number (the importer derives + * class and type from the number), so it never needs a manual target. Outside + * it (class 9, 5-digit numbers) the user must pick a target. */ -function isValidBASRange(accountNumber: string): boolean { +export function isValidBASRange(accountNumber: string): boolean { if (!/^\d{4}$/.test(accountNumber)) return false const num = parseInt(accountNumber, 10) return num >= 1000 && num <= 8999 @@ -109,8 +114,16 @@ function findBestMatch( // Fallback: if the account is a valid BAS-range number (1000-8999), // self-map it using the name from the SIE file. These are standard - // BAS sub-accounts not in our reference (e.g. 1241 Personbilar). - if (isValidBASRange(source.number) && source.name) { + // BAS sub-accounts not in our reference (e.g. 1241 Personbilar), or + // accounts a source system kept outside BAS (e.g. a Fortnox chart's 4599). + // + // A missing name is not a reason to refuse: an account referenced only by + // #TRANS/#IB (no #KONTO row) arrives nameless, and the parser has already + // told the user it will be created. The number alone determines class and + // type; the importer names it "Konto " when the file has no name. + // Leaving it unmapped offered no way forward except merging it into a + // different account, which is wrong for a ledger migration (issue #2212). + if (isValidBASRange(source.number)) { return { sourceAccount: source.number, sourceName: source.name, diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 7d0d30b6..96adfa22 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -1214,6 +1214,29 @@ export async function resyncNextPeriodOpeningBalance( } } + +/** + * Roll the per-voucher unmapped skips up per source account: which accounts + * had no mapping and how many vouchers each one excluded. Sorted by voucher + * count (most excluded first), then by account number, so the result step + * names the account that matters most first. Pure: exported for tests and + * for the result surface (ImportResultDetails.skippedVouchers.unmappedAccounts). + */ +export function summarizeUnmappedSkips( + skippedDetails: ReadonlyArray<{ reason: string; unmappedAccounts?: string[] }>, +): Array<{ account: string; vouchers: number }> { + const perAccount = new Map() + for (const detail of skippedDetails) { + if (detail.reason !== 'unmapped') continue + for (const account of new Set(detail.unmappedAccounts ?? [])) { + perAccount.set(account, (perAccount.get(account) ?? 0) + 1) + } + } + return [...perAccount] + .map(([account, vouchers]) => ({ account, vouchers })) + .sort((a, b) => b.vouchers - a.vouchers || a.account.localeCompare(b.account)) +} + /** * Create journal entries from vouchers using batch insert for performance. * @@ -2512,6 +2535,7 @@ export async function executeSIEImport( let voucherNumberMapping: Array<{ sourceId: string; series: string; targetNumber: number }> = [] let voucherSeriesUsed: string[] = [] let voucherRetryStats = { retriedBatches: 0, failedBatches: 0 } + let unmappedSkipSummary: Array<{ account: string; vouchers: number }> = [] let voucherStats = { total: parsed.vouchers.length, imported: 0, @@ -2904,12 +2928,17 @@ export async function executeSIEImport( } // Report skipped vouchers as warnings + unmappedSkipSummary = summarizeUnmappedSkips(voucherResults.skippedDetails) const totalSkipped = voucherResults.skippedEmpty + voucherResults.skippedSingleLine + voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped if (totalSkipped > 0) { const parts: string[] = [] 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`) + if (voucherResults.skippedUnmapped > 0) { + parts.push( + `${voucherResults.skippedUnmapped} med ej mappade konton (${unmappedSkipSummary.map((a) => a.account).join(', ')})` + ) + } result.warnings.push( `${totalSkipped} ${totalSkipped === 1 ? 'verifikation' : 'verifikationer'} hoppades över (${totalSkipped === 1 ? 'ofullständig' : 'ofullständiga'} i källsystemet): ${parts.join(', ')}` ) @@ -3083,6 +3112,7 @@ export async function executeSIEImport( singleLine: voucherStats.skippedSingleLine, empty: voucherStats.skippedEmpty, total: totalSkippedForDetails, + ...(unmappedSkipSummary.length > 0 ? { unmappedAccounts: unmappedSkipSummary } : {}), } : undefined, openingBalance: ibRoundingAdjustment !== 0 ? { imbalance: ibRoundingAdjustment, diff --git a/lib/import/types.ts b/lib/import/types.ts index 9b9b7b06..92a3a641 100644 --- a/lib/import/types.ts +++ b/lib/import/types.ts @@ -253,6 +253,14 @@ export interface ImportResultDetails { singleLine: number empty: number total: number + /** + * The source accounts behind `unmapped`, with how many vouchers each one + * excluded. Lets the result step name the accounts instead of leaving + * the user to diff the general ledger against the source system + * (issue #2212). Absent when `unmapped` is 0 and on results recorded + * before this field existed. + */ + unmappedAccounts?: Array<{ account: string; vouchers: number }> } /** Opening balance imbalance info */ diff --git a/messages/en.json b/messages/en.json index 0a05ab06..4182214f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5492,7 +5492,12 @@ "vat_review_filter": "{count} VAT treatments to review", "vat_treatment_column": "VAT treatment", "vat_treatment_confirm": "Confirm", - "vat_treatment_confirm_help": "Accept the VAT code for this row to mark it as reviewed. Change the VAT code or rate first if the suggestion is wrong." + "vat_treatment_confirm_help": "Accept the VAT code for this row to mark it as reviewed. Change the VAT code or rate first if the suggestion is wrong.", + "new_account_filter": "{count} new accounts to create", + "new_account_option": "created on import", + "source_name_missing": "no name in the file", + "unmapped_out_of_range_help": "Accounts outside the BAS range 1000-8999 cannot be created automatically. Choose which account in the chart their postings should go to: {accounts}.", + "mapping_new_accounts_note": "Accounts missing from the chart are created on import with the name from the source system and the type derived from the account number." }, "dimensions": { "new_value": "New value", @@ -5662,6 +5667,7 @@ "ext_email_description": "Send invoices and reminders via email", "ext_email_long_description": "Enables email features: send invoices to customers, automatic payment reminders on your chosen schedule, and email notifications. Requires a Resend account with a verified domain or your own SMTP server.", "ext_arcim_migration_name": "System migration", + "ext_arcim_accounts_created": "{count, plural, one {# new account was added to the chart of accounts with its name from the source system.} other {# new accounts were added to the chart of accounts with names from the source system.}}", "ext_arcim_registration_links_label": "Voucher links", "ext_arcim_registration_links_value": "{linked} of {scanned} invoices linked to their booking voucher", "ext_arcim_registration_links_detail": "{unlinked} not linked: {noRef} without a voucher number at the provider, {refNotFetched} whose provider details could not be fetched in time, {unresolved} without an unambiguous booking voucher, {amountMismatch} with a differing amount", diff --git a/messages/sv.json b/messages/sv.json index 8e36b372..5ee1f9df 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5492,7 +5492,12 @@ "vat_review_filter": "{count} momskoder att granska", "vat_treatment_column": "Momskod", "vat_treatment_confirm": "Bekräfta", - "vat_treatment_confirm_help": "Godkänn momskoden för raden så räknas den som granskad. Ändra momskod eller momssats först om förslaget inte stämmer." + "vat_treatment_confirm_help": "Godkänn momskoden för raden så räknas den som granskad. Ändra momskod eller momssats först om förslaget inte stämmer.", + "new_account_filter": "{count} nya konton skapas", + "new_account_option": "skapas vid importen", + "source_name_missing": "namn saknas i filen", + "unmapped_out_of_range_help": "Konton utanför BAS-intervallet 1000-8999 kan inte skapas automatiskt. Välj vilket konto i kontoplanen deras poster ska bokföras på: {accounts}.", + "mapping_new_accounts_note": "Konton som saknas i kontoplanen skapas vid importen med namnet från källsystemet och kontotyp från kontonumret." }, "dimensions": { "new_value": "Nytt värde", @@ -5662,6 +5667,7 @@ "ext_email_description": "Skicka fakturor och påminnelser via e-post", "ext_email_long_description": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän eller en egen SMTP-server.", "ext_arcim_migration_name": "Systemmigration", + "ext_arcim_accounts_created": "{count, plural, one {# nytt konto lades till i kontoplanen med namn från källsystemet.} other {# nya konton lades till i kontoplanen med namn från källsystemet.}}", "ext_arcim_registration_links_label": "Verifikatkoppling", "ext_arcim_registration_links_value": "{linked} av {scanned} fakturor kopplade till bokföringsverifikat", "ext_arcim_registration_links_detail": "{unlinked} utan koppling: {noRef} saknar verifikatnummer hos leverantören, {refNotFetched} vars detaljer inte hann hämtas från leverantören, {unresolved} utan entydigt bokföringsverifikat, {amountMismatch} med avvikande belopp",