feat(import): say in the SIE wizard that the chart and fiscal year come along (#2307)
* feat(import): say in the SIE wizard that the chart and fiscal year come along The preview scored the file's accounts against the BAS reference and said "matchas mot din kontoplan", so a consultant with a 41-account seeded company read "150 mappade" as "the file's chart replaces mine". A fiscal-year overlap with a non-empty period was only refused after the mapping step. - Parse route adds preview.chart (accounts new to THIS company vs already present, with a sample) via planChartChanges, and preview.fiscalYear from precheckFiscalPeriod: the containment/overlap verdict extracted out of ensureFiscalPeriod, which now consumes it, so preview and import cannot drift. - Preview card renamed to Kontoplan with the counts and the fiscal-year verdict (match / create / conflict with the import's own refusal text). - Review step lists the chart among "Vad händer när du importerar?". - executeSIEImport reports accountsCreated from the account sync; the result grid gets a Konton skapade card. No import logic changed; no migration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC * fix(import): preview refuses what the import refuses, and the chart card survives "Skapa saknade konton" Skeptic pass on #2307 refuted the first cut twice: - "Skapas vid import" was shown for a #RAR the import then refuses under BFL 3 kap. (19 months, non-month-end finish, mid-month start after an earlier year). The shape rules move into precheckFiscalPeriod as a fourth verdict 'invalid' with the same refusal text; ensureFiscalPeriod stays a consumer of one verdict, same query order. - The Kontoplan card counted unmapped sources under "Läggs till" and kept listing them after the create button, while the result said 0 created. planChartChanges (now client-safe in lib/import/chart-plan.ts) counts mapped targets only; the create button moves those accounts from "Ej mappade" to "Finns redan" in place. - The review line claimed existing accounts keep their name unless you opt in; the switch defaults to on. Reworded to match. - Sample names follow the file for identity mappings, as the sync does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
04898c3178
commit
f33628f005
@@ -1594,6 +1594,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ImportPreview, 'chart' | 'fiscalYear' | 'mappingStatus'>
|
||||
}
|
||||
|
||||
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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -256,7 +256,7 @@ export default function ImportResultStep({
|
||||
|
||||
{/* Statistics */}
|
||||
{result.success && !showReveal && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
@@ -267,6 +267,15 @@ export default function ImportResultStep({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<span className="text-sm">{t('result_accounts_created')}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-display tabular-nums">{result.accountsCreated ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
|
||||
@@ -540,9 +540,10 @@ export default function ImportReviewStep({
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||
<p>1. Räkenskapsåret skapas om det inte finns</p>
|
||||
<p>2. En verifikation för ingående balanser skapas</p>
|
||||
<p>3. Alla verifikationer importeras med nya verifikationsnummer</p>
|
||||
<p>4. Kontomappningarna sparas för framtida importer</p>
|
||||
<p>2. {t('review_step_chart')}</p>
|
||||
<p>3. En verifikation för ingående balanser skapas</p>
|
||||
<p>4. Alla verifikationer importeras med nya verifikationsnummer</p>
|
||||
<p>5. Kontomappningarna sparas för framtida importer</p>
|
||||
<p className="pt-2 font-medium">
|
||||
Importen kan inte ångras automatiskt, men du kan ta bort skapade verifikationer manuellt.
|
||||
</p>
|
||||
|
||||
@@ -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({
|
||||
</Card>
|
||||
|
||||
{/* Fiscal year */}
|
||||
<Card>
|
||||
<Card className={fiscalYearRefused ? 'border-destructive/50' : undefined}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
@@ -111,7 +122,22 @@ export default function SIEPreviewStep({
|
||||
{preview.fiscalYearEnd ?? 'Okänt'}
|
||||
</p>
|
||||
</div>
|
||||
{fiscalYear && (fiscalYear.verdict === 'match' || fiscalYear.verdict === 'create') && (
|
||||
<span className="ml-auto text-sm text-muted-foreground">
|
||||
{fiscalYear.verdict === 'match'
|
||||
? t('fiscal_year_match')
|
||||
: fiscalYear.replacesEmptyPeriodId
|
||||
? t('fiscal_year_create_replaces')
|
||||
: t('fiscal_year_create')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fiscalYear && (fiscalYear.verdict === 'conflict' || fiscalYear.verdict === 'invalid') && (
|
||||
<div className="mt-4 flex items-start gap-2 text-sm text-destructive">
|
||||
<XCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{fiscalYear.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -264,35 +290,67 @@ export default function SIEPreviewStep({
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
)}
|
||||
Kontomappning
|
||||
{t('chart_card_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
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'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Totalt</p>
|
||||
<p className="font-medium">{preview.mappingStatus.total}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Mappade</p>
|
||||
<p className="font-medium text-success">{preview.mappingStatus.mapped}</p>
|
||||
</div>
|
||||
{chart ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t('chart_to_create')}</p>
|
||||
<p className="font-medium tabular-nums">{chart.toCreate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t('chart_existing')}</p>
|
||||
<p className="font-medium tabular-nums">{chart.existing}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Totalt</p>
|
||||
<p className="font-medium">{preview.mappingStatus.total}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Mappade</p>
|
||||
<p className="font-medium text-success">{preview.mappingStatus.mapped}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Ej mappade</p>
|
||||
<p className={`font-medium ${preview.mappingStatus.unmapped > 0 ? 'text-destructive' : ''}`}>
|
||||
<p className={`font-medium tabular-nums ${preview.mappingStatus.unmapped > 0 ? 'text-destructive' : ''}`}>
|
||||
{preview.mappingStatus.unmapped}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Osäkra</p>
|
||||
<p className={`font-medium ${preview.mappingStatus.lowConfidence > 0 ? 'text-warning' : ''}`}>
|
||||
<p className={`font-medium tabular-nums ${preview.mappingStatus.lowConfidence > 0 ? 'text-warning' : ''}`}>
|
||||
{preview.mappingStatus.lowConfidence}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{chart && chart.sample.length > 0 && (
|
||||
<div className="mt-4 space-y-1 text-sm">
|
||||
{chart.sample.map((acc) => (
|
||||
<div key={acc.number} className="flex gap-2 text-muted-foreground">
|
||||
<span className="font-mono">{acc.number}</span>
|
||||
<span>{acc.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{chart.toCreate > chart.sample.length && (
|
||||
<div className="text-muted-foreground">
|
||||
{t('chart_sample_more', { count: chart.toCreate - chart.sample.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { planChartChanges } from '../chart-plan'
|
||||
import type { AccountMapping } from '../types'
|
||||
|
||||
function mapping(
|
||||
partial: Partial<AccountMapping> & { 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof precheckFiscalPeriod>[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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>,
|
||||
): ChartPlan {
|
||||
const seen = new Set<string>()
|
||||
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),
|
||||
}
|
||||
}
|
||||
+129
-57
@@ -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<string> {
|
||||
): Promise<FiscalYearPrecheck> {
|
||||
// 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<Extract<FiscalYearPrecheck, { verdict: 'invalid' }> | 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<string> {
|
||||
// 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user