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 /**