import { describe, it, expect } from 'vitest' import * as XLSX from 'xlsx' import { parseArticlesFile } from '../parser' function buildXlsx(rows: (string | number)[][]): ArrayBuffer { const ws = XLSX.utils.aoa_to_sheet(rows) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Artiklar') return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer } describe('parseArticlesFile', () => { it('parses a basic Swedish article register', () => { const buffer = buildXlsx([ ['Benämning', 'Artikelnummer', 'Pris', 'Moms', 'Enhet', 'Typ'], ['Konsulttimme', 'A-100', '950', '25', 'tim', 'tjänst'], ['Skruv', 'A-200', '2,50', '25', 'st', 'vara'], ]) const result = parseArticlesFile(buffer, 'artiklar.xlsx') expect(result.total_rows).toBe(2) expect(result.rows[0].name).toBe('Konsulttimme') expect(result.rows[0].article_number).toBe('A-100') expect(result.rows[0].price_excl_vat).toBe(950) expect(result.rows[0].vat_rate).toBe(25) expect(result.rows[0].unit).toBe('tim') expect(result.rows[0].type).toBe('tjanst') expect(result.rows[1].type).toBe('vara') expect(result.rows[1].price_excl_vat).toBe(2.5) expect(result.rows[0].is_valid).toBe(true) // A clean, valid VAT rate is not flagged as adjusted. expect(result.rows[0].vat_rate_adjusted).toBe(false) }) it('detects Fortnox-style export headers', () => { const buffer = buildXlsx([ ['Artikelnummer', 'Benämning', 'Försäljningspris', 'Momskod', 'Försäljningskonto', 'Enhet'], ['100', 'Webdesign', '1 200', '25', '3001', 'st'], ]) const result = parseArticlesFile(buffer, 'fortnox.xlsx') const r = result.rows[0] expect(r.article_number).toBe('100') expect(r.name).toBe('Webdesign') expect(r.price_excl_vat).toBe(1200) // "1 200" → 1200 expect(r.vat_rate).toBe(25) expect(r.revenue_account).toBe('3001') }) it('parses Swedish decimal prices', () => { const buffer = buildXlsx([ ['Benämning', 'Pris'], ['A', '1 234,56'], ['B', '1.234,50'], ['C', '500'], ]) const result = parseArticlesFile(buffer, 'priser.xlsx') expect(result.rows[0].price_excl_vat).toBe(1234.56) expect(result.rows[1].price_excl_vat).toBe(1234.5) expect(result.rows[2].price_excl_vat).toBe(500) }) it('snaps VAT to the nearest statutory rate and warns', () => { const buffer = buildXlsx([ ['Benämning', 'Moms'], ['A', '25%'], ['B', '7'], // → 6 ['C', ''], // → 25 default ]) const result = parseArticlesFile(buffer, 'moms.xlsx') expect(result.rows[0].vat_rate).toBe(25) expect(result.rows[1].vat_rate).toBe(6) expect(result.rows[2].vat_rate).toBe(25) // The "7" → 6 snap should surface a file-level warning. expect(result.warnings.some((w) => w.includes('momssats'))).toBe(true) // Per-row flag: only the snapped row (7 → 6) is marked adjusted. expect(result.rows[0].vat_rate_adjusted).toBe(false) // "25%" is already valid expect(result.rows[1].vat_rate_adjusted).toBe(true) // 7 → 6 expect(result.rows[2].vat_rate_adjusted).toBe(false) // empty → default 25 }) it('defaults an unparseable momskod to 25 with a warning', () => { const buffer = buildXlsx([ ['Benämning', 'Momskod'], ['A', 'MP1'], ]) const result = parseArticlesFile(buffer, 'momskod.xlsx') expect(result.rows[0].vat_rate).toBe(25) expect(result.rows[0].vat_rate_adjusted).toBe(true) expect(result.warnings.some((w) => w.toLowerCase().includes('moms'))).toBe(true) }) it('normalizes article type and falls back to tjanst', () => { const buffer = buildXlsx([ ['Benämning', 'Typ'], ['A', 'Produkt'], ['B', 'service'], ['C', ''], ]) const result = parseArticlesFile(buffer, 'typ.xlsx') expect(result.rows[0].type).toBe('vara') expect(result.rows[1].type).toBe('tjanst') expect(result.rows[2].type).toBe('tjanst') }) it('falls back to "st" when no unit is given', () => { const buffer = buildXlsx([ ['Benämning'], ['A'], ]) const result = parseArticlesFile(buffer, 'unit.xlsx') expect(result.rows[0].unit).toBe('st') }) it('keeps class 1-3 posting accounts and drops a class-4 account', () => { const buffer = buildXlsx([ ['Benämning', 'Försäljningskonto'], ['A', '3001'], ['B', '1930'], ['C', '4000'], ]) const result = parseArticlesFile(buffer, 'konto.xlsx') expect(result.rows[0].revenue_account).toBe('3001') expect(result.rows[1].revenue_account).toBe('1930') expect(result.rows[2].revenue_account).toBeNull() expect(result.warnings.some((w) => w.toLowerCase().includes('bokföringskonto'))).toBe(true) }) it('treats a blank cost price as null (not 0)', () => { const buffer = buildXlsx([ ['Benämning', 'Inköpspris'], ['A', ''], ['B', '100'], ]) const result = parseArticlesFile(buffer, 'cost.xlsx') expect(result.rows[0].cost_price).toBeNull() expect(result.rows[1].cost_price).toBe(100) }) it('warns when the price column looks incl-VAT', () => { const buffer = buildXlsx([ ['Benämning', 'Pris inkl moms'], ['A', '125'], ]) const result = parseArticlesFile(buffer, 'brutto.xlsx') expect(result.warnings.some((w) => w.toLowerCase().includes('inkl'))).toBe(true) }) it('skips rows with empty name and preserves row_index', () => { const buffer = buildXlsx([ ['Benämning'], ['A'], [''], ['C'], ]) const result = parseArticlesFile(buffer, 'sparse.xlsx') expect(result.total_rows).toBe(2) expect(result.rows.map((r) => r.name)).toEqual(['A', 'C']) expect(result.rows[0].row_index).toBe(2) expect(result.rows[1].row_index).toBe(4) }) it('flags a negative price as invalid', () => { const buffer = buildXlsx([ ['Benämning', 'Pris'], ['A', '-50'], ]) const result = parseArticlesFile(buffer, 'neg.xlsx') expect(result.rows[0].is_valid).toBe(false) expect(result.rows[0].validation_errors).toContain('Priset kan inte vara negativt') }) it('returns a warning when zero rows match', () => { const buffer = buildXlsx([['Benämning']]) const result = parseArticlesFile(buffer, 'empty.xlsx') expect(result.total_rows).toBe(0) expect(result.warnings.length).toBeGreaterThan(0) }) it('preserves Swedish characters when reading a UTF-8 CSV', () => { const csv = new TextEncoder().encode( 'Benämning,Enhet\nMöbel,st\nKärra,st\n', ).buffer const result = parseArticlesFile(csv, 'artiklar.csv') expect(result.rows[0].name).toBe('Möbel') 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) }) }) describe('parseArticlesFile ROT/RUT (housework) column', () => { it('normalizes work-type codes and bare kinds, and drops boolean-style values', () => { const buffer = buildXlsx([ ['Benämning', 'Pris', 'Husarbete'], ['Städning', '450', 'stad'], ['Fönsterputs', '500', 'RUT'], // A boolean "Rot"/"Husarbete" column exported by other systems: '1'/'0' // are not values the invoice editor can interpret, so they must not be // stored (this is what produced 178 junk rows on prod). ['Bygg', '600', '1'], ['Skruv', '2', '0'], ]) const result = parseArticlesFile(buffer, 'husarbete.xlsx') expect(result.detected_columns.housework_type_col).toBe(2) expect(result.rows[0].housework_type).toBe('STAD') expect(result.rows[1].housework_type).toBe('RUT') expect(result.rows[2].housework_type).toBeNull() expect(result.rows[3].housework_type).toBeNull() // Dropping a non-empty value is surfaced, never silent. expect(result.warnings.some((w) => w.includes('2 rader hade ett ROT/RUT-värde'))).toBe(true) }) })