diff --git a/DECISIONS.md b/DECISIONS.md index 4ee932b0..038f5f39 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1594,6 +1594,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths). [2026-09-05] Fortnox VAT-inclusive invoices (VATIncluded: true) now map their rows net of VAT (lib/providers/fortnox/mapper.ts netOfVat, preferring TotalExcludingVAT / PriceExcludingVAT when the payload carries them), and the migrated-row completion pass refuses a row set whose net or VAT disagrees with the header the same payload established by more than 1 kr (rowsMismatch, reported, never stored). Why: the first production run of the completion pass (#2291) wrote 345 Profilio invoices whose rows summed to the gross with 25 % on top, beside a correct header; the mapper had always read row Total as net, and the pass's only cross-check was the invoice total, which the header satisfied. Rows that contradict their own header are worse than no rows: the invoice page shows both, and for an open invoice the booking engine sums the rows. Rejected: comparing against the stored header (it may itself be the pre-#1745 default) and a wider tolerance (öresavrundning is at most 0.50 kr; the real disagreements are kronor). [2026-09-05] Fortnox header-level Freight and AdministrationFee become synthetic rows in the sales mapper (lib/providers/fortnox/mapper.ts headerChargeLines), free-text rows (no quantity, no amount) land as line_type 'text' and no longer count as a stated 0 % rate in the migration's VAT resolver. Why: Fortnox keeps both charges outside InvoiceRows while Total and TotalVAT include them, so the rows summed to less than the header by exactly the charge, and after #2302 the rows-versus-header check refused those invoices (Profilio 14 of 384); the *VAT fields are amounts, not rates, and the charge is gross when VATIncluded, all verified on live payloads (invoices 295 and 242) rather than the spec, whose endpoint answered 429 all day. Text rows with VAT 0 beside 25 % rows had made roughly half of the Loftux and Clearstoq registers "mixed" with a null header rate. Rejected: dropping the charge into the first priced row (it is its own line on the customer's invoice, often on 3520) and trusting Net for the header (Net excludes the charges; gross minus TotalVAT is the net the rows must reach). +[2026-09-05] SIE preview now says what the import does to the company's chart (accounts new to THIS company vs already present, planChartChanges in lib/import/account-sync.ts) and shows the fiscal-year verdict (match / create / conflict) from precheckFiscalPeriod, the same read-only check ensureFiscalPeriod now consumes, so preview and import cannot drift. Why: the card counted matches against the BAS reference ("150 mappade" on a 41-account company) and said "matchas mot din kontoplan", which a consultant read as "the file's chart replaces mine"; the fiscal-year overlap was only refused after the mapping step. Rejected: an "import chart of accounts" toggle (the chart already imports unconditionally, a toggle would have no off position); filtering to accounts with nonzero IB/UB/saldo (inactive accounts are harmless chart rows and later re-imports reference them); blocking Continue on a conflict (the import refuses anyway with the same text, and a hard block leaves nothing to do but re-upload; display only, no import logic changed). New wizard strings go through the import namespace in messages/sv.json + en.json even though the surrounding wizard copy is inline Swedish: the Definition of Done asks for both, and the wizard is not on the stays-Swedish list; the conflict text itself is the engine's refusal and stays verbatim. +[2026-09-05] The SIE fiscal-year precheck also carries the BFL 3 kap. shape rules (18 months, mid-month start, month-end finish) as a fourth verdict 'invalid', and the chart plan counts mapped targets only (unmapped sources stay in "Ej mappade"). Why: the skeptic pass on #2307 refuted the first cut twice: "Skapas vid import" was shown for a 19-month #RAR the import then refused, and the chart card kept listing accounts under "Läggs till" after "Skapa saknade konton" while the result said 0 created. Moving the shape rules into the precheck keeps ensureFiscalPeriod a consumer of one verdict (same query order, same texts); counting mapped targets only makes the create button move accounts from "Ej mappade" to "Finns redan" with no recompute. Rejected: recomputing the plan in the browser from the refreshed chart (would need the server-side plan helper in the client bundle, and the arithmetic is one addition). [2026-09-05] Parties: the register's legal name becomes the displayed name of a party and of its supplier or customer row when those still carry the memo the pipeline read ("Webhallen Oktober" becomes "Webhallen Sverige AB"); an all-capitals registry name is shown in title case with legal forms kept, the fact keeps the register's spelling. A name a person typed on the row is never touched. And a party confirmed under the pre-2026-09-04 ledger key keeps its vouchers through legacyLedgerKey: without it every such party came back as a fresh suggestion after the key change. [2026-09-05] Provider OAuth returns to the initiating validated app/brand origin through a two-minute provider_otc handoff. State rows and handoffs have disjoint consume predicates, handoff consumption also binds the destination origin, and both success and provider denial require the original user on hop 2. The token exchange keeps the original configured provider redirect URI. Staging prerequisites 20260831111519, 20260902090000 and 20260902100000 were replayed from the existing SQL files before 20260905094806; MCP-assigned history timestamps were reconciled to those repository versions. Production and the live amnas Fortnox connect remain pending deployment and specific production-write approval. [2026-09-05] PR #2305 review: encrypt both OAuth handoff payload columns with AES-256-GCM using a purpose-scoped derivation of the existing server-only service-role secret, matching other extension credential storage. Authenticate the handoff token, consent, initiating user, destination origin and column as additional data; reject plaintext or unreadable payloads after atomic consume. This resolves the at-rest encryption finding without new configuration, dependencies, or edits to the already-applied migration. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 5fbad292..ebab7aee 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -1018,6 +1018,13 @@ function SIEImportWizard({ mapped: newMapped, unmapped: Math.max(0, prev.mappingStatus.unmapped - createdSet.size), }, + // The accounts just created now exist in the chart and are mapped + // to themselves: they move from "Ej mappade" to "Finns redan". They + // were never in "Läggs till" (planChartChanges counts mapped targets + // only), so that count and its sample stay as they are. + chart: prev.chart + ? { ...prev.chart, existing: prev.chart.existing + createdSet.size } + : prev.chart, } }) } catch (err) { diff --git a/app/api/import/sie/parse/__tests__/route.test.ts b/app/api/import/sie/parse/__tests__/route.test.ts index 51069548..5c02b1df 100644 --- a/app/api/import/sie/parse/__tests__/route.test.ts +++ b/app/api/import/sie/parse/__tests__/route.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextResponse } from 'next/server' import { createQueuedMockSupabase } from '@/tests/helpers' -import type { ParseIssue } from '@/lib/import/types' +import type { ImportPreview, ParseIssue } from '@/lib/import/types' const { supabase, enqueue, reset } = createQueuedMockSupabase() @@ -85,6 +85,7 @@ type ParseResponse = { success: boolean parsed: { issues: ParseIssue[] } validation: { valid: boolean; warnings: string[] } + preview: Pick } describe('POST /api/import/sie/parse', () => { @@ -145,4 +146,75 @@ describe('POST /api/import/sie/parse', () => { expect(body.parsed.issues.some((i) => i.message.includes('felaktigt teckenkodad'))).toBe(false) expect(body.validation.warnings.some((w) => w.includes('felaktigt teckenkodad'))).toBe(false) }) + + // Queue order after the stored-mappings select from beforeEach: + // chart_of_accounts page (fetchAllRows), fiscal_periods containing, + // fiscal_periods overlapping, journal_entries (only when replaceable). + describe('chart and fiscal-year preview blocks', () => { + const seededPeriod = { + id: 'seeded-2024', + period_start: '2024-01-01', + period_end: '2024-12-31', + name: 'Räkenskapsår 2024', + is_closed: false, + locked_at: null, + opening_balances_set: false, + } + + it('counts the file accounts against the company chart, not the BAS reference', async () => { + enqueue({ data: [{ account_number: '1930' }] }) // company chart: 1930 only + enqueue({ data: { id: 'p-2024' } }) // containing period + + const response = await POST(fileRequest(CLEAN_SIE), emptyParams) + const body = (await response.json()) as ParseResponse + + expect(response.status).toBe(200) + // Both file accounts map to BAS, so the reference-based stats say 2 mapped + // while only one is new to this company. + expect(body.preview.mappingStatus.mapped).toBe(2) + expect(body.preview.chart).toEqual({ + toCreate: 1, + existing: 1, + sample: [{ number: '4056', name: expect.any(String) }], + }) + }) + + it('reports match when a fiscal period already contains the file year', async () => { + enqueue({ data: [] }) + enqueue({ data: { id: 'p-2024' } }) + + const response = await POST(fileRequest(CLEAN_SIE), emptyParams) + const body = (await response.json()) as ParseResponse + + expect(body.preview.fiscalYear).toEqual({ verdict: 'match', periodId: 'p-2024' }) + }) + + it('reports create when no period overlaps the file year', async () => { + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: [] }) + + const response = await POST(fileRequest(CLEAN_SIE), emptyParams) + const body = (await response.json()) as ParseResponse + + expect(body.preview.fiscalYear).toEqual({ verdict: 'create', replacesEmptyPeriodId: null }) + }) + + it('reports the conflict at preview that the import would otherwise refuse', async () => { + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: [{ ...seededPeriod, period_start: '2023-07-01', period_end: '2024-06-30' }] }) + enqueue({ data: [{ id: 'entry-1' }] }) // the overlapping period has entries + + const response = await POST(fileRequest(CLEAN_SIE), emptyParams) + const body = (await response.json()) as ParseResponse + + expect(response.status).toBe(200) + expect(body.preview.fiscalYear?.verdict).toBe('conflict') + if (body.preview.fiscalYear?.verdict !== 'conflict') return + expect(body.preview.fiscalYear.existingPeriod.id).toBe('seeded-2024') + expect(body.preview.fiscalYear.message).toMatch(/2024-01-01 till 2024-12-31/) + expect(body.preview.fiscalYear.message).toMatch(/Inställningar → Företag/) + }) + }) }) diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts index 2cbb6fba..c5536c32 100644 --- a/app/api/import/sie/parse/route.ts +++ b/app/api/import/sie/parse/route.ts @@ -7,9 +7,16 @@ import { calculateFileHash, } from '@/lib/import/sie-parser' import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper' +import { planChartChanges } from '@/lib/import/chart-plan' import { scanSieForCp1252Artifacts, formatSieArtifactWarning } from '@/lib/import/sie-artifact-scan' -import { generateImportPreview, checkDuplicateImport, checkDuplicatePeriodImport } from '@/lib/import/sie-import' +import { + generateImportPreview, + checkDuplicateImport, + checkDuplicatePeriodImport, + precheckFiscalPeriod, +} from '@/lib/import/sie-import' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { SIEAccountMappingRecord } from '@/lib/import/types' @@ -143,6 +150,34 @@ export const POST = withRouteContext( preview.excludedSystemAccounts = excludedSystemAccounts preview.accountCount = bookkeepingAccounts.length + // The mapping stats above score the file against the BAS reference. A + // consultant with a 41-account seeded company reads "150 mappade" as + // "your chart replaces mine", so also say what happens to THIS company's + // chart: how many of the file's accounts are new here, how many exist. + const chartRows = await fetchAllRows<{ account_number: string }>(({ from, to }) => + supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId) + .order('account_number') + .range(from, to), + ) + preview.chart = planChartChanges( + mappings, + new Set(chartRows.map((r) => r.account_number)), + ) + + // Same containment/overlap verdict the import runs (ensureFiscalPeriod), + // so a fiscal-year conflict shows here instead of after the mapping step. + if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) { + preview.fiscalYear = await precheckFiscalPeriod( + supabase, + companyId!, + parsed.stats.fiscalYearStart, + parsed.stats.fiscalYearEnd, + ) + } + const fileHash = await calculateFileHash(content) return NextResponse.json({ diff --git a/components/import/ImportResultStep.tsx b/components/import/ImportResultStep.tsx index ce85953a..966d2a02 100644 --- a/components/import/ImportResultStep.tsx +++ b/components/import/ImportResultStep.tsx @@ -256,7 +256,7 @@ export default function ImportResultStep({ {/* Statistics */} {result.success && !showReveal && ( -
+
@@ -267,6 +267,15 @@ export default function ImportResultStep({ + + +
+ {t('result_accounts_created')} +
+

{result.accountsCreated ?? 0}

+
+
+
diff --git a/components/import/ImportReviewStep.tsx b/components/import/ImportReviewStep.tsx index 67665ebf..16798615 100644 --- a/components/import/ImportReviewStep.tsx +++ b/components/import/ImportReviewStep.tsx @@ -540,9 +540,10 @@ export default function ImportReviewStep({

1. Räkenskapsåret skapas om det inte finns

-

2. En verifikation för ingående balanser skapas

-

3. Alla verifikationer importeras med nya verifikationsnummer

-

4. Kontomappningarna sparas för framtida importer

+

2. {t('review_step_chart')}

+

3. En verifikation för ingående balanser skapas

+

4. Alla verifikationer importeras med nya verifikationsnummer

+

5. Kontomappningarna sparas för framtida importer

Importen kan inte ångras automatiskt, men du kan ta bort skapade verifikationer manuellt.

diff --git a/components/import/SIEPreviewStep.tsx b/components/import/SIEPreviewStep.tsx index 18a28ebe..6a4c4ea2 100644 --- a/components/import/SIEPreviewStep.tsx +++ b/components/import/SIEPreviewStep.tsx @@ -1,6 +1,7 @@ 'use client' import { useState } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -45,9 +46,19 @@ export default function SIEPreviewStep({ onBack, onOpenManualOpeningBalances, }: SIEPreviewStepProps) { + const t = useTranslations('import') const errors = issues.filter((i) => i.severity === 'error') const warnings = issues.filter((i) => i.severity === 'warning') + // Both blocks are optional: a preview built by an older parse response + // lacks them, and the card then falls back to the BAS-reference counts. + const chart = preview.chart + const fiscalYear = preview.fiscalYear + // Both refusals carry the import's own text: an overlap with a period that + // has content, or #RAR dates that break a BFL 3 kap. shape rule. + const fiscalYearRefused = + fiscalYear?.verdict === 'conflict' || fiscalYear?.verdict === 'invalid' + // Opening-balance imbalance. The importer plugs any diff > 0.01 to 2099, but a // diff under ~1 SEK is genuine öresavrundning. Anything larger is a real // imbalance (incomplete export: missing liabilities / unappropriated prior-year @@ -89,7 +100,7 @@ export default function SIEPreviewStep({ {/* Fiscal year */} - + @@ -111,7 +122,22 @@ export default function SIEPreviewStep({ {preview.fiscalYearEnd ?? 'Okänt'}

+ {fiscalYear && (fiscalYear.verdict === 'match' || fiscalYear.verdict === 'create') && ( + + {fiscalYear.verdict === 'match' + ? t('fiscal_year_match') + : fiscalYear.replacesEmptyPeriodId + ? t('fiscal_year_create_replaces') + : t('fiscal_year_create')} + + )}
+ {fiscalYear && (fiscalYear.verdict === 'conflict' || fiscalYear.verdict === 'invalid') && ( +
+ + {fiscalYear.message} +
+ )}
@@ -264,35 +290,67 @@ export default function SIEPreviewStep({ ) : ( )} - Kontomappning + {t('chart_card_title')} - Hur väl kunde kontona i filen matchas mot din kontoplan + {chart + ? t('chart_summary', { toCreate: chart.toCreate, existing: chart.existing }) + : 'Hur väl kunde kontona i filen matchas mot din kontoplan'}
-
-

Totalt

-

{preview.mappingStatus.total}

-
-
-

Mappade

-

{preview.mappingStatus.mapped}

-
+ {chart ? ( + <> +
+

{t('chart_to_create')}

+

{chart.toCreate}

+
+
+

{t('chart_existing')}

+

{chart.existing}

+
+ + ) : ( + <> +
+

Totalt

+

{preview.mappingStatus.total}

+
+
+

Mappade

+

{preview.mappingStatus.mapped}

+
+ + )}

Ej mappade

-

0 ? 'text-destructive' : ''}`}> +

0 ? 'text-destructive' : ''}`}> {preview.mappingStatus.unmapped}

Osäkra

-

0 ? 'text-warning' : ''}`}> +

0 ? 'text-warning' : ''}`}> {preview.mappingStatus.lowConfidence}

+ {chart && chart.sample.length > 0 && ( +
+ {chart.sample.map((acc) => ( +
+ {acc.number} + {acc.name} +
+ ))} + {chart.toCreate > chart.sample.length && ( +
+ {t('chart_sample_more', { count: chart.toCreate - chart.sample.length })} +
+ )} +
+ )}
diff --git a/lib/import/__tests__/chart-plan.test.ts b/lib/import/__tests__/chart-plan.test.ts new file mode 100644 index 00000000..fc259e28 --- /dev/null +++ b/lib/import/__tests__/chart-plan.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import { planChartChanges } from '../chart-plan' +import type { AccountMapping } from '../types' + +function mapping( + partial: Partial & { sourceAccount: string; targetAccount: string } +): AccountMapping { + return { + sourceName: '', + targetName: '', + confidence: 1, + matchType: 'exact', + isOverride: false, + ...partial, + } +} + +describe('planChartChanges', () => { + it('splits distinct target accounts into new and existing for this company', () => { + const plan = planChartChanges( + [ + mapping({ sourceAccount: '1930', targetAccount: '1930', sourceName: 'Företagskonto' }), + mapping({ sourceAccount: '3010', targetAccount: '3010', sourceName: 'Konsultarvoden' }), + mapping({ sourceAccount: '6110', targetAccount: '6110', targetName: 'Kontorsmateriel' }), + ], + new Set(['1930']), + ) + + expect(plan.toCreate).toBe(2) + expect(plan.existing).toBe(1) + expect(plan.sample).toEqual([ + { number: '3010', name: 'Konsultarvoden' }, + { number: '6110', name: 'Kontorsmateriel' }, + ]) + }) + + it('names a new identity-mapped account after the file, not the BAS reference', () => { + // Matches syncMappedAccounts with "Använd kontonamn från filen" on (the + // default): the file's #KONTO name wins for sourceAccount === targetAccount. + const plan = planChartChanges( + [ + mapping({ + sourceAccount: '1930', + targetAccount: '1930', + sourceName: 'Företagskonto Swedbank', + targetName: 'Företagskonto/checkkonto', + }), + ], + new Set(), + ) + + expect(plan.sample).toEqual([{ number: '1930', name: 'Företagskonto Swedbank' }]) + }) + + it('names a remapped account after its target', () => { + const plan = planChartChanges( + [ + mapping({ + sourceAccount: '1910', + targetAccount: '1930', + sourceName: 'Kassa', + targetName: 'Företagskonto/checkkonto', + }), + ], + new Set(), + ) + + expect(plan.sample).toEqual([{ number: '1930', name: 'Företagskonto/checkkonto' }]) + }) + + it('counts two sources remapped onto one target once', () => { + const plan = planChartChanges( + [ + mapping({ sourceAccount: '1910', targetAccount: '1930' }), + mapping({ sourceAccount: '1920', targetAccount: '1930' }), + ], + new Set(), + ) + + expect(plan.toCreate).toBe(1) + expect(plan.existing).toBe(0) + }) + + it('leaves an unmapped source out of both counts', () => { + // The import refuses while anything is unmapped; the account is neither + // added nor present until the user creates or maps it. + const plan = planChartChanges( + [ + mapping({ sourceAccount: '9030', targetAccount: '', sourceName: 'Obokat resultat' }), + mapping({ sourceAccount: '1930', targetAccount: '1930' }), + ], + new Set(['1930']), + ) + + expect(plan.toCreate).toBe(0) + expect(plan.existing).toBe(1) + expect(plan.sample).toEqual([]) + }) + + it('caps the sample but not the count', () => { + const mappings = Array.from({ length: 12 }, (_, i) => + mapping({ sourceAccount: `40${10 + i}`, targetAccount: `40${10 + i}` }), + ) + + const plan = planChartChanges(mappings, new Set()) + + expect(plan.toCreate).toBe(12) + expect(plan.sample).toHaveLength(8) + expect(plan.sample[0].number).toBe('4010') + }) +}) diff --git a/lib/import/__tests__/sie-import.account-names.test.ts b/lib/import/__tests__/sie-import.account-names.test.ts index 866a8d7e..91812f13 100644 --- a/lib/import/__tests__/sie-import.account-names.test.ts +++ b/lib/import/__tests__/sie-import.account-names.test.ts @@ -219,4 +219,32 @@ describe('executeSIEImport: account name sync wiring', () => { expect(result.warnings.join(' ')).not.toMatch(/bytte namn/) }) + + it('reports the number of accounts the sync inserted', async () => { + mockSync.mockResolvedValue({ + created: 109, + renamed: 0, + renamedAccounts: [], + renameFailed: 0, + error: null, + }) + + const result = await runImport() + + expect(result.accountsCreated).toBe(109) + }) + + it('leaves accountsCreated unset when the create pass fails', async () => { + mockSync.mockResolvedValue({ + created: 3, + renamed: 0, + renamedAccounts: [], + renameFailed: 0, + error: 'permission denied', + }) + + const result = await runImport() + + expect(result.accountsCreated).toBeUndefined() + }) }) diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index f02d3890..46b957fd 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -4,6 +4,7 @@ import { validateIBBalance, isBalanceSheetAccount, ensureFiscalPeriod, + precheckFiscalPeriod, importVouchers, computeVoucherNumberRanges, linkOpeningBalanceEntryToPeriod, @@ -1548,3 +1549,199 @@ describe('IB derivation from #UB -1 (issue #675)', () => { }) }) }) + +describe('precheckFiscalPeriod', () => { + // The read-only verdict the parse preview shows. Mirrors the + // ensureFiscalPeriod cases above one-to-one: whatever this says at preview + // is what the import does. + type Supabase = Parameters[0] + + const seededPeriod = { + id: 'seeded-2026', + period_start: '2026-01-01', + period_end: '2026-12-31', + name: 'Räkenskapsår 2026', + is_closed: false, + locked_at: null, + opening_balances_set: false, + } + + it('reports match when an existing period contains the range', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([{ data: { id: 'existing-period-id' }, error: null }]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2026-01-01', + '2026-12-31', + ) + + expect(verdict).toEqual({ verdict: 'match', periodId: 'existing-period-id' }) + }) + + it('reports create with nothing to replace when no period overlaps', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, // containing check, no match + { data: [], error: null }, // overlapping check, none + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2025-01-01', + '2025-12-31', + ) + + expect(verdict).toEqual({ verdict: 'create', replacesEmptyPeriodId: null }) + }) + + it('reports invalid with the import refusal text for a 19-month #RAR', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [], error: null }, + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2024-01-01', + '2025-07-31', + ) + + expect(verdict.verdict).toBe('invalid') + if (verdict.verdict !== 'invalid') return + expect(verdict.message).toMatch(/omfattar 19 månader/) + }) + + it('reports invalid for an end date that is not the last day of its month', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [], error: null }, + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2024-01-01', + '2024-12-30', + ) + + expect(verdict.verdict).toBe('invalid') + if (verdict.verdict !== 'invalid') return + expect(verdict.message).toMatch(/måste sluta på månadens sista dag/) + }) + + it('reports invalid for a mid-month start when an earlier period exists', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [], error: null }, + { data: [{ id: 'earlier' }], error: null }, // earlier period exists + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2026-04-16', + '2026-12-31', + ) + + expect(verdict.verdict).toBe('invalid') + if (verdict.verdict !== 'invalid') return + expect(verdict.message).toMatch(/kronologiskt första räkenskapsår får börja mitt i månaden/) + }) + + it('reports create naming the empty seeded period it will replace', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [seededPeriod], error: null }, + { data: [], error: null }, // journal_entries: none + { data: [], error: null }, // earlier-period check, none (mid-month start) + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2025-10-20', + '2026-12-31', + ) + + expect(verdict).toEqual({ verdict: 'create', replacesEmptyPeriodId: 'seeded-2026' }) + }) + + it('reports conflict with the import refusal text when the overlapping period has entries', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [seededPeriod], error: null }, + { data: [{ id: 'entry-1' }], error: null }, + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2025-03-01', + '2026-02-28', + ) + + expect(verdict.verdict).toBe('conflict') + if (verdict.verdict !== 'conflict') return + expect(verdict.existingPeriod).toEqual({ + id: 'seeded-2026', + name: 'Räkenskapsår 2026', + periodStart: '2026-01-01', + periodEnd: '2026-12-31', + }) + expect(verdict.message).toMatch(/2025-03-01 till 2026-02-28/) + expect(verdict.message).toMatch(/Inställningar → Företag/) + }) + + it('reports conflict without reading entries when opening balances are set', async () => { + const { supabase, enqueueMany, calls } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [{ ...seededPeriod, opening_balances_set: true }], error: null }, + ]) + + const verdict = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2025-10-20', + '2026-12-31', + ) + + expect(verdict.verdict).toBe('conflict') + expect(calls.some((c) => c.table === 'journal_entries')).toBe(false) + }) + + it('is the verdict ensureFiscalPeriod acts on: conflict throws the same text', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [seededPeriod], error: null }, + { data: [{ id: 'entry-1' }], error: null }, + ]) + const precheck = await precheckFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2025-03-01', + '2026-02-28', + ) + + const again = createQueuedMockSupabase() + again.enqueueMany([ + { data: null, error: null }, + { data: [seededPeriod], error: null }, + { data: [{ id: 'entry-1' }], error: null }, + ]) + + await expect( + ensureFiscalPeriod(again.supabase as unknown as Supabase, 'company-id', '2025-03-01', '2026-02-28'), + ).rejects.toThrow(precheck.verdict === 'conflict' ? precheck.message : 'unreachable') + }) +}) diff --git a/lib/import/chart-plan.ts b/lib/import/chart-plan.ts new file mode 100644 index 00000000..483f930a --- /dev/null +++ b/lib/import/chart-plan.ts @@ -0,0 +1,67 @@ +/** + * Pure preview of what the SIE import does to the company's chart of + * accounts. Client-safe (types only): the import wizard recomputes it in the + * browser after "Skapa saknade konton", and the parse route computes it on + * the server. The insert itself lives in account-sync.ts (syncMappedAccounts). + */ + +import type { AccountMapping } from './types' + +/** How many accounts are shown by number in the preview's chart card. */ +export const CHART_SAMPLE_SIZE = 8 + +export interface ChartPlan { + /** Distinct mapped target accounts absent from chart_of_accounts today. */ + toCreate: number + /** Distinct mapped target accounts already present in chart_of_accounts. */ + existing: number + /** First few accounts that will be created, for the preview card. */ + sample: { number: string; name: string }[] +} + +/** + * Split the distinct target accounts of `mappings` into those absent from the + * company's chart and those already present. + * + * Only mapped accounts count: syncMappedAccounts inserts targets, and the + * import refuses to run while any account is unmapped. An unmapped source is + * therefore neither "läggs till" nor "finns redan"; it stays in the card's + * "Ej mappade" count until the user creates it or maps it. + * + * Counts targets, not sources, so two sources remapped onto one target count + * once, the same way the insert pass dedupes. + * + * The sample shows the name the import will give the account: the file's + * #KONTO name for an identity mapping (the default, "Använd kontonamn från + * filen" on), else the BAS name the mapping resolved to. + */ +export function planChartChanges( + mappings: AccountMapping[], + existingNumbers: ReadonlySet, +): ChartPlan { + const seen = new Set() + const toCreate: { number: string; name: string }[] = [] + let existing = 0 + + for (const m of mappings) { + const number = m.targetAccount + if (!number || seen.has(number)) continue + seen.add(number) + if (existingNumbers.has(number)) { + existing += 1 + } else { + const fileName = m.sourceName?.trim() + const name = + m.sourceAccount === m.targetAccount && fileName ? fileName : m.targetName || fileName || '' + toCreate.push({ number, name }) + } + } + + toCreate.sort((a, b) => a.number.localeCompare(b.number)) + + return { + toCreate: toCreate.length, + existing, + sample: toCreate.slice(0, CHART_SAMPLE_SIZE), + } +} diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 7d085c85..dbb8cd52 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -15,6 +15,7 @@ import type { AccountMapping, ImportResult, ImportPreview, + FiscalYearPrecheck, SIEImport, MigrationDocumentation, } from './types' @@ -463,18 +464,32 @@ async function cleanupStaleImportRecords( } /** - * Create a fiscal period if one doesn't exist for the date range. - * Dates are ISO strings "YYYY-MM-DD" to avoid timezone issues. + * Read-only verdict on how the SIE file's räkenskapsår relates to the + * company's existing fiscal periods. Shared by the parse preview and by + * ensureFiscalPeriod, so what the wizard says before import is exactly what + * the import will do: * - * Exported for unit testing of the pre-validation that mirrors the - * `enforce_period_start_day` DB trigger. + * - match: a period already contains the file's date range; it is reused. + * - create: no period covers the range; one is created. If an overlapping + * period is empty (onboarding-seeded with the default calendar year but + * never used) it is replaced: the user has a förlängt räkenskapsår per + * BFL 3 kap. that doesn't match the seeded period, and the seeded period + * carries no data to preserve. + * - conflict: an overlapping period carries real content (posted entries, + * opening balances set, closed, or locked). Silently reusing it would stamp + * imported vouchers with a fiscal_period_id whose date window doesn't match + * the voucher's own date: breaking the SIE invariant that #VER dates fall + * inside #RAR and BFL 5 kap. (verifikationsnummer per räkenskapsår). + * + * Runs no writes. The date-shape rules (18 months, mid-month start, month-end + * finish) stay in ensureFiscalPeriod: they are about the file, not the company. */ -export async function ensureFiscalPeriod( +export async function precheckFiscalPeriod( supabase: SupabaseClient, companyId: string, startDate: string, endDate: string -): Promise { +): Promise { // Check for an existing period that contains the SIE date range const { data: containing } = await supabase .from('fiscal_periods') @@ -485,19 +500,9 @@ export async function ensureFiscalPeriod( .single() if (containing) { - return containing.id + return { verdict: 'match', periodId: containing.id } } - // An overlapping-but-not-containing period needs to be split into two cases: - // - The period has any real content (posted entries, opening balances set, - // closed, or locked): refuse. Silently reusing it would stamp imported - // vouchers with a fiscal_period_id whose date window doesn't match the - // voucher's own date: breaking the SIE invariant that #VER dates fall - // inside #RAR and BFL 5 kap. (verifikationsnummer per räkenskapsår). - // - The period is empty (onboarding-seeded with the default calendar year - // but never used): replace it. The user has a förlängt räkenskapsår per - // BFL 3 kap. that doesn't match the seeded period, and the seeded period - // carries no data to preserve. const { data: overlapping } = await supabase .from('fiscal_periods') .select('id, period_start, period_end, name, is_closed, locked_at, opening_balances_set') @@ -507,36 +512,63 @@ export async function ensureFiscalPeriod( .order('period_start', { ascending: false }) .limit(1) - let periodToReplaceId: string | null = null - - if (overlapping && overlapping.length > 0) { - const existing = overlapping[0] - - const replaceableGateOpen = - !existing.is_closed && !existing.locked_at && !existing.opening_balances_set - - let hasEntries = true - if (replaceableGateOpen) { - const { data: existingEntries } = await supabase - .from('journal_entries') - .select('id') - .eq('fiscal_period_id', existing.id) - .eq('company_id', companyId) - .limit(1) - hasEntries = (existingEntries?.length ?? 0) > 0 - } - - if (!replaceableGateOpen || hasEntries) { - throw new Error( - `SIE-filens räkenskapsår (${startDate} till ${endDate}) överlappar men matchar inte ett befintligt räkenskapsår i Accounted ` + - `(${existing.name}: ${existing.period_start} till ${existing.period_end}). ` + - `Justera räkenskapsåret i Inställningar → Företag så att det matchar SIE-filen exakt, eller importera en SIE-fil som täcker exakt samma period.` - ) - } - - periodToReplaceId = existing.id + if (!overlapping || overlapping.length === 0) { + const invalid = await checkFiscalYearShape(supabase, companyId, startDate, endDate) + if (invalid) return invalid + return { verdict: 'create', replacesEmptyPeriodId: null } } + const existing = overlapping[0] + const existingPeriod = { + id: existing.id as string, + name: existing.name as string, + periodStart: existing.period_start as string, + periodEnd: existing.period_end as string, + } + + const replaceableGateOpen = + !existing.is_closed && !existing.locked_at && !existing.opening_balances_set + + let hasEntries = true + if (replaceableGateOpen) { + const { data: existingEntries } = await supabase + .from('journal_entries') + .select('id') + .eq('fiscal_period_id', existing.id) + .eq('company_id', companyId) + .limit(1) + hasEntries = (existingEntries?.length ?? 0) > 0 + } + + if (!replaceableGateOpen || hasEntries) { + return { + verdict: 'conflict', + existingPeriod, + message: + `SIE-filens räkenskapsår (${startDate} till ${endDate}) överlappar men matchar inte ett befintligt räkenskapsår i Accounted ` + + `(${existingPeriod.name}: ${existingPeriod.periodStart} till ${existingPeriod.periodEnd}). ` + + `Justera räkenskapsåret i Inställningar → Företag så att det matchar SIE-filen exakt, eller importera en SIE-fil som täcker exakt samma period.`, + } + } + + const invalid = await checkFiscalYearShape(supabase, companyId, startDate, endDate) + if (invalid) return invalid + + return { verdict: 'create', replacesEmptyPeriodId: existingPeriod.id } +} + +/** + * The BFL 3 kap. shape rules a new period must satisfy, in the order the + * import has always applied them. Returns the 'invalid' verdict with the + * refusal text, or null when the dates are fine. Runs after the overlap + * verdict so a conflict with an existing period is reported first, as before. + */ +async function checkFiscalYearShape( + supabase: SupabaseClient, + companyId: string, + startDate: string, + endDate: string +): Promise | null> { const startParts = parseDateParts(startDate) const endParts = parseDateParts(endDate) @@ -555,13 +587,15 @@ export async function ensureFiscalPeriod( // imported voucher with an illegal period that no UI path can repair // afterwards (the fiscal-period editor rejects the very same span), leaving // undo_sie_import as the only way out. Checked before the destructive delete - // below so a refused import leaves the company untouched. + // in ensureFiscalPeriod so a refused import leaves the company untouched. const months = monthsBetween(startDate, endDate) if (months > 18) { - throw new Error( - `SIE-filens räkenskapsår (${startDate} till ${endDate}) omfattar ${months} månader: ett räkenskapsår får vara högst 18 månader (BFL 3 kap.). ` + - `Kontrollera #RAR-raden i filen och exportera om från källsystemet med ett räkenskapsår per fil.` - ) + return { + verdict: 'invalid', + message: + `SIE-filens räkenskapsår (${startDate} till ${endDate}) omfattar ${months} månader: ett räkenskapsår får vara högst 18 månader (BFL 3 kap.). ` + + `Kontrollera #RAR-raden i filen och exportera om från källsystemet med ett räkenskapsår per fil.`, + } } // Pre-validate against the DB-side enforce_period_start_day trigger so the @@ -581,9 +615,10 @@ export async function ensureFiscalPeriod( .limit(1) if (earlier && earlier.length > 0) { - throw new Error( - `SIE-filens räkenskapsår börjar ${startDate}: endast företagets kronologiskt första räkenskapsår får börja mitt i månaden. Efterföljande räkenskapsår måste börja den 1:a i en månad (BFL 3 kap.). Kontrollera datumen i #RAR-raden.` - ) + return { + verdict: 'invalid', + message: `SIE-filens räkenskapsår börjar ${startDate}: endast företagets kronologiskt första räkenskapsår får börja mitt i månaden. Efterföljande räkenskapsår måste börja den 1:a i en månad (BFL 3 kap.). Kontrollera datumen i #RAR-raden.`, + } } } @@ -591,12 +626,48 @@ export async function ensureFiscalPeriod( // surface it as a clean message instead of a DB error. const lastDayOfEndMonth = new Date(endParts.year, endParts.month, 0).getDate() if (endParts.day !== lastDayOfEndMonth) { - throw new Error( - `SIE-filens räkenskapsår slutar ${endDate}: räkenskapsår måste sluta på månadens sista dag (BFL 3 kap.). Kontrollera datumen i #RAR-raden.` - ) + return { + verdict: 'invalid', + message: `SIE-filens räkenskapsår slutar ${endDate}: räkenskapsår måste sluta på månadens sista dag (BFL 3 kap.). Kontrollera datumen i #RAR-raden.`, + } } - // All date validation passed. If we identified an empty seeded period above, + return null +} + +/** + * Create a fiscal period if one doesn't exist for the date range. + * Dates are ISO strings "YYYY-MM-DD" to avoid timezone issues. + * + * Exported for unit testing of the pre-validation that mirrors the + * `enforce_period_start_day` DB trigger. + */ +export async function ensureFiscalPeriod( + supabase: SupabaseClient, + companyId: string, + startDate: string, + endDate: string +): Promise { + // Containment, overlap and the refuse-or-replace verdict live in + // precheckFiscalPeriod so the parse preview shows the same outcome the + // import will produce. + const precheck = await precheckFiscalPeriod(supabase, companyId, startDate, endDate) + + if (precheck.verdict === 'match') { + return precheck.periodId + } + + if (precheck.verdict === 'conflict' || precheck.verdict === 'invalid') { + throw new Error(precheck.message) + } + + const periodToReplaceId: string | null = precheck.replacesEmptyPeriodId + + const startParts = parseDateParts(startDate) + const endParts = parseDateParts(endDate) + + // All date validation passed (checkFiscalYearShape, via the precheck). If we + // identified an empty seeded period above, // delete it now, deferring the destructive step until after every check // keeps the seeded period intact when an SIE has malformed dates. // FK cascades: account_balances, voucher_sequences, voucher_gap_explanations @@ -2292,6 +2363,7 @@ export async function executeSIEImport( result.errors.push(`Failed to create accounts: ${accountSync.error}`) return result } + result.accountsCreated = accountSync.created if (accountSync.renamed > 0) { result.warnings.push( accountSync.renamed === 1 diff --git a/lib/import/types.ts b/lib/import/types.ts index 19d15058..0f22d86c 100644 --- a/lib/import/types.ts +++ b/lib/import/types.ts @@ -5,6 +5,8 @@ * accounting data exchange between systems). */ +import type { ChartPlan } from './chart-plan' + // SIE file types export type SIEType = 1 | 2 | 3 | 4 @@ -303,6 +305,13 @@ export interface ImportResult { journalEntriesCreated: number journalEntryIds: string[] + // Accounts the import itself inserted into chart_of_accounts (the mapped + // target accounts that did not exist yet). Accounts created from the + // preview's "Skapa saknade konton" button are not counted: they exist + // before the import runs. Optional: results produced before this field + // existed lack it. + accountsCreated?: number + // Issues errors: string[] warnings: string[] @@ -384,10 +393,55 @@ export interface ImportPreview { // before this field existed lack it; consumers must treat absence as []. voucherSeriesInFile?: string[] + // What the import does to the company's chart of accounts: the file's + // accounts are added unconditionally (a chart follows the company across + // fiscal years), so the preview must say how many are new to THIS company + // rather than how many matched the BAS reference. Counted per distinct + // mapped target account (planChartChanges in chart-plan.ts). Optional: + // previews built before this field existed lack it. + chart?: ChartPlan + + // How the file's räkenskapsår relates to the company's existing fiscal + // periods, from the same precheck the import runs (precheckFiscalPeriod). + // Absent when the file carries no #RAR 0 dates. + fiscalYear?: FiscalYearPrecheck + // Issues to review issues: ParseIssue[] } +/** + * Verdict from precheckFiscalPeriod: how the SIE file's fiscal year relates + * to the company's existing fiscal periods. + */ +export type FiscalYearPrecheck = + | { + // A period already contains the file's date range; it is reused. + verdict: 'match' + periodId: string + } + | { + // No period covers the range; the import creates one. When an empty + // onboarding-seeded period overlaps it, that period is replaced. + verdict: 'create' + replacesEmptyPeriodId: string | null + } + | { + // An overlapping period carries real content; the import will refuse. + verdict: 'conflict' + existingPeriod: { id: string; name: string; periodStart: string; periodEnd: string } + // The Swedish refusal text the import raises, verbatim. + message: string + } + | { + // The file's own #RAR dates break a BFL 3 kap. shape rule (over 18 + // months, mid-month start on a non-first year, or an end that is not + // the last day of its month); the import will refuse. + verdict: 'invalid' + // The Swedish refusal text the import raises, verbatim. + message: string + } + /** * Structured systemdokumentation per BFNAR 2013:2 Chapter 9. * Generated at the end of a SIE import and stored in sie_imports.migration_documentation. diff --git a/messages/en.json b/messages/en.json index 30418141..5cea90aa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7528,6 +7528,16 @@ "reveal_cta_bank": "Connect the bank", "reveal_cta_csv": "Upload bank statement (CSV)", "reveal_cta_open": "Open Accounted", + "chart_card_title": "Chart of accounts", + "chart_summary": "{toCreate, plural, one {# account from the file is added to the company's chart of accounts} other {# accounts from the file are added to the company's chart of accounts}}, {existing, plural, one {# already exists} other {# already exist}}. The chart of accounts stays with the company across fiscal years.", + "chart_to_create": "Added", + "chart_existing": "Already present", + "chart_sample_more": "... and {count} more", + "fiscal_year_match": "Already exists in Accounted", + "fiscal_year_create": "Created at import", + "fiscal_year_create_replaces": "Created at import, replacing the empty fiscal year that was created automatically", + "review_step_chart": "The file's chart of accounts comes along: new accounts are created, and existing accounts take the names from the file while Use account names from the file is on", + "result_accounts_created": "Accounts created", "next_steps_title": "Next step: fetch your bank history", "next_steps_match_copy": "Connect your bank or upload bank statements, and we match the bank history against what you just imported.", "next_steps_cta_bank": "Connect your bank", diff --git a/messages/sv.json b/messages/sv.json index ebda5e10..84f347a0 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -7528,6 +7528,16 @@ "reveal_cta_bank": "Koppla banken", "reveal_cta_csv": "Ladda upp kontoutdrag (CSV)", "reveal_cta_open": "Öppna Accounted", + "chart_card_title": "Kontoplan", + "chart_summary": "{toCreate, plural, one {# konto från filen läggs till i företagets kontoplan} other {# konton från filen läggs till i företagets kontoplan}}, {existing, plural, one {# finns redan} other {# finns redan}}. Kontoplanen följer företaget mellan räkenskapsår.", + "chart_to_create": "Läggs till", + "chart_existing": "Finns redan", + "chart_sample_more": "... och {count} till", + "fiscal_year_match": "Finns redan i Accounted", + "fiscal_year_create": "Skapas vid import", + "fiscal_year_create_replaces": "Skapas vid import och ersätter det tomma räkenskapsår som skapades automatiskt", + "review_step_chart": "Kontoplanen från filen tas med: nya konton skapas, och befintliga konton får namnen från filen om valet Använd kontonamn från filen är på", + "result_accounts_created": "Konton skapade", "next_steps_title": "Nästa steg: hämta bankhistoriken", "next_steps_match_copy": "Koppla din bank eller ladda upp kontoutdrag, så matchar vi bankhistoriken mot det du just importerade.", "next_steps_cta_bank": "Koppla din bank",