feat: unified document inbox, full BAS 2026, and document-transaction matching
- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026) with K2 exclusion flags, per-class data files, and computed SRU codes - Evolve invoice inbox into unified document inbox handling invoices, receipts, and government letters with AI-powered classification (Claude Haiku Vision) - Add multi-pass document-to-transaction matching engine with greedy assignment for both supplier invoices (reference/amount/date/name) and receipts (weighted amount/merchant/date scoring) - Add supplier invoice matching in transaction ingest pipeline - Inject booking template suggestions into AI extraction prompts - Surface matched documents in swipe categorization UI with one-tap booking - Auto-activate missing BAS accounts during SIE import against full reference - Add K2 filter toggle in Chart of Accounts manager - Add receipt confirmation route with BFNAR representation fields - Add database migrations for K2 support and document matching columns - Remove obsolete extension migration scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6956a757f3
commit
39e407644d
@@ -311,6 +311,7 @@ export const CategorizeTransactionSchema = z.object({
|
||||
vat_treatment: VatTreatmentSchema.optional(),
|
||||
account_override: accountNumber.optional(),
|
||||
user_description: z.string().max(500).optional(),
|
||||
inbox_item_id: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
export const BookTransactionSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
BAS_REFERENCE,
|
||||
ACCOUNT_CLASS_LABELS,
|
||||
ACCOUNT_GROUP_LABELS,
|
||||
getBASReference,
|
||||
getBASReferenceByClass,
|
||||
isStandardBASAccount,
|
||||
} from '../bas-reference'
|
||||
|
||||
describe('BAS_REFERENCE data integrity', () => {
|
||||
it('contains the expected number of accounts (~1,276)', () => {
|
||||
expect(BAS_REFERENCE.length).toBeGreaterThanOrEqual(1250)
|
||||
expect(BAS_REFERENCE.length).toBeLessThanOrEqual(1300)
|
||||
})
|
||||
|
||||
it('has no duplicate account numbers', () => {
|
||||
const numbers = BAS_REFERENCE.map((a) => a.account_number)
|
||||
const uniqueNumbers = new Set(numbers)
|
||||
expect(uniqueNumbers.size).toBe(numbers.length)
|
||||
})
|
||||
|
||||
it('account_class matches the first digit of account_number', () => {
|
||||
for (const account of BAS_REFERENCE) {
|
||||
const firstDigit = parseInt(account.account_number[0], 10)
|
||||
expect(account.account_class).toBe(firstDigit)
|
||||
}
|
||||
})
|
||||
|
||||
it('account_group matches the first two digits of account_number', () => {
|
||||
for (const account of BAS_REFERENCE) {
|
||||
const firstTwo = account.account_number.substring(0, 2)
|
||||
expect(account.account_group).toBe(firstTwo)
|
||||
}
|
||||
})
|
||||
|
||||
it('every account has a non-null sru_code', () => {
|
||||
const withoutSru = BAS_REFERENCE.filter((a) => a.sru_code === null)
|
||||
expect(withoutSru).toEqual([])
|
||||
})
|
||||
|
||||
it('every account has a non-empty description', () => {
|
||||
const withoutDesc = BAS_REFERENCE.filter((a) => !a.description || a.description.trim() === '')
|
||||
expect(withoutDesc).toEqual([])
|
||||
})
|
||||
|
||||
it('every account has a valid account_type', () => {
|
||||
const validTypes = ['asset', 'liability', 'equity', 'revenue', 'expense', 'untaxed_reserves']
|
||||
for (const account of BAS_REFERENCE) {
|
||||
expect(validTypes).toContain(account.account_type)
|
||||
}
|
||||
})
|
||||
|
||||
it('every account has a valid normal_balance', () => {
|
||||
for (const account of BAS_REFERENCE) {
|
||||
expect(['debit', 'credit']).toContain(account.normal_balance)
|
||||
}
|
||||
})
|
||||
|
||||
it('all account numbers are 4 digits', () => {
|
||||
for (const account of BAS_REFERENCE) {
|
||||
expect(account.account_number).toMatch(/^\d{4}$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-standard accounts removed', () => {
|
||||
const nonStandard = ['1249', '1259', '1400', '1580', '3109', '4100', '4990', '7834', '7835', '7910', '8710']
|
||||
|
||||
for (const num of nonStandard) {
|
||||
it(`${num} is not in the catalog`, () => {
|
||||
expect(isStandardBASAccount(num)).toBe(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('Class 2 account_type correctness', () => {
|
||||
it('20xx accounts are equity', () => {
|
||||
const group20 = BAS_REFERENCE.filter((a) => a.account_group === '20')
|
||||
expect(group20.length).toBeGreaterThan(0)
|
||||
for (const a of group20) {
|
||||
expect(a.account_type).toBe('equity')
|
||||
}
|
||||
})
|
||||
|
||||
it('21xx accounts are untaxed_reserves', () => {
|
||||
const group21 = BAS_REFERENCE.filter((a) => a.account_group === '21')
|
||||
expect(group21.length).toBeGreaterThan(0)
|
||||
for (const a of group21) {
|
||||
expect(a.account_type).toBe('untaxed_reserves')
|
||||
}
|
||||
})
|
||||
|
||||
it('22xx-29xx accounts are liability', () => {
|
||||
const liabilityGroups = BAS_REFERENCE.filter(
|
||||
(a) => a.account_class === 2 && parseInt(a.account_group) >= 22
|
||||
)
|
||||
expect(liabilityGroups.length).toBeGreaterThan(0)
|
||||
for (const a of liabilityGroups) {
|
||||
expect(a.account_type).toBe('liability')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Class 8 normal_balance correctness', () => {
|
||||
it('8310 (Ränteintäkter) has credit normal_balance', () => {
|
||||
const account = getBASReference('8310')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('credit')
|
||||
})
|
||||
|
||||
it('8410 (Räntekostnader) has debit normal_balance', () => {
|
||||
const account = getBASReference('8410')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('debit')
|
||||
})
|
||||
|
||||
it('8910 (Skatt) has debit normal_balance', () => {
|
||||
const account = getBASReference('8910')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('debit')
|
||||
})
|
||||
|
||||
it('8810 (Bokslutsdispositioner) has credit normal_balance', () => {
|
||||
const account = getBASReference('8810')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('credit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Contra accounts have opposite normal_balance', () => {
|
||||
it('1119 (Ack. avskrivningar byggnader) has credit balance', () => {
|
||||
const account = getBASReference('1119')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('credit')
|
||||
})
|
||||
|
||||
it('1229 (Ack. avskrivningar inventarier) has credit balance', () => {
|
||||
const account = getBASReference('1229')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('credit')
|
||||
})
|
||||
|
||||
it('2011 (Egna varuuttag) has debit balance', () => {
|
||||
const account = getBASReference('2011')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('debit')
|
||||
})
|
||||
|
||||
it('3740 (Öres- och kronutjämning) has debit balance', () => {
|
||||
const account = getBASReference('3740')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('debit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('K2-excluded accounts', () => {
|
||||
const k2Excluded = [
|
||||
'1010', '1011', '1012', '1018', '1019',
|
||||
'1370', '1518',
|
||||
'2092', '2096', '2240', '2448',
|
||||
'3940', '7940',
|
||||
'8290', '8291', '8295',
|
||||
'8320', '8321', '8325',
|
||||
'8450', '8451', '8455',
|
||||
'8480', '8940',
|
||||
]
|
||||
|
||||
it('known K2-excluded accounts are marked correctly', () => {
|
||||
for (const num of k2Excluded) {
|
||||
const account = getBASReference(num)
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.k2_excluded).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('common non-K2-excluded accounts are not marked', () => {
|
||||
const normalAccounts = ['1510', '1930', '2440', '3001', '4010', '5010', '7010', '8310']
|
||||
for (const num of normalAccounts) {
|
||||
const account = getBASReference(num)
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.k2_excluded).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('total K2-excluded count matches expected (24)', () => {
|
||||
const k2Count = BAS_REFERENCE.filter((a) => a.k2_excluded).length
|
||||
expect(k2Count).toBe(24)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ACCOUNT_GROUP_LABELS coverage', () => {
|
||||
it('all groups present in BAS_REFERENCE have labels', () => {
|
||||
const groups = new Set(BAS_REFERENCE.map((a) => a.account_group))
|
||||
for (const group of groups) {
|
||||
expect(ACCOUNT_GROUP_LABELS[group]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('covers at least 70 groups', () => {
|
||||
expect(Object.keys(ACCOUNT_GROUP_LABELS).length).toBeGreaterThanOrEqual(70)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ACCOUNT_CLASS_LABELS', () => {
|
||||
it('has labels for all 8 classes', () => {
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
expect(ACCOUNT_CLASS_LABELS[i]).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Helper functions', () => {
|
||||
it('getBASReference returns correct account', () => {
|
||||
const account = getBASReference('1930')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.account_name).toBe('Företagskonto')
|
||||
expect(account!.account_type).toBe('asset')
|
||||
})
|
||||
|
||||
it('getBASReference returns undefined for non-existent account', () => {
|
||||
expect(getBASReference('9999')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getBASReferenceByClass returns accounts for each class', () => {
|
||||
for (let cls = 1; cls <= 8; cls++) {
|
||||
const accounts = getBASReferenceByClass(cls)
|
||||
expect(accounts.length).toBeGreaterThan(0)
|
||||
for (const a of accounts) {
|
||||
expect(a.account_class).toBe(cls)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('getBASReferenceByClass returns empty array for non-existent class', () => {
|
||||
expect(getBASReferenceByClass(9)).toEqual([])
|
||||
})
|
||||
|
||||
it('isStandardBASAccount returns true for standard accounts', () => {
|
||||
expect(isStandardBASAccount('1510')).toBe(true)
|
||||
expect(isStandardBASAccount('3001')).toBe(true)
|
||||
expect(isStandardBASAccount('8999')).toBe(true)
|
||||
})
|
||||
|
||||
it('isStandardBASAccount returns false for non-standard accounts', () => {
|
||||
expect(isStandardBASAccount('9999')).toBe(false)
|
||||
expect(isStandardBASAccount('0000')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account class distribution', () => {
|
||||
it('class 1 (assets) has ~230 accounts', () => {
|
||||
const cls = getBASReferenceByClass(1)
|
||||
expect(cls.length).toBeGreaterThanOrEqual(220)
|
||||
expect(cls.length).toBeLessThanOrEqual(240)
|
||||
})
|
||||
|
||||
it('class 2 (equity & liabilities) has ~265 accounts', () => {
|
||||
const cls = getBASReferenceByClass(2)
|
||||
expect(cls.length).toBeGreaterThanOrEqual(255)
|
||||
expect(cls.length).toBeLessThanOrEqual(275)
|
||||
})
|
||||
|
||||
it('class 3 (revenue) has ~100 accounts', () => {
|
||||
const cls = getBASReferenceByClass(3)
|
||||
expect(cls.length).toBeGreaterThanOrEqual(90)
|
||||
expect(cls.length).toBeLessThanOrEqual(110)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
|
||||
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves'
|
||||
|
||||
export interface AccountDescription {
|
||||
name: string
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,917 @@
|
||||
import type { BASReferenceAccount } from '../bas-reference'
|
||||
|
||||
export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
{
|
||||
account_number: '4000',
|
||||
account_name: 'Inköp av handelsvaror (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4010',
|
||||
account_name: 'Inköp av handelsvaror i Sverige',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader for inkop av varor avsedda for vidareforssaljning.',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4060',
|
||||
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4065',
|
||||
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4066',
|
||||
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4067',
|
||||
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4070',
|
||||
account_name: 'Inköp av handelsvaror från annat EU-land',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EU-land',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4075',
|
||||
account_name: 'Inköp av handelsvaror från annat EUland, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EUland, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4076',
|
||||
account_name: 'Inköp av handelsvaror från annat EUland, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EUland, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4077',
|
||||
account_name: 'Inköp av handelsvaror från annat EUland, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EUland, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4078',
|
||||
account_name: 'Inköp av handelsvaror från annat EUland, momsfri',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EUland, momsfri',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4080',
|
||||
account_name: 'Import av handelsvaror',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av handelsvaror',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4085',
|
||||
account_name: 'Import av handelsvaror, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av handelsvaror, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4086',
|
||||
account_name: 'Import av handelsvaror, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av handelsvaror, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4087',
|
||||
account_name: 'Import av handelsvaror, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av handelsvaror, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4090',
|
||||
account_name: 'Erhållna rabatter (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna rabatter (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4091',
|
||||
account_name: 'Erhållna kassarabatter (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna kassarabatter (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4092',
|
||||
account_name: 'Erhållna mängdrabatter (inkl. bonus) (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna mängdrabatter (inkl. bonus) (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4099',
|
||||
account_name: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4200',
|
||||
account_name: 'Sålda handelsvaror VMB (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror VMB (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4210',
|
||||
account_name: 'Sålda handelsvaror VMB',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror VMB',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4211',
|
||||
account_name: 'Sålda handelsvaror positiv VMB 25 %',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror positiv VMB 25 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4212',
|
||||
account_name: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4300',
|
||||
account_name: 'Inköp av råvaror och material i Sverige (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '43',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4310',
|
||||
account_name: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '43',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4400',
|
||||
account_name: 'Inköp av råvaror och material, tjänster m.m. i Sverige, omvänd betalningsskyldighet (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material, tjänster m.m. i Sverige, omvänd betalningsskyldighet (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4410',
|
||||
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4415',
|
||||
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4416',
|
||||
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4417',
|
||||
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4420',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4425',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4426',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4427',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4500',
|
||||
account_name: 'Inköp av råvaror och material, tjänster m.m. från utlandet (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Varuinkop fran utlandet (ravaror och fornodenheter).',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4510',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4515',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land, 25 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land, 25 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4516',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land, 12 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land, 12 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4517',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land, 6 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land, 6 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4518',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land, momsfri',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land, momsfri',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4530',
|
||||
account_name: 'Inköp av tjänster m.m. från utlandet',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster m.m. från utlandet',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4531',
|
||||
account_name: 'Inköp av tjänster från ett land utanför EU, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från ett land utanför EU, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4532',
|
||||
account_name: 'Inköp av tjänster från ett land utanför EU, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från ett land utanför EU, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4533',
|
||||
account_name: 'Inköp av tjänster från ett land utanför EU, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från ett land utanför EU, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4535',
|
||||
account_name: 'Inköp av tjänster från annat EU-land, 25 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från annat EU-land, 25 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4536',
|
||||
account_name: 'Inköp av tjänster från annat EU-land, 12 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från annat EU-land, 12 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4537',
|
||||
account_name: 'Inköp av tjänster från annat EU-land, 6 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från annat EU-land, 6 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4538',
|
||||
account_name: 'Inköp av tjänster från annat EU-land, momsfri',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från annat EU-land, momsfri',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4540',
|
||||
account_name: 'Import av råvaror och material',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4545',
|
||||
account_name: 'Import av råvaror och material, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4546',
|
||||
account_name: 'Import av råvaror och material, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4547',
|
||||
account_name: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4600',
|
||||
account_name: 'Inköp av tjänster, underentreprenader och legoarbeten i Sverige (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '46',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader for arbete utfort av underleverantorer som del av leverans till kund.',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4610',
|
||||
account_name: 'Inköp av tjänster och underentreprenader',
|
||||
account_class: 4,
|
||||
account_group: '46',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster och underentreprenader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4670',
|
||||
account_name: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '46',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4700',
|
||||
account_name: 'Reduktion av inköpspriser (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Reduktion av inköpspriser (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4730',
|
||||
account_name: 'Erhållna rabatter (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna rabatter (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4731',
|
||||
account_name: 'Erhållna kassarabatter (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna kassarabatter (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4732',
|
||||
account_name: 'Erhållna mängdrabatter (inkl. bonus) (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna mängdrabatter (inkl. bonus) (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4739',
|
||||
account_name: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4800',
|
||||
account_name: 'Andra produktionskostnader (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Andra produktionskostnader (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4810',
|
||||
account_name: 'Kostnader för energi (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader för energi (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4820',
|
||||
account_name: 'Kostnader för drivmedel (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader för drivmedel (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4830',
|
||||
account_name: 'Kostnader för resor (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader för resor (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4840',
|
||||
account_name: 'Kostnader för hyra av utrustning (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader för hyra av utrustning (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4890',
|
||||
account_name: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4900',
|
||||
account_name: 'Förändring av lager (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4910',
|
||||
account_name: 'Förändring av lager av råvaror',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av råvaror',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4920',
|
||||
account_name: 'Förändring av lager av tillsatsmaterial och förnödenheter',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av tillsatsmaterial och förnödenheter',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4940',
|
||||
account_name: 'Förändring av produkter i arbete',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av produkter i arbete',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4944',
|
||||
account_name: 'Förändring av produkter i arbete, material och utlägg',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av produkter i arbete, material och utlägg',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4945',
|
||||
account_name: 'Förändring av produkter i arbete, omkostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av produkter i arbete, omkostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4947',
|
||||
account_name: 'Förändring av produkter i arbete, personalkostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av produkter i arbete, personalkostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4950',
|
||||
account_name: 'Förändring av lager av färdiga varor',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av färdiga varor',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4960',
|
||||
account_name: 'Förändring av lager av handelsvaror',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av handelsvaror',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4970',
|
||||
account_name: 'Förändring av pågående arbeten, nedlagda kostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av pågående arbeten, nedlagda kostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4974',
|
||||
account_name: 'Förändring av pågående arbeten, material och utlägg',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av pågående arbeten, material och utlägg',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4975',
|
||||
account_name: 'Förändring av pågående arbeten, omkostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av pågående arbeten, omkostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4977',
|
||||
account_name: 'Förändring av pågående arbeten, personalkostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av pågående arbeten, personalkostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4980',
|
||||
account_name: 'Förändring av lager av värdepapper (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av värdepapper (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4981',
|
||||
account_name: 'Sålda värdepappers anskaffningsvärde (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Sålda värdepappers anskaffningsvärde (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4987',
|
||||
account_name: 'Nedskrivning av värdepapper (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Nedskrivning av värdepapper (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4988',
|
||||
account_name: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
import { CLASS_1_ACCOUNTS } from './class-1-assets'
|
||||
import { CLASS_2_ACCOUNTS } from './class-2-equity-liabilities'
|
||||
import { CLASS_3_ACCOUNTS } from './class-3-revenue'
|
||||
import { CLASS_4_ACCOUNTS } from './class-4-purchases'
|
||||
import { CLASS_5_ACCOUNTS } from './class-5-external-expenses'
|
||||
import { CLASS_6_ACCOUNTS } from './class-6-other-external'
|
||||
import { CLASS_7_ACCOUNTS } from './class-7-personnel'
|
||||
import { CLASS_8_ACCOUNTS } from './class-8-financial'
|
||||
|
||||
import type { BASReferenceAccount } from '../bas-reference'
|
||||
|
||||
export const BAS_REFERENCE: BASReferenceAccount[] = [
|
||||
...CLASS_1_ACCOUNTS,
|
||||
...CLASS_2_ACCOUNTS,
|
||||
...CLASS_3_ACCOUNTS,
|
||||
...CLASS_4_ACCOUNTS,
|
||||
...CLASS_5_ACCOUNTS,
|
||||
...CLASS_6_ACCOUNTS,
|
||||
...CLASS_7_ACCOUNTS,
|
||||
...CLASS_8_ACCOUNTS,
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* SRU Code Computation
|
||||
*
|
||||
* Replicates the range-based SRU code assignment logic from
|
||||
* supabase/migrations/20240101000021_sru_codes.sql.
|
||||
*
|
||||
* SRU codes are used for NE (enskild firma) and INK2 (aktiebolag) tax forms
|
||||
* filed with Skatteverket.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the SRU code for a given BAS account number.
|
||||
*
|
||||
* The logic applies NE-form codes first (higher priority for revenue/expense
|
||||
* accounts), then falls back to INK2 balance sheet codes.
|
||||
*/
|
||||
export function computeSRUCode(accountNumber: string): string | null {
|
||||
const num = accountNumber
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NE form codes (enskild firma) — fields 7310-7325
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// NE: R1 - Försäljning med moms (3000-3499 excl 3100)
|
||||
if (num >= '3000' && num <= '3499' && num !== '3100') return '7310'
|
||||
|
||||
// NE: R2 - Momsfria intäkter (3100, 3900, 3970-3980)
|
||||
if (num === '3100' || num === '3900' || (num >= '3970' && num <= '3980')) return '7311'
|
||||
|
||||
// NE: R3 - Bil/bostadsförmån (3200-3299) — overlaps with R1, R1 wins
|
||||
if (num >= '3200' && num <= '3299') return '7312'
|
||||
|
||||
// NE: R4 - Ränteintäkter (8310-8330)
|
||||
if (num >= '8310' && num <= '8330') return '7313'
|
||||
|
||||
// NE: R5 - Varuinköp (4000-4990)
|
||||
if (num >= '4000' && num <= '4990') return '7320'
|
||||
|
||||
// NE: R6 - Övriga kostnader (5000-6990, 7970)
|
||||
if ((num >= '5000' && num <= '6990') || num === '7970') return '7321'
|
||||
|
||||
// NE: R7 - Lönekostnader (7000-7699)
|
||||
if (num >= '7000' && num <= '7699') return '7322'
|
||||
|
||||
// NE: R8 - Räntekostnader (8400-8499)
|
||||
if (num >= '8400' && num <= '8499') return '7323'
|
||||
|
||||
// NE: R9 - Avskrivningar fastighet (7820)
|
||||
if (num === '7820') return '7324'
|
||||
|
||||
// NE: R10 - Avskrivningar övrigt (7700-7899 excl 7820)
|
||||
if (num >= '7700' && num <= '7899' && num !== '7820') return '7325'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INK2 form codes (aktiebolag) — fields 7201-7380
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// INK2: Immateriella anläggningstillgångar (1000-1099)
|
||||
if (num >= '1000' && num <= '1099') return '7201'
|
||||
|
||||
// INK2: Materiella anläggningstillgångar (1100-1299)
|
||||
if (num >= '1100' && num <= '1299') return '7202'
|
||||
|
||||
// INK2: Finansiella anläggningstillgångar (1300-1399)
|
||||
if (num >= '1300' && num <= '1399') return '7203'
|
||||
|
||||
// INK2: Varulager (1400-1499)
|
||||
if (num >= '1400' && num <= '1499') return '7210'
|
||||
|
||||
// INK2: Kundfordringar (1500-1599)
|
||||
if (num >= '1500' && num <= '1599') return '7211'
|
||||
|
||||
// INK2: Övriga omsättningstillgångar (1600-1999)
|
||||
if (num >= '1600' && num <= '1999') return '7212'
|
||||
|
||||
// INK2: Aktiekapital (2081)
|
||||
if (num === '2081') return '7220'
|
||||
|
||||
// INK2: Övrigt eget kapital (2085-2098)
|
||||
if (num >= '2085' && num <= '2098') return '7221'
|
||||
|
||||
// INK2: Årets resultat (2099)
|
||||
if (num === '2099') return '7222'
|
||||
|
||||
// INK2: Skulder (2100-2499)
|
||||
if (num >= '2100' && num <= '2499') return '7230'
|
||||
|
||||
// INK2: Övriga skulder (2500-2999)
|
||||
if (num >= '2500' && num <= '2999') return '7231'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INK2 remaining income statement (fallback for class 3-8 not covered by NE)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (num >= '3000' && num <= '3999') return '7310'
|
||||
if (num >= '4000' && num <= '4999') return '7320'
|
||||
if (num >= '5000' && num <= '6999') return '7330'
|
||||
if (num >= '7000' && num <= '7699') return '7340'
|
||||
if (num >= '7700' && num <= '7899') return '7350'
|
||||
if (num >= '7900' && num <= '7999') return '7360'
|
||||
if (num >= '8000' && num <= '8499') return '7370'
|
||||
if (num >= '8500' && num <= '8999') return '7380'
|
||||
|
||||
// Equity accounts not covered above (2000-2084)
|
||||
if (num >= '2000' && num <= '2084') return '7221'
|
||||
|
||||
return null
|
||||
}
|
||||
+53
-1939
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Template Prompt Builder
|
||||
*
|
||||
* Generates the booking template list section for AI extraction prompts.
|
||||
* Used by both receipt-analyzer and invoice-analyzer to stay in sync.
|
||||
*/
|
||||
|
||||
import { BOOKING_TEMPLATES } from './booking-templates'
|
||||
|
||||
/**
|
||||
* Build the template list section for AI prompts.
|
||||
* Lists all expense templates with their Swedish name, primary debit account, and VAT rate.
|
||||
*/
|
||||
export function buildTemplatePromptSection(): string {
|
||||
const expenseTemplates = BOOKING_TEMPLATES.filter((t) => t.direction === 'expense')
|
||||
|
||||
const lines = expenseTemplates.map((t) => {
|
||||
const vatInfo = t.vat_rate > 0 ? `moms ${t.vat_rate * 100}%` : 'momsfri'
|
||||
return `- ${t.id}: ${t.name_sv} (konto ${t.debit_account}, ${vatInfo})`
|
||||
})
|
||||
|
||||
return `BOKFÖRINGSMALLAR (välj den mest passande suggestedTemplateId):
|
||||
${lines.join('\n')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact template ID list for validation.
|
||||
*/
|
||||
export function getValidTemplateIds(): string[] {
|
||||
return BOOKING_TEMPLATES.map((t) => t.id)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock Anthropic SDK - vi.hoisted ensures the variable is available before vi.mock hoisting
|
||||
const { mockCreate } = vi.hoisted(() => {
|
||||
const mockCreate = vi.fn()
|
||||
return { mockCreate }
|
||||
})
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => {
|
||||
return {
|
||||
default: class MockAnthropic {
|
||||
messages = { create: mockCreate }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { classifyDocument } from '../classifier'
|
||||
|
||||
function makeResponse(json: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(json) }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('classifyDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('classifies a supplier invoice', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.95,
|
||||
reasoning: 'Contains invoice number, bankgiro, and supplier details',
|
||||
isReverseCharge: false,
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('supplier_invoice')
|
||||
expect(result.confidence).toBe(0.95)
|
||||
expect(result.isReverseCharge).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a receipt', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.92,
|
||||
reasoning: 'Store receipt with line items and total',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('receipt')
|
||||
expect(result.confidence).toBe(0.92)
|
||||
expect(result.isReverseCharge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies a government letter', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'government_letter',
|
||||
confidence: 0.88,
|
||||
reasoning: 'Letter from Skatteverket',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('government_letter')
|
||||
expect(result.confidence).toBe(0.88)
|
||||
expect(result.isReverseCharge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies unknown documents', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'unknown',
|
||||
confidence: 0.5,
|
||||
reasoning: 'Cannot determine document type',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/png')
|
||||
|
||||
expect(result.type).toBe('unknown')
|
||||
expect(result.confidence).toBe(0.5)
|
||||
})
|
||||
|
||||
it('detects reverse charge on EU invoices', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.93,
|
||||
reasoning: 'EU invoice with reverse charge',
|
||||
isReverseCharge: true,
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('supplier_invoice')
|
||||
expect(result.isReverseCharge).toBe(true)
|
||||
})
|
||||
|
||||
it('retries on API error', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API timeout'))
|
||||
.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Receipt',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('receipt')
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws on JSON parse error without retry', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'not valid json' }],
|
||||
})
|
||||
|
||||
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
|
||||
'Failed to parse AI response'
|
||||
)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws on unsupported MIME type', async () => {
|
||||
await expect(classifyDocument('base64data', 'text/plain')).rejects.toThrow(
|
||||
'Unsupported file type: text/plain'
|
||||
)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to unknown for invalid type values', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'invalid_type',
|
||||
confidence: 0.8,
|
||||
reasoning: 'Test',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('unknown')
|
||||
})
|
||||
|
||||
it('handles PDF content blocks correctly', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.95,
|
||||
reasoning: 'PDF invoice',
|
||||
isReverseCharge: false,
|
||||
})
|
||||
)
|
||||
|
||||
await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'document' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('handles image content blocks correctly', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Image receipt',
|
||||
})
|
||||
)
|
||||
|
||||
await classifyDocument('base64data', 'image/png')
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'image' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('strips markdown code blocks from response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '```json\n{"type":"receipt","confidence":0.9,"reasoning":"Test"}\n```',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
expect(result.type).toBe('receipt')
|
||||
})
|
||||
|
||||
it('throws after max retries', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API error 1'))
|
||||
.mockRejectedValueOnce(new Error('API error 2'))
|
||||
.mockRejectedValueOnce(new Error('API error 3'))
|
||||
|
||||
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
|
||||
'Document classification failed after 3 attempts'
|
||||
)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
levenshteinDistance,
|
||||
normalizeMerchantName,
|
||||
calculateMerchantSimilarity,
|
||||
calculateMatchConfidence,
|
||||
} from '../core-receipt-matcher'
|
||||
|
||||
describe('levenshteinDistance', () => {
|
||||
it('returns 0 for identical strings', () => {
|
||||
expect(levenshteinDistance('abc', 'abc')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns length of other string for empty string', () => {
|
||||
expect(levenshteinDistance('', 'abc')).toBe(3)
|
||||
expect(levenshteinDistance('abc', '')).toBe(3)
|
||||
})
|
||||
|
||||
it('calculates correct edit distance', () => {
|
||||
expect(levenshteinDistance('kitten', 'sitting')).toBe(3)
|
||||
expect(levenshteinDistance('saturday', 'sunday')).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMerchantName', () => {
|
||||
it('lowercases and trims', () => {
|
||||
expect(normalizeMerchantName(' ICA MAXI ')).toBe('ica maxi')
|
||||
})
|
||||
|
||||
it('removes Swedish company suffixes', () => {
|
||||
expect(normalizeMerchantName('Telia AB')).toBe('telia')
|
||||
})
|
||||
|
||||
it('removes special characters but keeps Swedish letters', () => {
|
||||
expect(normalizeMerchantName('Café Överkås!')).toBe('café överkås')
|
||||
})
|
||||
|
||||
it('collapses whitespace', () => {
|
||||
expect(normalizeMerchantName('ica maxi stockholm')).toBe('ica maxi stockholm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateMerchantSimilarity', () => {
|
||||
it('returns 1 for exact match', () => {
|
||||
expect(calculateMerchantSimilarity('ICA Maxi', 'ICA Maxi')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 1 for match after normalization', () => {
|
||||
expect(calculateMerchantSimilarity('Telia AB', 'telia')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0.9 when one contains the other', () => {
|
||||
expect(calculateMerchantSimilarity('ICA', 'ICA MAXI STOCKHOLM')).toBe(0.9)
|
||||
})
|
||||
|
||||
it('returns 0 for empty strings', () => {
|
||||
expect(calculateMerchantSimilarity('', 'abc')).toBe(0)
|
||||
expect(calculateMerchantSimilarity('abc', '')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns score between 0 and 1 for partial matches', () => {
|
||||
const score = calculateMerchantSimilarity('ICA Maxi', 'Coop Forum')
|
||||
expect(score).toBeGreaterThanOrEqual(0)
|
||||
expect(score).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('gives high score for word overlap', () => {
|
||||
const score = calculateMerchantSimilarity('ICA Maxi Stockholm', 'ICA Maxi Solna')
|
||||
expect(score).toBeGreaterThan(0.7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateMatchConfidence', () => {
|
||||
it('gives high confidence for exact date + amount + merchant', () => {
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(0, 0, 1.0)
|
||||
expect(confidence).toBeGreaterThan(0.9)
|
||||
expect(matchReasons).toContain('Exakt datum')
|
||||
expect(matchReasons).toContain('Exakt belopp')
|
||||
expect(matchReasons).toContain('Handlare matchar')
|
||||
})
|
||||
|
||||
it('gives lower confidence when date is off', () => {
|
||||
const exact = calculateMatchConfidence(0, 0, 1.0)
|
||||
const dateOff = calculateMatchConfidence(2, 0, 1.0)
|
||||
expect(dateOff.confidence).toBeLessThan(exact.confidence)
|
||||
})
|
||||
|
||||
it('gives lower confidence when amount is off', () => {
|
||||
const exact = calculateMatchConfidence(0, 0, 1.0)
|
||||
const amountOff = calculateMatchConfidence(0, 0.03, 1.0)
|
||||
expect(amountOff.confidence).toBeLessThan(exact.confidence)
|
||||
})
|
||||
|
||||
it('gives lower confidence with no merchant similarity when other signals are imperfect', () => {
|
||||
// With imperfect date/amount, missing merchant signal lowers overall confidence
|
||||
const withMerchant = calculateMatchConfidence(1, 0.02, 0.8)
|
||||
const noMerchant = calculateMatchConfidence(1, 0.02, 0)
|
||||
expect(noMerchant.confidence).toBeLessThan(withMerchant.confidence)
|
||||
})
|
||||
|
||||
it('respects custom tolerances', () => {
|
||||
// With wider tolerance, same variance should give higher score
|
||||
const narrow = calculateMatchConfidence(2, 0.03, 0.5, 3, 0.05)
|
||||
const wide = calculateMatchConfidence(2, 0.03, 0.5, 7, 0.10)
|
||||
expect(wide.confidence).toBeGreaterThan(narrow.confidence)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,352 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { matchDocumentToTransactions } from '../document-matcher'
|
||||
import { makeInvoiceInboxItem, makeTransaction } from '@/tests/helpers'
|
||||
import type { InvoiceExtractionResult, ReceiptExtractionResult, Transaction } from '@/types'
|
||||
|
||||
describe('matchDocumentToTransactions', () => {
|
||||
const mockSupabase = {} as never // Not used when candidateTransactions is provided
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('supplier_invoice matching', () => {
|
||||
const baseExtraction: InvoiceExtractionResult = {
|
||||
supplier: {
|
||||
name: 'Telia AB',
|
||||
orgNumber: '556103-4249',
|
||||
vatNumber: 'SE556103424901',
|
||||
address: 'Stockholm',
|
||||
bankgiro: '5820-5093',
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: 'INV-2024-001',
|
||||
invoiceDate: '2024-06-10',
|
||||
dueDate: '2024-06-20',
|
||||
paymentReference: '73401284756',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.95,
|
||||
}
|
||||
|
||||
it('returns null for government_letter type', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'government_letter',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no extracted_data', async () => {
|
||||
const item = makeInvoiceInboxItem({ extracted_data: null })
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no candidate transactions', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('pass 1: matches by payment reference with 0.98 confidence', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '73401284756',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
expect(result!.transactionId).toBe(tx.id)
|
||||
})
|
||||
|
||||
it('pass 1: matches with whitespace/dash-normalized references', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '734 012 847 56',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('pass 2: matches by exact amount + bankgiro with 0.92 confidence', async () => {
|
||||
const extractionNoRef = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoRef as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'BETALNING 58205093',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.92)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('pass 3: matches by exact amount + date proximity with 0.85 confidence', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-06-22',
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.85)
|
||||
expect(result!.method).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('pass 3: matches with lower confidence at 6–14 days', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-06-28', // 8 days after due date
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.75)
|
||||
expect(result!.method).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('pass 3: does not match if date is >14 days away', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-07-06', // 16 days after due date
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('pass 4: matches by fuzzy amount + supplier name with 0.70 confidence', async () => {
|
||||
const extractionMinimal = {
|
||||
...baseExtraction,
|
||||
invoice: {
|
||||
...baseExtraction.invoice,
|
||||
paymentReference: null,
|
||||
dueDate: null,
|
||||
invoiceDate: null,
|
||||
},
|
||||
supplier: {
|
||||
...baseExtraction.supplier,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
name: 'Telia Sverige',
|
||||
},
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionMinimal as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'telia faktura april',
|
||||
date: '2024-06-15',
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.70)
|
||||
expect(result!.method).toBe('amount_merchant')
|
||||
})
|
||||
|
||||
it('prefers higher confidence matches', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
|
||||
const txWithRef = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '73401284756',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
const txWithAmount = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'BETALNING',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [txWithAmount, txWithRef])
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.transactionId).toBe(txWithRef.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('receipt matching', () => {
|
||||
const receiptExtraction: ReceiptExtractionResult = {
|
||||
merchant: {
|
||||
name: 'ICA Maxi',
|
||||
orgNumber: null,
|
||||
vatNumber: null,
|
||||
isForeign: false,
|
||||
},
|
||||
receipt: {
|
||||
date: '2024-06-15',
|
||||
time: '14:30',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
|
||||
flags: {
|
||||
isRestaurant: false,
|
||||
isSystembolaget: false,
|
||||
isForeignMerchant: false,
|
||||
},
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
it('matches receipt to transaction with high confidence', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
description: 'ICA MAXI STOCKHOLM',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('receipt_match')
|
||||
expect(result!.confidence).toBeGreaterThanOrEqual(0.60)
|
||||
})
|
||||
|
||||
it('returns null when amount is too different', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -500,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when date is too far away', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-25', // 10 days after
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('skips transactions with existing receipt_id', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
receipt_id: 'existing-receipt',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when total is 0 or null', async () => {
|
||||
const zeroExtraction = {
|
||||
...receiptExtraction,
|
||||
totals: { ...receiptExtraction.totals, total: 0 },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: zeroExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({ amount: -299, date: '2024-06-15' })
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Batch Document Matching
|
||||
*
|
||||
* Orchestrates matching multiple inbox items to transactions in a single sweep.
|
||||
* Fetches all unbooked transactions once, then runs per-item matching with
|
||||
* greedy assignment to prevent double-matching.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, Transaction } from '@/types'
|
||||
import { matchDocumentToTransactions, type DocumentMatchResult } from './document-matcher'
|
||||
|
||||
export interface BatchMatchResult {
|
||||
matched: number
|
||||
total: number
|
||||
matches: Array<{ inboxItemId: string; result: DocumentMatchResult }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a matching sweep for all ready unmatched inbox items.
|
||||
*
|
||||
* 1. Fetches all ready/processing inbox items without a matched_transaction_id
|
||||
* 2. Fetches all unbooked expense transactions
|
||||
* 3. Runs matching per item, greedily assigning (highest confidence first)
|
||||
* 4. Persists matches back to inbox items
|
||||
*/
|
||||
export async function runDocumentMatchingSweep(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
inboxItemIds?: string[]
|
||||
): Promise<BatchMatchResult> {
|
||||
// 1. Fetch unmatched inbox items
|
||||
let query = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('matched_transaction_id', null)
|
||||
.in('status', ['ready', 'processing'])
|
||||
|
||||
if (inboxItemIds && inboxItemIds.length > 0) {
|
||||
query = query.in('id', inboxItemIds)
|
||||
}
|
||||
|
||||
const { data: inboxItems, error: itemsError } = await query
|
||||
|
||||
if (itemsError || !inboxItems || inboxItems.length === 0) {
|
||||
console.log(`[batch-match] No unmatched inbox items found`)
|
||||
return { matched: 0, total: 0, matches: [] }
|
||||
}
|
||||
|
||||
console.log(`[batch-match] Starting sweep: ${inboxItems.length} unmatched inbox items`)
|
||||
|
||||
// 2. Fetch all unbooked expense transactions (broad window: last 90 days)
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('is_business', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', ninetyDaysAgo.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError || !transactions || transactions.length === 0) {
|
||||
console.log(`[batch-match] No candidate transactions found (last 90 days)`)
|
||||
return { matched: 0, total: inboxItems.length, matches: [] }
|
||||
}
|
||||
|
||||
console.log(`[batch-match] ${transactions.length} candidate transactions (last 90 days)`)
|
||||
|
||||
// 3. Run matching for each item and collect results
|
||||
const pendingMatches: Array<{
|
||||
inboxItemId: string
|
||||
result: DocumentMatchResult
|
||||
}> = []
|
||||
|
||||
for (const item of inboxItems as InvoiceInboxItem[]) {
|
||||
const result = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
userId,
|
||||
item,
|
||||
transactions as Transaction[]
|
||||
)
|
||||
if (result) {
|
||||
pendingMatches.push({ inboxItemId: item.id, result })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Greedy assignment: sort by confidence desc, assign each transaction at most once
|
||||
pendingMatches.sort((a, b) => b.result.confidence - a.result.confidence)
|
||||
|
||||
const assignedTransactionIds = new Set<string>()
|
||||
const finalMatches: typeof pendingMatches = []
|
||||
|
||||
for (const match of pendingMatches) {
|
||||
if (assignedTransactionIds.has(match.result.transactionId)) {
|
||||
console.log(`[batch-match] Skipped item=${match.inboxItemId} → tx=${match.result.transactionId} (already assigned to higher-confidence match)`)
|
||||
continue // Transaction already assigned to a higher-confidence match
|
||||
}
|
||||
assignedTransactionIds.add(match.result.transactionId)
|
||||
finalMatches.push(match)
|
||||
}
|
||||
|
||||
console.log(`[batch-match] Sweep complete: ${finalMatches.length}/${inboxItems.length} items matched, ${pendingMatches.length - finalMatches.length} skipped (greedy dedup)`)
|
||||
|
||||
// 5. Persist matches
|
||||
for (const { inboxItemId, result } of finalMatches) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: result.transactionId,
|
||||
match_confidence: result.confidence,
|
||||
match_method: result.method,
|
||||
})
|
||||
.eq('id', inboxItemId)
|
||||
.eq('user_id', userId)
|
||||
}
|
||||
|
||||
return {
|
||||
matched: finalMatches.length,
|
||||
total: inboxItems.length,
|
||||
matches: finalMatches,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Document Classifier using Claude Haiku Vision API
|
||||
*
|
||||
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
|
||||
* in server components or API routes.
|
||||
*
|
||||
* Classifies documents as supplier invoices, receipts, government letters,
|
||||
* or unknown. Also detects EU reverse charge for supplier invoices.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { DocumentClassificationType } from '@/types'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 1000
|
||||
|
||||
type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
|
||||
export interface DocumentClassification {
|
||||
type: DocumentClassificationType
|
||||
confidence: number
|
||||
reasoning: string
|
||||
isReverseCharge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a document using Claude Haiku Vision.
|
||||
* Determines if it's a supplier invoice, receipt, government letter, or unknown.
|
||||
*/
|
||||
export async function classifyDocument(
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<DocumentClassification> {
|
||||
const systemPrompt = `Du är expert på att klassificera svenska affärsdokument.
|
||||
Din uppgift är att avgöra vilken typ av dokument som visas.
|
||||
|
||||
DOKUMENTTYPER:
|
||||
- supplier_invoice: Leverantörsfaktura (har fakturanummer, bankgiro/plusgiro, förfallodatum, leverantörsuppgifter)
|
||||
- receipt: Kvitto (butiks-/restaurangkvitto, kort betalningsbevis med artikelrader)
|
||||
- government_letter: Myndighetspost (från Skatteverket, Bolagsverket, Försäkringskassan, kommun, etc.)
|
||||
- unknown: Annat dokument som inte passar ovan
|
||||
|
||||
FÖR LEVERANTÖRSFAKTUROR - kontrollera även:
|
||||
- Är fakturan från en utländsk/EU-leverantör utan svensk moms?
|
||||
- Nämner dokumentet "reverse charge", "omvänd skattskyldighet", eller "artikel 196"?
|
||||
- Har leverantören ett VAT-nummer som INTE börjar med SE?
|
||||
Om ja: flagga isReverseCharge = true`
|
||||
|
||||
const userPrompt = `Klassificera detta dokument. Returnera ENDAST ett JSON-objekt:
|
||||
|
||||
{
|
||||
"type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
|
||||
"confidence": 0.95,
|
||||
"reasoning": "Kort förklaring",
|
||||
"isReverseCharge": false
|
||||
}
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
|
||||
const isPdf = mimeType === 'application/pdf'
|
||||
const isImage = mimeType.startsWith('image/')
|
||||
|
||||
if (!isPdf && !isImage) {
|
||||
throw new Error(`Unsupported file type: ${mimeType}`)
|
||||
}
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
|
||||
? [
|
||||
{
|
||||
type: 'document' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: 'application/pdf' as const,
|
||||
data: base64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: userPrompt },
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: mimeType as ImageMediaType,
|
||||
data: base64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: userPrompt },
|
||||
]
|
||||
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: contentBlocks }],
|
||||
system: systemPrompt,
|
||||
})
|
||||
|
||||
const content = message.content[0]
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from AI')
|
||||
}
|
||||
|
||||
let jsonText = content.text.trim()
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.slice(7)
|
||||
} else if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.slice(3)
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.slice(0, -3)
|
||||
}
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return validateClassification(parsed)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Failed to parse AI response: ${lastError.message}`)
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Document classification failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
|
||||
}
|
||||
|
||||
const VALID_TYPES: DocumentClassificationType[] = [
|
||||
'supplier_invoice',
|
||||
'receipt',
|
||||
'government_letter',
|
||||
'unknown',
|
||||
]
|
||||
|
||||
function validateClassification(raw: unknown): DocumentClassification {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid classification result: not an object')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = raw as any
|
||||
|
||||
const type = VALID_TYPES.includes(data.type) ? data.type : 'unknown'
|
||||
const confidence = typeof data.confidence === 'number' ? data.confidence : 0.5
|
||||
const reasoning = typeof data.reasoning === 'string' ? data.reasoning : ''
|
||||
const isReverseCharge = type === 'supplier_invoice' ? Boolean(data.isReverseCharge) : undefined
|
||||
|
||||
return { type, confidence, reasoning, isReverseCharge }
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Core Receipt Matcher — pure matching utility functions extracted from the
|
||||
* receipt-ocr extension so they can be reused by the document matching engine.
|
||||
*
|
||||
* These are pure functions with no Supabase or extension dependencies.
|
||||
*/
|
||||
|
||||
// Matching configuration (re-exported for consumers)
|
||||
export const DATE_TOLERANCE_DAYS = 3
|
||||
export const AMOUNT_TOLERANCE_PERCENT = 0.05
|
||||
export const MIN_MATCH_CONFIDENCE = 0.4
|
||||
|
||||
/**
|
||||
* Normalize a merchant name for comparison.
|
||||
* Removes special characters, Swedish company suffixes, and extra whitespace.
|
||||
*/
|
||||
export function normalizeMerchantName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
|
||||
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein (edit) distance between two strings.
|
||||
*/
|
||||
export function levenshteinDistance(str1: string, str2: string): number {
|
||||
const m = str1.length
|
||||
const n = str2.length
|
||||
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(null)
|
||||
.map(() => Array(n + 1).fill(0))
|
||||
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1, // deletion
|
||||
dp[i][j - 1] + 1, // insertion
|
||||
dp[i - 1][j - 1] + cost // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate merchant name similarity using Levenshtein distance and word overlap.
|
||||
* Returns a value between 0 (no match) and 1 (exact match).
|
||||
*/
|
||||
export function calculateMerchantSimilarity(name1: string, name2: string): number {
|
||||
if (!name1 || !name2) return 0
|
||||
|
||||
const n1 = normalizeMerchantName(name1)
|
||||
const n2 = normalizeMerchantName(name2)
|
||||
|
||||
// Exact match
|
||||
if (n1 === n2) return 1
|
||||
|
||||
// One contains the other
|
||||
if (n1.includes(n2) || n2.includes(n1)) return 0.9
|
||||
|
||||
// Word overlap
|
||||
const words1 = n1.split(/\s+/)
|
||||
const words2 = n2.split(/\s+/)
|
||||
const commonWords = words1.filter((w) => words2.includes(w))
|
||||
|
||||
if (commonWords.length > 0) {
|
||||
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
|
||||
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
|
||||
}
|
||||
|
||||
// Levenshtein similarity
|
||||
const distance = levenshteinDistance(n1, n2)
|
||||
const maxLength = Math.max(n1.length, n2.length)
|
||||
return 1 - distance / maxLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a weighted match confidence score from date, amount, and merchant signals.
|
||||
* Weights: amount 40%, merchant 35%, date 25%.
|
||||
*
|
||||
* When merchant similarity is 0, the merchant weight is excluded from the
|
||||
* total weight so the confidence is normalized across the active signals only.
|
||||
*/
|
||||
export function calculateMatchConfidence(
|
||||
dateVariance: number,
|
||||
amountVariance: number,
|
||||
merchantSimilarity: number,
|
||||
dateTolerance: number = DATE_TOLERANCE_DAYS,
|
||||
amountTolerance: number = AMOUNT_TOLERANCE_PERCENT
|
||||
): { confidence: number; matchReasons: string[] } {
|
||||
const matchReasons: string[] = []
|
||||
let totalWeight = 0
|
||||
let weightedScore = 0
|
||||
|
||||
// Date score (weight: 25%)
|
||||
const dateScore = Math.max(0, 1 - dateVariance / dateTolerance)
|
||||
if (dateScore >= 0.8) {
|
||||
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
|
||||
}
|
||||
weightedScore += dateScore * 0.25
|
||||
totalWeight += 0.25
|
||||
|
||||
// Amount score (weight: 40%)
|
||||
const amountScore = Math.max(0, 1 - amountVariance / amountTolerance)
|
||||
if (amountVariance < 0.01) {
|
||||
matchReasons.push('Exakt belopp')
|
||||
} else if (amountVariance < amountTolerance) {
|
||||
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
|
||||
}
|
||||
weightedScore += amountScore * 0.4
|
||||
totalWeight += 0.4
|
||||
|
||||
// Merchant score (weight: 35%) — only counted when there's data
|
||||
if (merchantSimilarity > 0) {
|
||||
if (merchantSimilarity >= 0.9) {
|
||||
matchReasons.push('Handlare matchar')
|
||||
} else if (merchantSimilarity >= 0.6) {
|
||||
matchReasons.push('Trolig handlarmatch')
|
||||
}
|
||||
weightedScore += merchantSimilarity * 0.35
|
||||
totalWeight += 0.35
|
||||
}
|
||||
|
||||
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
|
||||
|
||||
return {
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
matchReasons,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Document-to-Transaction Matcher
|
||||
*
|
||||
* Pure matching logic that works from extracted data already stored on inbox items.
|
||||
* Zero AI or extension dependencies — works entirely from structured data.
|
||||
*
|
||||
* Matching passes by document type:
|
||||
*
|
||||
* Supplier invoices:
|
||||
* 1. Payment reference exact match → 0.98
|
||||
* 2. Exact amount + bankgiro → 0.92
|
||||
* 3. Exact amount + date ±5 days → 0.85
|
||||
* 4. Fuzzy amount + supplier name → 0.70
|
||||
*
|
||||
* Receipts:
|
||||
* Weighted scoring (amount 40%, date 25%, merchant 35%), min confidence 0.60
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, Transaction, InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
||||
import {
|
||||
calculateMerchantSimilarity,
|
||||
calculateMatchConfidence,
|
||||
} from './core-receipt-matcher'
|
||||
|
||||
export type DocumentMatchMethod =
|
||||
| 'payment_reference'
|
||||
| 'amount_date'
|
||||
| 'amount_merchant'
|
||||
| 'receipt_match'
|
||||
|
||||
export interface DocumentMatchResult {
|
||||
transactionId: string
|
||||
confidence: number
|
||||
method: DocumentMatchMethod
|
||||
matchReasons: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a single inbox item to the best candidate transaction.
|
||||
*
|
||||
* If `candidateTransactions` is not provided, fetches unbooked expense
|
||||
* transactions within ±7 days of the document date.
|
||||
*/
|
||||
export async function matchDocumentToTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
inboxItem: InvoiceInboxItem,
|
||||
candidateTransactions?: Transaction[]
|
||||
): Promise<DocumentMatchResult | null> {
|
||||
const tag = `[document-matcher] item=${inboxItem.id} type=${inboxItem.document_type}`
|
||||
|
||||
// Only match supplier invoices and receipts
|
||||
if (inboxItem.document_type === 'government_letter' || inboxItem.document_type === 'unknown') {
|
||||
console.log(`${tag} — skipped (unsupported document type)`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!inboxItem.extracted_data) {
|
||||
console.log(`${tag} — skipped (no extracted_data)`)
|
||||
return null
|
||||
}
|
||||
|
||||
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, userId, inboxItem))
|
||||
|
||||
console.log(`${tag} — ${transactions.length} candidate transactions`)
|
||||
|
||||
if (transactions.length === 0) {
|
||||
console.log(`${tag} — no candidates, aborting`)
|
||||
return null
|
||||
}
|
||||
|
||||
let result: DocumentMatchResult | null = null
|
||||
|
||||
if (inboxItem.document_type === 'supplier_invoice') {
|
||||
result = matchSupplierInvoiceDocument(inboxItem, transactions)
|
||||
} else if (inboxItem.document_type === 'receipt') {
|
||||
result = matchReceiptDocument(inboxItem, transactions)
|
||||
}
|
||||
|
||||
if (result) {
|
||||
console.log(`${tag} — MATCHED tx=${result.transactionId} confidence=${result.confidence} method=${result.method} reasons=[${result.matchReasons.join(', ')}]`)
|
||||
} else {
|
||||
console.log(`${tag} — no match found`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch unbooked expense transactions within ±7 days of the document date.
|
||||
*/
|
||||
async function fetchCandidateTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
inboxItem: InvoiceInboxItem
|
||||
): Promise<Transaction[]> {
|
||||
const docDate = getDocumentDate(inboxItem)
|
||||
if (!docDate) return []
|
||||
|
||||
const startDate = new Date(docDate)
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
const endDate = new Date(docDate)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('is_business', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
.lte('date', endDate.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (error || !data) return []
|
||||
return data as Transaction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most relevant date from an inbox item's extracted data.
|
||||
*/
|
||||
function getDocumentDate(inboxItem: InvoiceInboxItem): string | null {
|
||||
const data = inboxItem.extracted_data as Record<string, unknown> | null
|
||||
if (!data) return null
|
||||
|
||||
if (inboxItem.document_type === 'supplier_invoice') {
|
||||
const extraction = data as unknown as InvoiceExtractionResult
|
||||
return extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate ?? null
|
||||
}
|
||||
|
||||
if (inboxItem.document_type === 'receipt') {
|
||||
const extraction = data as unknown as ReceiptExtractionResult
|
||||
return extraction.receipt?.date ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a supplier invoice inbox item to transactions using a 4-pass algorithm.
|
||||
*/
|
||||
function matchSupplierInvoiceDocument(
|
||||
inboxItem: InvoiceInboxItem,
|
||||
transactions: Transaction[]
|
||||
): DocumentMatchResult | null {
|
||||
const tag = `[document-matcher:supplier] item=${inboxItem.id}`
|
||||
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
|
||||
if (!extraction) return null
|
||||
|
||||
const invoiceTotal = extraction.totals?.total
|
||||
if (invoiceTotal == null || invoiceTotal === 0) {
|
||||
console.log(`${tag} — no invoice total in extracted data`)
|
||||
return null
|
||||
}
|
||||
|
||||
const paymentRef = extraction.invoice?.paymentReference
|
||||
const bankgiro = extraction.supplier?.bankgiro
|
||||
const plusgiro = extraction.supplier?.plusgiro
|
||||
const supplierName = extraction.supplier?.name
|
||||
const dueDate = extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate
|
||||
|
||||
console.log(`${tag} — extracted: total=${invoiceTotal}, supplier=${supplierName || '?'}, dueDate=${dueDate || '?'}, paymentRef=${paymentRef || '?'}, bankgiro=${bankgiro || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
||||
|
||||
let bestMatch: DocumentMatchResult | null = null
|
||||
|
||||
for (const tx of transactions) {
|
||||
const txAmount = Math.abs(tx.amount)
|
||||
const txDesc = (tx.description || '').toLowerCase()
|
||||
const txRef = tx.reference || ''
|
||||
|
||||
// Pass 1: Payment reference exact match → 0.98
|
||||
if (paymentRef && txRef) {
|
||||
const normTxRef = txRef.replace(/\D/g, '')
|
||||
const normPayRef = paymentRef.replace(/\D/g, '')
|
||||
if (normTxRef && normPayRef && normTxRef === normPayRef) {
|
||||
console.log(`${tag} — Pass 1 HIT: tx=${tx.id} ref=${normPayRef}`)
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.98,
|
||||
method: 'payment_reference',
|
||||
matchReasons: ['Betalningsreferens matchar'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Exact amount + bankgiro/plusgiro → 0.92
|
||||
const amountMatch = Math.abs(txAmount - invoiceTotal) < 0.005
|
||||
if (amountMatch) {
|
||||
const bgNorm = bankgiro?.replace(/\D/g, '')
|
||||
const pgNorm = plusgiro?.replace(/\D/g, '')
|
||||
const hasBgMatch = bgNorm && txDesc.includes(bgNorm)
|
||||
const hasPgMatch = pgNorm && txDesc.includes(pgNorm)
|
||||
|
||||
if (hasBgMatch || hasPgMatch) {
|
||||
console.log(`${tag} — Pass 2 HIT: tx=${tx.id} amount=${txAmount} bg/pg match`)
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.92,
|
||||
method: 'payment_reference',
|
||||
matchReasons: ['Exakt belopp', hasBgMatch ? 'Bankgiro matchar' : 'Plusgiro matchar'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Exact amount + date ±14 days → 0.85 (close) / 0.75 (wider)
|
||||
// Invoices are often paid early or a few days late, so we use a 14-day window.
|
||||
if (amountMatch && dueDate) {
|
||||
const txDate = new Date(tx.date)
|
||||
const docDate = new Date(dueDate)
|
||||
const diffDays = Math.abs((txDate.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays <= 14) {
|
||||
// Higher confidence for close dates, lower for wider window
|
||||
const confidence = diffDays <= 5 ? 0.85 : 0.75
|
||||
console.log(`${tag} — Pass 3 HIT: tx=${tx.id} amount=${txAmount} date_diff=${diffDays.toFixed(1)}d → confidence=${confidence}`)
|
||||
const candidate: DocumentMatchResult = {
|
||||
transactionId: tx.id,
|
||||
confidence,
|
||||
method: 'amount_date',
|
||||
matchReasons: ['Exakt belopp', diffDays === 0 ? 'Exakt datum' : `Datum ±${Math.round(diffDays)} dagar`],
|
||||
}
|
||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Fuzzy amount (±1%) + supplier name in description → 0.70
|
||||
const fuzzyAmountMatch = Math.abs(txAmount - invoiceTotal) / invoiceTotal <= 0.01
|
||||
if (fuzzyAmountMatch && supplierName) {
|
||||
const normalizedName = supplierName.toLowerCase().replace(/[^\w\såäöé]/g, '')
|
||||
const nameWords = normalizedName.split(/\s+/).filter((w) => w.length >= 3)
|
||||
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
|
||||
|
||||
if (nameInDesc) {
|
||||
console.log(`${tag} — Pass 4 HIT: tx=${tx.id} amount=${txAmount} (~${((Math.abs(txAmount - invoiceTotal) / invoiceTotal) * 100).toFixed(1)}%) name words=[${nameWords.join(',')}]`)
|
||||
const candidate: DocumentMatchResult = {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.70,
|
||||
method: 'amount_merchant',
|
||||
matchReasons: ['Belopp matchar (±1%)', 'Leverantörsnamn i beskrivning'],
|
||||
}
|
||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a receipt inbox item to transactions using weighted scoring.
|
||||
* Weights: amount 40%, date 25%, merchant 35%. Min confidence: 0.60.
|
||||
*/
|
||||
function matchReceiptDocument(
|
||||
inboxItem: InvoiceInboxItem,
|
||||
transactions: Transaction[]
|
||||
): DocumentMatchResult | null {
|
||||
const tag = `[document-matcher:receipt] item=${inboxItem.id}`
|
||||
const extraction = inboxItem.extracted_data as unknown as ReceiptExtractionResult
|
||||
if (!extraction) return null
|
||||
|
||||
const receiptTotal = extraction.totals?.total
|
||||
const receiptDate = extraction.receipt?.date
|
||||
const merchantName = extraction.merchant?.name
|
||||
|
||||
if (receiptTotal == null || receiptTotal === 0) {
|
||||
console.log(`${tag} — no receipt total in extracted data`)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log(`${tag} — extracted: total=${receiptTotal}, date=${receiptDate || '?'}, merchant=${merchantName || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
||||
|
||||
let bestMatch: DocumentMatchResult | null = null
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (tx.receipt_id) continue // Skip already matched
|
||||
|
||||
const txAmount = Math.abs(tx.amount)
|
||||
const txDate = new Date(tx.date)
|
||||
|
||||
// Calculate date variance
|
||||
const dateVariance = receiptDate
|
||||
? Math.abs((new Date(receiptDate).getTime() - txDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 3 // Default to tolerance boundary if no date
|
||||
|
||||
if (dateVariance > 3) continue
|
||||
|
||||
// Calculate amount variance
|
||||
const amountVariance = Math.abs(receiptTotal - txAmount) / receiptTotal
|
||||
if (amountVariance > 0.05) continue // Skip if >5% off
|
||||
|
||||
// Calculate merchant similarity
|
||||
const txMerchant = tx.merchant_name || tx.description || ''
|
||||
const merchantSimilarity = merchantName
|
||||
? calculateMerchantSimilarity(merchantName, txMerchant)
|
||||
: 0
|
||||
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
merchantSimilarity
|
||||
)
|
||||
|
||||
console.log(`${tag} — scoring tx=${tx.id} "${tx.description}": date_var=${dateVariance.toFixed(1)}d amount_var=${(amountVariance * 100).toFixed(1)}% merchant_sim=${merchantSimilarity.toFixed(2)} → confidence=${confidence}`)
|
||||
|
||||
if (confidence >= 0.60 && (!bestMatch || confidence > bestMatch.confidence)) {
|
||||
bestMatch = {
|
||||
transactionId: tx.id,
|
||||
confidence,
|
||||
method: 'receipt_match',
|
||||
matchReasons,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InboxItemStatus, InvoiceExtractionResult } from '@/types'
|
||||
import type { InboxItemStatus, InvoiceExtractionResult, DocumentClassificationType } from '@/types'
|
||||
|
||||
const STATUS_LABELS: Record<InboxItemStatus, string> = {
|
||||
pending: 'Väntar',
|
||||
@@ -51,3 +51,43 @@ export function formatExtractionSummary(
|
||||
lineCount: data.lineItems?.length ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Document type labels (Swedish)
|
||||
const DOCUMENT_TYPE_LABELS: Record<DocumentClassificationType, string> = {
|
||||
supplier_invoice: 'Faktura',
|
||||
receipt: 'Kvitto',
|
||||
government_letter: 'Myndighetspost',
|
||||
unknown: 'Övrigt',
|
||||
}
|
||||
|
||||
export function getDocumentTypeLabel(type: DocumentClassificationType): string {
|
||||
return DOCUMENT_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPE_VARIANTS: Record<DocumentClassificationType, string> = {
|
||||
supplier_invoice: 'default',
|
||||
receipt: 'secondary',
|
||||
government_letter: 'outline',
|
||||
unknown: 'outline',
|
||||
}
|
||||
|
||||
export function getDocumentTypeVariant(type: DocumentClassificationType): string {
|
||||
return DOCUMENT_TYPE_VARIANTS[type] ?? 'outline'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format extraction summary for receipt documents
|
||||
*/
|
||||
export function formatReceiptSummary(
|
||||
data: Record<string, unknown> | null | undefined
|
||||
): { merchantName: string; total: number } {
|
||||
if (!data) {
|
||||
return { merchantName: '', total: 0 }
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any
|
||||
return {
|
||||
merchantName: d.merchant?.name ?? '',
|
||||
total: d.totals?.total ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,15 +72,15 @@ export const SECTORS: Sector[] = [
|
||||
},
|
||||
{
|
||||
slug: 'invoice-inbox',
|
||||
name: 'Leverantörsfaktura-inbox',
|
||||
name: 'Dokumentinkorg',
|
||||
sector: 'general',
|
||||
category: 'import',
|
||||
icon: 'Inbox',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Ta emot leverantörsfakturor via e-post eller uppladdning',
|
||||
description: 'Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost',
|
||||
longDescription:
|
||||
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
|
||||
'Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.',
|
||||
},
|
||||
{
|
||||
slug: 'calendar',
|
||||
|
||||
@@ -13,7 +13,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
||||
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
|
||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/DocumentInboxWorkspace')),
|
||||
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
'general/user-description-match': dynamic(() => import('@/components/extensions/general/UserDescriptionMatchWorkspace')),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { BASAccount } from '@/types'
|
||||
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import type { SIEAccount, SIEAccountMappingRecord } from '../types'
|
||||
import {
|
||||
suggestMappings,
|
||||
@@ -36,6 +37,7 @@ function makeBASAccount(number: string, name: string): BASAccount {
|
||||
default_vat_code: null,
|
||||
description: null,
|
||||
sru_code: null,
|
||||
k2_excluded: false,
|
||||
sort_order: parseInt(number, 10),
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
@@ -73,7 +75,7 @@ describe('suggestMappings', () => {
|
||||
expect(result[0].isOverride).toBe(false)
|
||||
})
|
||||
|
||||
it('returns unmapped entry when no match exists', () => {
|
||||
it('returns unmapped entry for out-of-range accounts', () => {
|
||||
const source = [makeSIEAccount('9999', 'Okänt konto')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
@@ -84,19 +86,40 @@ describe('suggestMappings', () => {
|
||||
expect(result[0].matchType).toBe('manual')
|
||||
})
|
||||
|
||||
it('does not fuzzy match accounts with similar names', () => {
|
||||
// 3400 should NOT match 3001 or 3002 despite being in same class
|
||||
it('self-maps valid BAS-range accounts not in reference via bas_range fallback', () => {
|
||||
// 3400 is a valid BAS-range account (1000-8999) but not in the fixture list
|
||||
const source = [makeSIEAccount('3400', 'Försäljning tjänster')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('3400')
|
||||
expect(result[0].targetName).toBe('Försäljning tjänster')
|
||||
expect(result[0].confidence).toBe(0.9)
|
||||
expect(result[0].matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
it('self-maps sub-accounts not in reference (e.g. 1241 Personbilar)', () => {
|
||||
const source = [makeSIEAccount('1241', 'Personbilar')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('1241')
|
||||
expect(result[0].targetName).toBe('Personbilar')
|
||||
expect(result[0].confidence).toBe(0.9)
|
||||
expect(result[0].matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
it('does not self-map accounts outside BAS range (9000+)', () => {
|
||||
const source = [makeSIEAccount('9100', 'Internt konto')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
})
|
||||
|
||||
it('does not fuzzy match accounts with similar numbers', () => {
|
||||
// 2510 should NOT match 2440 despite being in same class
|
||||
const source = [makeSIEAccount('2510', 'Skatteskulder')]
|
||||
it('does not self-map non-4-digit account numbers', () => {
|
||||
const source = [makeSIEAccount('12345', 'Felaktigt kontonummer')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
@@ -128,21 +151,25 @@ describe('suggestMappings', () => {
|
||||
expect(result[0].matchType).toBe('manual')
|
||||
})
|
||||
|
||||
it('sorts unmapped accounts first (lowest confidence)', () => {
|
||||
it('sorts by confidence (lowest first)', () => {
|
||||
const source = [
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
makeSIEAccount('3400', 'Försäljning tjänster'),
|
||||
makeSIEAccount('1930', 'Företagskonto'),
|
||||
]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toHaveLength(4)
|
||||
// Unmapped (confidence 0) should come first
|
||||
expect(result[0].sourceAccount).toBe('9999')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
// Exact matches (confidence 1.0) come after
|
||||
expect(result[1].confidence).toBe(1.0)
|
||||
// bas_range (confidence 0.9) next
|
||||
expect(result[1].sourceAccount).toBe('3400')
|
||||
expect(result[1].confidence).toBe(0.9)
|
||||
// Exact matches (confidence 1.0) come last
|
||||
expect(result[2].confidence).toBe(1.0)
|
||||
expect(result[3].confidence).toBe(1.0)
|
||||
})
|
||||
|
||||
it('handles multiple accounts with mixed results', () => {
|
||||
@@ -155,10 +182,10 @@ describe('suggestMappings', () => {
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
|
||||
// All 3 should be mapped: 1510 and 5010 exact, 3400 bas_range
|
||||
const mapped = result.filter((m) => m.targetAccount)
|
||||
const unmapped = result.filter((m) => !m.targetAccount)
|
||||
expect(mapped).toHaveLength(2)
|
||||
expect(unmapped).toHaveLength(1)
|
||||
expect(mapped).toHaveLength(3)
|
||||
expect(mapped.find((m) => m.sourceAccount === '3400')?.matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
it('handles empty source accounts', () => {
|
||||
@@ -174,6 +201,48 @@ describe('suggestMappings', () => {
|
||||
expect(result[0].targetAccount).toBe('')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts BASReferenceAccount objects (full BAS reference)', () => {
|
||||
const refAccounts: BASReferenceAccount[] = [
|
||||
{
|
||||
account_number: '1510',
|
||||
account_name: 'Kundfordringar',
|
||||
account_class: 1,
|
||||
account_group: '15',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kundfordringar',
|
||||
sru_code: null,
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '2440',
|
||||
account_name: 'Leverantörsskulder',
|
||||
account_class: 2,
|
||||
account_group: '24',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Leverantörsskulder',
|
||||
sru_code: null,
|
||||
k2_excluded: false,
|
||||
},
|
||||
]
|
||||
|
||||
const source = [
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('2440', 'Leverantörsskulder'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
]
|
||||
|
||||
const result = suggestMappings(source, refAccounts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
const mapped = result.filter((m) => m.targetAccount)
|
||||
const unmapped = result.filter((m) => !m.targetAccount)
|
||||
expect(mapped).toHaveLength(2)
|
||||
expect(unmapped).toHaveLength(1)
|
||||
expect(mapped.find((m) => m.sourceAccount === '1510')?.confidence).toBe(1.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateMappings', () => {
|
||||
@@ -188,7 +257,7 @@ describe('validateMappings', () => {
|
||||
expect(validation.unmappedAccounts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns invalid when accounts are unmapped', () => {
|
||||
it('returns invalid when out-of-range accounts are unmapped', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('9999', 'Okänt konto')],
|
||||
basAccounts
|
||||
@@ -200,6 +269,17 @@ describe('validateMappings', () => {
|
||||
expect(validation.unmappedAccounts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns valid when all accounts mapped via exact + bas_range', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('1241', 'Personbilar')],
|
||||
basAccounts
|
||||
)
|
||||
const validation = validateMappings(mappings)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.unmappedAccounts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('detects low confidence accounts', () => {
|
||||
// With exact-match-only mapper, low confidence only comes from existing overrides
|
||||
const mappings = [
|
||||
@@ -236,18 +316,20 @@ describe('getMappingStats', () => {
|
||||
expect(stats.unmapped).toBe(1)
|
||||
})
|
||||
|
||||
it('counts match types correctly', () => {
|
||||
it('counts match types correctly including bas_range', () => {
|
||||
const mappings = suggestMappings(
|
||||
[
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
makeSIEAccount('1510', 'Kundfordringar'), // exact
|
||||
makeSIEAccount('1241', 'Personbilar'), // bas_range
|
||||
makeSIEAccount('9999', 'Okänt konto'), // manual (unmapped)
|
||||
],
|
||||
basAccounts
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
|
||||
expect(stats.exact).toBe(1)
|
||||
expect(stats.manual).toBe(1) // unmapped gets matchType 'manual'
|
||||
expect(stats.basRange).toBe(1)
|
||||
expect(stats.manual).toBe(1)
|
||||
expect(stats.name).toBe(0)
|
||||
expect(stats.class).toBe(0)
|
||||
})
|
||||
@@ -267,6 +349,20 @@ describe('getMappingStats', () => {
|
||||
expect(stats.averageConfidence).toBe(1.0)
|
||||
})
|
||||
|
||||
it('includes bas_range in average confidence calculation', () => {
|
||||
const mappings = suggestMappings(
|
||||
[
|
||||
makeSIEAccount('1510', 'Kundfordringar'), // exact, confidence 1.0
|
||||
makeSIEAccount('1241', 'Personbilar'), // bas_range, confidence 0.9
|
||||
],
|
||||
basAccounts
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
|
||||
// Average of (1.0 + 0.9) / 2 = 0.95
|
||||
expect(stats.averageConfidence).toBe(0.95)
|
||||
})
|
||||
|
||||
it('returns 0 average confidence when nothing is mapped', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('9999', 'Okänt konto')],
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* Account Mapping Engine
|
||||
*
|
||||
* Maps accounts from an imported SIE file to the user's BAS chart of accounts.
|
||||
* Uses exact account number matching only — no fuzzy/heuristic matching.
|
||||
* Uses exact account number matching against the BAS reference, with a fallback
|
||||
* for valid BAS-range sub-accounts (1000-8999) not in the reference.
|
||||
* This aligns with Swedish industry standard (e.g. Fortnox): exact match,
|
||||
* create new, or let the user map manually.
|
||||
*/
|
||||
|
||||
import type { BASAccount } from '@/types'
|
||||
import type {
|
||||
SIEAccount,
|
||||
AccountMapping,
|
||||
@@ -15,13 +15,35 @@ import type {
|
||||
SIEAccountMappingRecord,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Minimal account shape needed for mapping.
|
||||
* Both BASAccount (from user chart) and BASReferenceAccount (from reference data)
|
||||
* satisfy this interface.
|
||||
*/
|
||||
export type MappableAccount = {
|
||||
account_number: string
|
||||
account_name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account number is in the valid BAS range (1000-8999).
|
||||
* Standard Swedish BAS accounts are 4-digit numbers in classes 1-8.
|
||||
*/
|
||||
function isValidBASRange(accountNumber: string): boolean {
|
||||
if (!/^\d{4}$/.test(accountNumber)) return false
|
||||
const num = parseInt(accountNumber, 10)
|
||||
return num >= 1000 && num <= 8999
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching BAS account for a source account.
|
||||
* Only matches on exact account number — no fuzzy matching.
|
||||
* First tries exact match against the reference, then falls back to
|
||||
* self-mapping for valid BAS-range accounts not in the reference
|
||||
* (common for sub-accounts like 1241 Personbilar under 1240).
|
||||
*/
|
||||
function findBestMatch(
|
||||
source: SIEAccount,
|
||||
basAccounts: BASAccount[],
|
||||
basAccounts: MappableAccount[],
|
||||
existingOverride?: AccountMapping
|
||||
): AccountMapping | null {
|
||||
// If there's a user override, use it
|
||||
@@ -32,7 +54,7 @@ function findBestMatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Exact account number match
|
||||
// Exact account number match against reference
|
||||
const exactMatch = basAccounts.find(
|
||||
(target) => source.number === target.account_number
|
||||
)
|
||||
@@ -49,6 +71,21 @@ function findBestMatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if the account is a valid BAS-range number (1000-8999),
|
||||
// self-map it using the name from the SIE file. These are standard
|
||||
// BAS sub-accounts not in our reference (e.g. 1241 Personbilar).
|
||||
if (isValidBASRange(source.number) && source.name) {
|
||||
return {
|
||||
sourceAccount: source.number,
|
||||
sourceName: source.name,
|
||||
targetAccount: source.number,
|
||||
targetName: source.name,
|
||||
confidence: 0.9,
|
||||
matchType: 'bas_range',
|
||||
isOverride: false,
|
||||
}
|
||||
}
|
||||
|
||||
// No match found
|
||||
return null
|
||||
}
|
||||
@@ -58,7 +95,7 @@ function findBestMatch(
|
||||
*/
|
||||
export function suggestMappings(
|
||||
sourceAccounts: SIEAccount[],
|
||||
basAccounts: BASAccount[],
|
||||
basAccounts: MappableAccount[],
|
||||
existingMappings?: SIEAccountMappingRecord[]
|
||||
): AccountMapping[] {
|
||||
// Convert existing mappings to a lookup map
|
||||
@@ -132,6 +169,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
|
||||
mapped: number
|
||||
unmapped: number
|
||||
exact: number
|
||||
basRange: number
|
||||
name: number
|
||||
class: number
|
||||
manual: number
|
||||
@@ -143,6 +181,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
|
||||
const unmapped = total - mapped
|
||||
|
||||
const exact = mappings.filter((m) => m.matchType === 'exact').length
|
||||
const basRange = mappings.filter((m) => m.matchType === 'bas_range').length
|
||||
const name = mappings.filter((m) => m.matchType === 'name').length
|
||||
const classMatch = mappings.filter((m) => m.matchType === 'class').length
|
||||
const manual = mappings.filter((m) => m.matchType === 'manual').length
|
||||
@@ -159,6 +198,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
|
||||
mapped,
|
||||
unmapped,
|
||||
exact,
|
||||
basRange,
|
||||
name,
|
||||
class: classMatch,
|
||||
manual,
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export type SIEEncoding = 'cp437' | 'utf8'
|
||||
export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed'
|
||||
|
||||
// Match type for account mapping
|
||||
export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual'
|
||||
export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual' | 'bas_range'
|
||||
|
||||
// Parse issue severity
|
||||
export type ParseIssueSeverity = 'error' | 'warning' | 'info'
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { findSupplierInvoiceMatch } from '../supplier-invoice-matching'
|
||||
import { makeTransaction, makeSupplierInvoice, makeSupplier } from '@/tests/helpers'
|
||||
|
||||
describe('findSupplierInvoiceMatch', () => {
|
||||
const supplier = makeSupplier({
|
||||
name: 'Kontorsbolaget AB',
|
||||
bankgiro: '123-4567',
|
||||
plusgiro: '987654-3',
|
||||
})
|
||||
|
||||
it('returns null for empty invoice list', () => {
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
expect(findSupplierInvoiceMatch(tx, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for zero-amount transactions', () => {
|
||||
const tx = makeTransaction({ amount: 0 })
|
||||
const inv = makeSupplierInvoice({ status: 'registered', remaining_amount: 1000 })
|
||||
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
|
||||
})
|
||||
|
||||
it('skips paid invoices (remaining_amount = 0)', () => {
|
||||
const tx = makeTransaction({ amount: -1000, reference: '12345' })
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 0,
|
||||
payment_reference: '12345',
|
||||
})
|
||||
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
|
||||
})
|
||||
|
||||
it('skips invoices with non-matching status', () => {
|
||||
const tx = makeTransaction({ amount: -1000, reference: '12345' })
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'paid',
|
||||
remaining_amount: 1000,
|
||||
payment_reference: '12345',
|
||||
})
|
||||
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
|
||||
})
|
||||
|
||||
// Pass 1: Payment reference
|
||||
it('matches by payment reference with confidence 0.98', () => {
|
||||
const tx = makeTransaction({ amount: -5000, reference: '73100 12345 67890' })
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
payment_reference: '731001234567890',
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.matchMethod).toBe('payment_reference')
|
||||
})
|
||||
|
||||
// Pass 2: Amount + bankgiro
|
||||
it('matches by exact amount + bankgiro in description with confidence 0.92', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -10000,
|
||||
description: 'Betalning BG 1234567 Kontorsbolaget',
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'approved',
|
||||
remaining_amount: 10000,
|
||||
supplier: { ...supplier, bankgiro: '123-4567' },
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.92)
|
||||
expect(result!.matchMethod).toBe('amount_bankgiro')
|
||||
})
|
||||
|
||||
// Pass 3: Amount + date
|
||||
it('matches by exact amount + due date within 5 days with confidence 0.85', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -10000,
|
||||
date: '2024-07-03', // 2 days after due date
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 10000,
|
||||
due_date: '2024-07-01',
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.85)
|
||||
expect(result!.matchMethod).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('does not match when date difference exceeds 5 days', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -10000,
|
||||
date: '2024-07-10', // 9 days after due date
|
||||
description: 'random payment',
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 10000,
|
||||
due_date: '2024-07-01',
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// Pass 4: Fuzzy amount + name
|
||||
it('matches by fuzzy amount + supplier name in description with confidence 0.70', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -10000,
|
||||
description: 'Betalning Kontorsbolaget',
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 10000,
|
||||
due_date: '2024-01-01', // far away date — won't match pass 3
|
||||
supplier: { ...supplier, name: 'Kontorsbolaget AB' },
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.70)
|
||||
expect(result!.matchMethod).toBe('fuzzy_name')
|
||||
})
|
||||
|
||||
it('prefers higher-confidence matches', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -5000,
|
||||
date: '2024-07-02',
|
||||
reference: '999888777',
|
||||
})
|
||||
|
||||
const invoiceRef = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
payment_reference: '999888777',
|
||||
due_date: '2024-07-01',
|
||||
})
|
||||
|
||||
const invoiceDate = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
due_date: '2024-07-01',
|
||||
})
|
||||
|
||||
// Payment reference match should win (0.98 > 0.85)
|
||||
const result = findSupplierInvoiceMatch(tx, [invoiceDate, invoiceRef])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.matchMethod).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('handles öresavrundning (±0.01 fuzzy)', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -999.99,
|
||||
description: 'Betalning Kontorsbolaget faktura',
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 1000,
|
||||
due_date: '2024-01-01',
|
||||
supplier: { ...supplier, name: 'Kontorsbolaget AB' },
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.70)
|
||||
})
|
||||
|
||||
it('ignores short words when matching supplier name', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -5000,
|
||||
description: 'AB payment', // "AB" is only 2 chars, should be ignored
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
due_date: '2024-01-01',
|
||||
supplier: { ...supplier, name: 'AB' },
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
// "AB" is filtered out (length < 3), so no name match
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Supplier Invoice Matching — auto-match expense transactions to unpaid supplier invoices.
|
||||
*
|
||||
* 4-pass matching algorithm (ordered by confidence):
|
||||
* 1. Payment reference/OCR exact match → 0.98
|
||||
* 2. Exact amount + bankgiro/plusgiro match → 0.92
|
||||
* 3. Exact amount + date ±5 days → 0.85
|
||||
* 4. Fuzzy amount (±0.01) + supplier name in description → 0.70
|
||||
*
|
||||
* Auto-match threshold: ≥0.85 → applied automatically
|
||||
* Suggestion threshold: 0.70–0.85 → stored as potential_supplier_invoice_id
|
||||
*/
|
||||
|
||||
import type { Transaction, SupplierInvoice } from '@/types'
|
||||
|
||||
export interface SupplierInvoiceMatch {
|
||||
supplierInvoice: SupplierInvoice
|
||||
confidence: number
|
||||
matchMethod: 'payment_reference' | 'amount_bankgiro' | 'amount_date' | 'fuzzy_name'
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize payment reference for comparison (strip whitespace and non-digits).
|
||||
*/
|
||||
function normalizeReference(ref: string): string {
|
||||
return ref.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching supplier invoice for an expense transaction.
|
||||
* Expects invoices to have the `supplier` relation populated (for name/bankgiro matching).
|
||||
* Only matches against invoices with status 'registered' or 'approved'
|
||||
* and with remaining_amount > 0.
|
||||
*/
|
||||
export function findSupplierInvoiceMatch(
|
||||
transaction: Transaction,
|
||||
unpaidInvoices: SupplierInvoice[]
|
||||
): SupplierInvoiceMatch | null {
|
||||
if (unpaidInvoices.length === 0) return null
|
||||
|
||||
// Only match expense transactions
|
||||
const txAmount = Math.abs(transaction.amount)
|
||||
if (txAmount === 0) return null
|
||||
|
||||
let bestMatch: SupplierInvoiceMatch | null = null
|
||||
|
||||
for (const invoice of unpaidInvoices) {
|
||||
// Only match against registered/approved invoices with remaining amount
|
||||
if (!['registered', 'approved'].includes(invoice.status)) continue
|
||||
const remaining = invoice.remaining_amount ?? invoice.total
|
||||
if (remaining <= 0) continue
|
||||
|
||||
// Pass 1: Payment reference/OCR exact match → 0.98
|
||||
if (transaction.reference && invoice.payment_reference) {
|
||||
const txRef = normalizeReference(transaction.reference)
|
||||
const invRef = normalizeReference(invoice.payment_reference)
|
||||
if (txRef && invRef && txRef === invRef) {
|
||||
return {
|
||||
supplierInvoice: invoice,
|
||||
confidence: 0.98,
|
||||
matchMethod: 'payment_reference',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Exact amount + bankgiro/plusgiro match → 0.92
|
||||
const amountMatch = Math.abs(txAmount - remaining) < 0.005
|
||||
if (amountMatch) {
|
||||
const txDesc = (transaction.description || '').toLowerCase()
|
||||
const supplierBg = invoice.supplier?.bankgiro
|
||||
const supplierPg = invoice.supplier?.plusgiro
|
||||
const bgMatch = supplierBg && txDesc.includes(normalizeReference(supplierBg))
|
||||
const pgMatch = supplierPg && txDesc.includes(normalizeReference(supplierPg))
|
||||
|
||||
if (bgMatch || pgMatch) {
|
||||
return {
|
||||
supplierInvoice: invoice,
|
||||
confidence: 0.92,
|
||||
matchMethod: 'amount_bankgiro',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Exact amount + date ±5 days → 0.85
|
||||
if (amountMatch && invoice.due_date) {
|
||||
const txDate = new Date(transaction.date)
|
||||
const dueDate = new Date(invoice.due_date)
|
||||
const diffDays = Math.abs((txDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays <= 5) {
|
||||
const confidence = 0.85
|
||||
if (!bestMatch || confidence > bestMatch.confidence) {
|
||||
bestMatch = {
|
||||
supplierInvoice: invoice,
|
||||
confidence,
|
||||
matchMethod: 'amount_date',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Fuzzy amount (±0.01) + supplier name in description → 0.70
|
||||
const fuzzyAmountMatch = Math.abs(txAmount - remaining) <= 0.01
|
||||
const supplierName = invoice.supplier?.name
|
||||
if (fuzzyAmountMatch && supplierName) {
|
||||
const txDesc = (transaction.description || '').toLowerCase()
|
||||
const normalizedName = supplierName.toLowerCase()
|
||||
|
||||
// Check if any significant word from the supplier name appears in the description
|
||||
const nameWords = normalizedName
|
||||
.replace(/[^\w\såäöé]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 3)
|
||||
|
||||
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
|
||||
|
||||
if (nameInDesc) {
|
||||
const confidence = 0.70
|
||||
if (!bestMatch || confidence > bestMatch.confidence) {
|
||||
bestMatch = {
|
||||
supplierInvoice: invoice,
|
||||
confidence,
|
||||
matchMethod: 'fuzzy_name',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
@@ -134,6 +134,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query (no booked transactions)
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch (no unpaid invoices)
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check returns null (no existing row)
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert returns the new transaction
|
||||
@@ -158,6 +160,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check returns an existing record
|
||||
enqueue({ data: { id: 'existing-tx-1' }, error: null })
|
||||
|
||||
@@ -177,6 +181,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert fails
|
||||
@@ -203,6 +209,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert returns the new transaction
|
||||
@@ -243,6 +251,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -272,6 +282,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -308,6 +320,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -337,6 +351,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -365,6 +381,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Transaction 1: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted1, error: null })
|
||||
@@ -402,6 +420,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Transaction rawNew: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: insertedNew, error: null })
|
||||
@@ -474,6 +494,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
@@ -498,6 +520,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
@@ -548,6 +572,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -590,6 +616,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -617,6 +645,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -647,6 +677,8 @@ describe('ingestTransactions', () => {
|
||||
data: [{ date: '2024-06-15', amount: -250 }],
|
||||
error: null,
|
||||
})
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// external_id dedup: no match (different source)
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -670,6 +702,8 @@ describe('ingestTransactions', () => {
|
||||
data: [{ date: '2024-06-15', amount: -250 }],
|
||||
error: null,
|
||||
})
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// external_id dedup: no match
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -702,6 +736,8 @@ describe('ingestTransactions', () => {
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
// raw1: external_id dedup (no match) -> content dedup matches (bookedCount=2 -> 1)
|
||||
enqueue({ data: null, error: null })
|
||||
@@ -726,6 +762,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked map query throws (caught by try/catch in buildBookedTransactionMap)
|
||||
enqueue({ error: { message: 'Query failed' } })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// external_id dedup: no match
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { Transaction, RawTransaction, IngestResult } from '@/types'
|
||||
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice } from '@/types'
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type { RawTransaction, IngestResult } from '@/types'
|
||||
@@ -92,6 +93,21 @@ export async function ingestTransactions(
|
||||
// Non-critical — reconciliation will be skipped
|
||||
}
|
||||
|
||||
// Pre-fetch unpaid supplier invoices for expense matching (non-critical)
|
||||
let unpaidSupplierInvoices: SupplierInvoice[] = []
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(*)')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['registered', 'approved'])
|
||||
.gt('remaining_amount', 0)
|
||||
|
||||
if (data) unpaidSupplierInvoices = data as SupplierInvoice[]
|
||||
} catch {
|
||||
// Non-critical — supplier invoice matching will be skipped
|
||||
}
|
||||
|
||||
for (const raw of rawTransactions) {
|
||||
// 1. Check for duplicates via external_id
|
||||
const { data: existing } = await supabase
|
||||
@@ -193,6 +209,36 @@ export async function ingestTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. For expense transactions, try supplier invoice matching
|
||||
if (newTransaction.amount < 0 && unpaidSupplierInvoices.length > 0) {
|
||||
try {
|
||||
const match = findSupplierInvoiceMatch(
|
||||
newTransaction as Transaction,
|
||||
unpaidSupplierInvoices
|
||||
)
|
||||
|
||||
if (match) {
|
||||
if (match.confidence >= 0.85) {
|
||||
// Auto-link at high confidence
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
result.auto_matched_invoices++
|
||||
} else {
|
||||
// Store as suggestion at lower confidence (0.70–0.85)
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — continue processing
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Evaluate mapping rules for auto-categorization
|
||||
try {
|
||||
const mappingResult = await evaluateMappingRules(
|
||||
|
||||
Reference in New Issue
Block a user