From 36a1df4f6b26e63eeacbf09b3b3277835240ebe0 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:59:23 +0200 Subject: [PATCH] feat(import): detect and import the article register's Valuta column (#1183) Fixes #1167. The register export gained a Valuta column in #1166 but the importer ignored it, so re-imported non-SEK articles silently became SEK, breaking the export -> edit -> re-import round-trip. - Column detector recognizes valuta/valutakod/currency (claimed before generic columns; no keyword collision with Momskod). - Parser normalizes to upper-case ISO shape, drops malformed codes with a file-level warning, and carries currency per row. - Execute route validates codes lazily against the currencies table (FK stays the backstop when the reference read fails), imports valid codes, defaults absent to SEK, and in merge mode only overwrites when the file explicitly carries a valid currency. - Edit step shows a muted currency marker next to non-SEK prices; manual column mapping offers Valuta. - Export docblock caveat removed. Co-authored-by: Claude Fable 5 --- app/(dashboard)/import/page.tsx | 2 + app/api/export/articles/route.ts | 3 +- .../import/articles/__tests__/execute.test.ts | 57 +++++++++++++++++++ app/api/import/articles/execute/route.ts | 28 +++++++++ components/import/ArticlesEditStep.tsx | 18 ++++-- lib/api/schemas.ts | 7 +++ lib/import/articles/__tests__/parser.test.ts | 47 +++++++++++++++ lib/import/articles/column-detector.ts | 6 ++ lib/import/articles/parser.ts | 16 ++++++ lib/import/articles/types.ts | 4 ++ 10 files changed, 180 insertions(+), 8 deletions(-) diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 6928df6a..91705189 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -1631,6 +1631,7 @@ const ARTICLE_COLUMN_SPECS: RegisterColumnSpec[] = { key: 'type_col', label: 'Typ (vara/tjänst)', required: false }, { key: 'unit_col', label: 'Enhet', required: false }, { key: 'price_col', label: 'Pris exkl moms', required: false }, + { key: 'currency_col', label: 'Valuta', required: false }, { key: 'vat_rate_col', label: 'Moms (%)', required: false }, { key: 'revenue_account_col', label: 'Försäljningskonto', required: false }, { key: 'cost_price_col', label: 'Inköpspris', required: false }, @@ -1713,6 +1714,7 @@ function ArticlesFlow() { type_col: mapping.type_col, unit_col: mapping.unit_col, price_col: mapping.price_col, + currency_col: mapping.currency_col, vat_rate_col: mapping.vat_rate_col, revenue_account_col: mapping.revenue_account_col, cost_price_col: mapping.cost_price_col, diff --git a/app/api/export/articles/route.ts b/app/api/export/articles/route.ts index a6353b8c..7abed585 100644 --- a/app/api/export/articles/route.ts +++ b/app/api/export/articles/route.ts @@ -11,8 +11,7 @@ import type { Article } from '@/types' * * Downloads the article register as xlsx (default) or csv. Read-only: viewers * may export. Column headers match the article importer's detector keywords so - * the file round-trips (export → edit → re-import). Exception: the importer - * does not yet detect Valuta, so re-imported articles default to SEK. + * the file round-trips (export → edit → re-import), including Valuta. */ export const GET = withRouteContext( 'article.export', diff --git a/app/api/import/articles/__tests__/execute.test.ts b/app/api/import/articles/__tests__/execute.test.ts index 303adf29..4df4a0c3 100644 --- a/app/api/import/articles/__tests__/execute.test.ts +++ b/app/api/import/articles/__tests__/execute.test.ts @@ -180,3 +180,60 @@ describe('POST /api/import/articles/execute', () => { expect(body.data.warnings[0]).toContain('3999') }) }) + +describe('POST /api/import/articles/execute currency handling', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockFetchAllRows.mockResolvedValue([]) + mockCheckRevenueAccount.mockResolvedValue('ok') + mockEnsureArticleNumber.mockResolvedValue('AUTO-1') + }) + + it('imports a valid Valuta code and defaults missing to SEK', async () => { + // First queued result: lazy currencies reference read. + enqueue({ data: [{ code: 'SEK' }, { code: 'EUR' }, { code: 'USD' }] }) + enqueue({ data: { id: 'a1', name: 'EU-tjanst', article_number: 'A-1', currency: 'EUR' } }) + enqueue({ data: { id: 'a2', name: 'Svensk tjanst', article_number: 'A-2', currency: 'SEK' } }) + + const res = await POST(makeRequest({ + rows: [ + row({ name: 'EU-tjanst', article_number: 'A-1', currency: 'EUR' }), + row({ row_index: 3, name: 'Svensk tjanst', article_number: 'A-2', currency: null }), + ], + update_duplicates: false, + })) + const { status, body } = await parseJsonResponse<{ data: { created: number; warnings: string[] } }>(res) + + expect(status).toBe(200) + expect(body.data.created).toBe(2) + expect(body.data.warnings).toEqual([]) + + const inserts = mockSupabase.from.mock.calls.filter((c: unknown[]) => c[0] === 'articles') + expect(inserts.length).toBeGreaterThan(0) + }) + + it('drops a code missing from the currencies table with a warning', async () => { + enqueue({ data: [{ code: 'SEK' }, { code: 'EUR' }] }) + enqueue({ data: { id: 'a1', name: 'X', article_number: 'A-1', currency: 'SEK' } }) + + const res = await POST(makeRequest({ + rows: [row({ article_number: 'A-1', currency: 'XXX' })], + update_duplicates: false, + })) + const { status, body } = await parseJsonResponse<{ data: { warnings: string[] } }>(res) + + expect(status).toBe(200) + expect(body.data.warnings.some((w) => w.includes('XXX'))).toBe(true) + }) + + it('rejects a malformed currency shape at validation', async () => { + const res = await POST(makeRequest({ + rows: [row({ currency: 'EURO' })], + update_duplicates: false, + })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }) +}) diff --git a/app/api/import/articles/execute/route.ts b/app/api/import/articles/execute/route.ts index 32e0dcca..a6131fd9 100644 --- a/app/api/import/articles/execute/route.ts +++ b/app/api/import/articles/execute/route.ts @@ -75,6 +75,29 @@ export const POST = withRouteContext( const accountStatusCache = new Map() const droppedAccounts = new Set() const warnings: string[] = [] + + // Currency codes are validated against the currencies reference table + // (the same set the articles.currency FK enforces): unknown codes are + // dropped with a warning instead of failing the row on a FK violation. + // Loaded lazily: most files carry no Valuta column at all. If the + // reference read fails, codes pass through and the FK stays the backstop. + let validCurrencies: Set | null | undefined + const droppedCurrencies = new Set() + const resolveCurrency = async (code: string | null | undefined): Promise => { + if (!code) return null + if (validCurrencies === undefined) { + const { data: currencyRows } = await supabase.from('currencies').select('code') + validCurrencies = currencyRows + ? new Set((currencyRows as { code: string }[]).map((c) => c.code)) + : null + } + if (!validCurrencies || validCurrencies.has(code)) return code + if (!droppedCurrencies.has(code)) { + droppedCurrencies.add(code) + warnings.push(`Valutan ${code} stöds inte, ignorerades: priset importerades som SEK.`) + } + return null + } const resolveRevenueAccount = async (acc: string | null): Promise => { if (!acc) return null let status = accountStatusCache.get(acc) @@ -107,6 +130,7 @@ export const POST = withRouteContext( null const revenueAccount = await resolveRevenueAccount(row.revenue_account) + const currency = await resolveCurrency(row.currency) if (match) { if (!update_duplicates) { @@ -126,6 +150,9 @@ export const POST = withRouteContext( if (row.housework_type) merged.housework_type = row.housework_type if (row.notes) merged.notes = row.notes if (revenueAccount) merged.revenue_account = revenueAccount + // Only when the file explicitly carries a valid currency: absence + // must never reset an existing non-SEK article to SEK. + if (currency) merged.currency = currency if (Object.keys(merged).length === 0) { skipped++ @@ -159,6 +186,7 @@ export const POST = withRouteContext( type: row.type, unit: row.unit || 'st', price_excl_vat: row.price_excl_vat, + currency: currency ?? 'SEK', vat_rate: row.vat_rate, revenue_account: revenueAccount, cost_price: row.cost_price, diff --git a/components/import/ArticlesEditStep.tsx b/components/import/ArticlesEditStep.tsx index 8ecfd84f..e4db57dc 100644 --- a/components/import/ArticlesEditStep.tsx +++ b/components/import/ArticlesEditStep.tsx @@ -187,12 +187,18 @@ export default function ArticlesEditStep({ - handlePriceChange(row.id, e.target.value)} - className="h-8 text-right tabular-nums" - /> +
+ handlePriceChange(row.id, e.target.value)} + className="h-8 text-right tabular-nums" + /> + {/* Exception chip: only non-SEK rows carry a marker. */} + {row.currency && row.currency !== 'SEK' && ( + {row.currency} + )} +
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index cb6a8b71..b1ba618b 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -2166,6 +2166,10 @@ const ImportedArticleRowSchema = z.object({ type: ArticleTypeSchema, unit: z.string(), price_excl_vat: nonNegativeAmount, + // ISO 4217 shape only; the execute route validates against the currencies + // table and drops unknown codes (mirrors revenue_account). Optional so rows + // parsed before this field existed still validate. + currency: z.string().regex(/^[A-Z]{3}$/).nullable().optional(), vat_rate: vatRatePercent, // The execute route re-validates against the chart of accounts (and drops // unknown/inactive overrides), so a loose nullable string is enough here. @@ -2192,6 +2196,9 @@ export const ArticleColumnOverridesSchema = z.object({ type_col: articleColumnIndex, unit_col: articleColumnIndex, price_col: articleColumnIndex, + // Optional + defaulted so a mapping payload from a client rendered before + // this column existed still validates. + currency_col: articleColumnIndex.optional().default(null), vat_rate_col: articleColumnIndex, revenue_account_col: articleColumnIndex, cost_price_col: articleColumnIndex, diff --git a/lib/import/articles/__tests__/parser.test.ts b/lib/import/articles/__tests__/parser.test.ts index 9e29b48d..5db2c425 100644 --- a/lib/import/articles/__tests__/parser.test.ts +++ b/lib/import/articles/__tests__/parser.test.ts @@ -192,3 +192,50 @@ describe('parseArticlesFile', () => { expect(result.rows[1].name).toBe('Kärra') }) }) + +describe('parseArticlesFile currency (Valuta) column', () => { + it('parses and normalizes a Valuta column', () => { + const buffer = buildXlsx([ + ['Benämning', 'Försäljningspris', 'Valuta'], + ['EU-konsulting', '950', 'eur'], + ['Svensk tjänst', '500', 'SEK'], + ['Utan valuta', '100', ''], + ]) + + const result = parseArticlesFile(buffer, 'valuta.xlsx') + + expect(result.detected_columns.currency_col).toBe(2) + expect(result.rows[0].currency).toBe('EUR') + expect(result.rows[1].currency).toBe('SEK') + // Blank cell = not specified: the execute route imports it as SEK. + expect(result.rows[2].currency).toBeNull() + expect(result.warnings).toEqual([]) + }) + + it('drops malformed currency codes with a warning', () => { + const buffer = buildXlsx([ + ['Benämning', 'Pris', 'Valuta'], + ['A', '100', 'EURO'], + ['B', '200', 'EUR'], + ]) + + const result = parseArticlesFile(buffer, 'valuta.xlsx') + + expect(result.rows[0].currency).toBeNull() + expect(result.rows[1].currency).toBe('EUR') + expect(result.warnings.some((w) => w.includes('valutakod'))).toBe(true) + }) + + it('does not let Valuta steal the price or VAT columns', () => { + const buffer = buildXlsx([ + ['Benämning', 'Valuta', 'Försäljningspris', 'Moms %'], + ['A', 'EUR', '100', '25'], + ]) + + const result = parseArticlesFile(buffer, 'valuta.xlsx') + + expect(result.detected_columns.currency_col).toBe(1) + expect(result.rows[0].price_excl_vat).toBe(100) + expect(result.rows[0].vat_rate).toBe(25) + }) +}) diff --git a/lib/import/articles/column-detector.ts b/lib/import/articles/column-detector.ts index 66789479..4c9a3e22 100644 --- a/lib/import/articles/column-detector.ts +++ b/lib/import/articles/column-detector.ts @@ -61,6 +61,10 @@ const NOTES_KEYWORDS = [ 'note', 'övrigt', 'ovrigt', ] +// Matches our own register export header ("Valuta", #1166) plus common +// English variants. Deliberately NO bare 'kod'/'code': collides with Momskod. +const CURRENCY_KEYWORDS = ['valuta', 'valutakod', 'currency', 'currency code'] + /** * Detect article-register columns from headers. * @@ -78,6 +82,7 @@ export function detectArticleColumns(headers: string[]): DetectedArticleColumns // generic name column dead last. const name_en_col = findColumn(headers, NAME_EN_KEYWORDS, taken) const ean_col = findColumn(headers, EAN_KEYWORDS, taken) + const currency_col = findColumn(headers, CURRENCY_KEYWORDS, taken) const article_number_col = findColumn(headers, ARTICLE_NUMBER_KEYWORDS, taken) const revenue_account_col = findColumn(headers, REVENUE_ACCOUNT_KEYWORDS, taken) const cost_price_col = findColumn(headers, COST_PRICE_KEYWORDS, taken) @@ -106,6 +111,7 @@ export function detectArticleColumns(headers: string[]): DetectedArticleColumns type_col, unit_col, price_col, + currency_col, vat_rate_col, revenue_account_col, cost_price_col, diff --git a/lib/import/articles/parser.ts b/lib/import/articles/parser.ts index ab43ba6a..bbdb84b1 100644 --- a/lib/import/articles/parser.ts +++ b/lib/import/articles/parser.ts @@ -101,6 +101,7 @@ export function parseArticlesFile( type_col: null, unit_col: null, price_col: null, + currency_col: null, vat_rate_col: null, revenue_account_col: null, cost_price_col: null, @@ -139,6 +140,7 @@ export function parseArticlesFile( let vatNoteCount = 0 let droppedAccountCount = 0 + let droppedCurrencyCount = 0 for (let i = 0; i < dataRows.length; i++) { const row = dataRows[i] @@ -154,6 +156,16 @@ export function parseArticlesFile( const priceRaw = cell(row, columns.price_col) const price = priceRaw !== null ? parseAmount(priceRaw) : 0 + // Keep only well-formed ISO 4217 codes; the execute route validates them + // against the currencies table. null = column absent/blank (imports as SEK). + const currencyRaw = cell(row, columns.currency_col) + let currency: string | null = null + if (currencyRaw) { + const normalized = currencyRaw.trim().toUpperCase() + if (/^[A-Z]{3}$/.test(normalized)) currency = normalized + else droppedCurrencyCount++ + } + const { rate: vatRate, note: vatNote } = normalizeVatRate(cell(row, columns.vat_rate_col)) if (vatNote) vatNoteCount++ @@ -186,6 +198,7 @@ export function parseArticlesFile( type, unit, price_excl_vat: price, + currency, vat_rate: vatRate, // A note means the rate was snapped or defaulted: flag it for review. vat_rate_adjusted: vatNote !== null, @@ -205,6 +218,9 @@ export function parseArticlesFile( if (droppedAccountCount > 0) { warnings.push(`${droppedAccountCount} rad${droppedAccountCount === 1 ? '' : 'er'} hade ett ogiltigt bokföringskonto (måste vara klass 1-3) som ignorerades.`) } + if (droppedCurrencyCount > 0) { + warnings.push(`${droppedCurrencyCount} rad${droppedCurrencyCount === 1 ? '' : 'er'} hade en ogiltig valutakod (måste vara tre bokstäver, t.ex. EUR) som ignorerades: priset importeras som SEK.`) + } if (rows.length === 0) { warnings.push('Inga giltiga artiklar hittades. Kontrollera att namn-/benämningskolumnen är korrekt mappad.') } diff --git a/lib/import/articles/types.ts b/lib/import/articles/types.ts index 5f23cd06..a47b1d0c 100644 --- a/lib/import/articles/types.ts +++ b/lib/import/articles/types.ts @@ -8,6 +8,7 @@ export interface DetectedArticleColumns { type_col: number | null unit_col: number | null price_col: number | null + currency_col: number | null vat_rate_col: number | null revenue_account_col: number | null cost_price_col: number | null @@ -28,6 +29,9 @@ export interface ParsedArticleRow { unit: string /** Always stored EXCLUDING VAT. */ price_excl_vat: number + /** ISO 4217 price currency from the file's Valuta column; null = not in the + * file (imports as SEK). Validated against the currencies table at execute. */ + currency: string | null /** Integer percent, snapped to one of 0 | 6 | 12 | 25. */ vat_rate: number /**