Merge remote-tracking branch 'origin/main' into logger

This commit is contained in:
Emil
2026-02-24 10:02:36 +01:00
108 changed files with 10358 additions and 651 deletions
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import { validateBody, validateQuery } from '../validate'
// ============================================================
// Helpers
// ============================================================
function createJsonRequest(body: unknown): Request {
return new Request('http://localhost/api/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
function createMalformedRequest(): Request {
return new Request('http://localhost/api/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'not valid json {{{',
})
}
function createRequestWithQuery(params: Record<string, string>): Request {
const url = new URL('http://localhost/api/test')
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
return new Request(url.toString())
}
const TestSchema = z.object({
name: z.string().min(1),
age: z.number().int().positive(),
email: z.string().email().optional(),
})
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
})
// ============================================================
// validateBody
// ============================================================
describe('validateBody', () => {
it('returns success with parsed data on valid input', async () => {
const request = createJsonRequest({ name: 'Alice', age: 30 })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toEqual({ name: 'Alice', age: 30 })
}
})
it('returns success with optional fields', async () => {
const request = createJsonRequest({ name: 'Bob', age: 25, email: 'bob@test.com' })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.email).toBe('bob@test.com')
}
})
it('returns failure response on invalid body', async () => {
const request = createJsonRequest({ name: '', age: -5 })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
expect(result.response.status).toBe(400)
expect(body.error).toBe('Validation failed')
expect(body.type).toBe('validation_error')
expect(body.errors).toBeInstanceOf(Array)
expect(body.errors.length).toBeGreaterThan(0)
}
})
it('returns field paths in error details', async () => {
const request = createJsonRequest({ name: 'Alice', age: 'not-a-number' })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
const ageError = body.errors.find((e: { field: string }) => e.field === 'age')
expect(ageError).toBeDefined()
expect(ageError.code).toBeDefined()
}
})
it('returns error for malformed JSON', async () => {
const request = createMalformedRequest()
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
expect(result.response.status).toBe(400)
expect(body.error).toBe('Invalid JSON in request body')
expect(body.type).toBe('validation_error')
}
})
it('reports all validation errors, not just the first', async () => {
// Missing name and age
const request = createJsonRequest({})
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
expect(body.errors.length).toBeGreaterThanOrEqual(2)
}
})
it('strips unknown fields (Zod default behavior)', async () => {
const request = createJsonRequest({ name: 'Alice', age: 30, secret: 'hidden' })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(true)
if (result.success) {
expect((result.data as Record<string, unknown>).secret).toBeUndefined()
}
})
it('rejects invalid email format', async () => {
const request = createJsonRequest({ name: 'Alice', age: 30, email: 'not-email' })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
})
})
// ============================================================
// validateQuery
// ============================================================
describe('validateQuery', () => {
it('returns success with parsed query params', () => {
const request = createRequestWithQuery({ page: '3', limit: '25' })
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.page).toBe(3)
expect(result.data.limit).toBe(25)
}
})
it('applies defaults for missing params', () => {
const request = createRequestWithQuery({})
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.page).toBe(1)
expect(result.data.limit).toBe(50)
}
})
it('coerces string values to numbers', () => {
const request = createRequestWithQuery({ page: '10' })
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(true)
if (result.success) {
expect(typeof result.data.page).toBe('number')
}
})
it('returns failure for invalid query params', () => {
const request = createRequestWithQuery({ page: '0', limit: '200' })
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = result.response as unknown as { status: number }
expect(result.response.status).toBe(400)
}
})
it('includes error details in response', () => {
const request = createRequestWithQuery({ limit: 'abc' })
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(false)
if (!result.success) {
// The response is a NextResponse — we verify it's a 400
expect(result.response.status).toBe(400)
}
})
})
// ============================================================
// Integration: validateBody with domain schemas
// ============================================================
describe('validateBody with domain schemas', () => {
// Demonstrates using validateBody with the actual schemas from schemas.ts
// This pattern is what API routes should use
const InvoiceSchema = z.object({
customer_id: z.string().uuid(),
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
items: z.array(z.object({
description: z.string().min(1),
amount: z.number().positive(),
})).min(1),
})
it('validates a well-formed invoice request', async () => {
const request = createJsonRequest({
customer_id: '550e8400-e29b-41d4-a716-446655440000',
invoice_date: '2025-03-15',
items: [{ description: 'Service', amount: 1000 }],
})
const result = await validateBody(request, InvoiceSchema)
expect(result.success).toBe(true)
})
it('catches nested array validation errors', async () => {
const request = createJsonRequest({
customer_id: '550e8400-e29b-41d4-a716-446655440000',
invoice_date: '2025-03-15',
items: [{ description: '', amount: -1 }],
})
const result = await validateBody(request, InvoiceSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
// Should catch both description and amount errors
expect(body.errors.length).toBeGreaterThanOrEqual(2)
const fields = body.errors.map((e: { field: string }) => e.field)
expect(fields.some((f: string) => f.includes('items'))).toBe(true)
}
})
})
+500
View File
@@ -0,0 +1,500 @@
import { z } from 'zod'
// ============================================================
// Shared primitives
// ============================================================
/** UUID v4 string */
const uuid = z.string().uuid()
/** ISO date string (YYYY-MM-DD) */
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
/** BAS account number — always a string of 4 digits */
const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
/** Positive monetary amount (> 0) */
const positiveAmount = z.number().positive()
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
/** Time string (HH:MM or HH:MM:SS) */
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
// ============================================================
// Enum schemas (matching types/index.ts)
// ============================================================
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
export const CustomerTypeSchema = z.enum([
'individual',
'swedish_business',
'eu_business',
'non_eu_business',
])
export const SupplierTypeSchema = z.enum([
'swedish_business',
'eu_business',
'non_eu_business',
])
export const InvoiceStatusSchema = z.enum([
'draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited',
])
export const InvoiceDocumentTypeSchema = z.enum([
'invoice', 'proforma', 'delivery_note',
])
export const SupplierInvoiceStatusSchema = z.enum([
'registered', 'approved', 'paid', 'partially_paid', 'overdue', 'disputed', 'credited',
])
export const VatTreatmentSchema = z.enum([
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
])
export const AccountingMethodSchema = z.enum(['accrual', 'cash'])
export const CurrencySchema = z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
export const TransactionCategorySchema = z.enum([
'income_services',
'income_products',
'income_other',
'expense_equipment',
'expense_software',
'expense_travel',
'expense_office',
'expense_marketing',
'expense_professional_services',
'expense_education',
'expense_bank_fees',
'expense_card_fees',
'expense_currency_exchange',
'expense_other',
'private',
'uncategorized',
])
export const JournalEntrySourceTypeSchema = z.enum([
'manual',
'bank_transaction',
'invoice_created',
'invoice_paid',
'invoice_cash_payment',
'credit_note',
'salary_payment',
'opening_balance',
'year_end',
'storno',
'correction',
'import',
'system',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'supplier_credit_note',
])
export const AccountTypeSchema = z.enum([
'asset', 'equity', 'liability', 'revenue', 'expense',
])
export const NormalBalanceSchema = z.enum(['debit', 'credit'])
export const MappingRuleTypeSchema = z.enum([
'mcc_code', 'merchant_name', 'description_pattern', 'amount_threshold', 'combined',
])
export const RiskLevelSchema = z.enum(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH'])
export const DeadlineTypeSchema = z.enum([
'delivery', 'invoicing', 'report', 'tax', 'other',
])
export const DeadlinePrioritySchema = z.enum(['critical', 'important', 'normal'])
export const TaxDeadlineTypeSchema = z.enum([
'moms_monthly',
'moms_quarterly',
'moms_yearly',
'f_skatt',
'arbetsgivardeklaration',
'inkomstdeklaration_ef',
'inkomstdeklaration_ab',
'arsredovisning',
'periodisk_sammanstallning',
'bokslut',
])
export const DeadlineSourceSchema = z.enum(['system', 'user'])
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
export const DocumentUploadSourceSchema = z.enum([
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
])
// ============================================================
// Invoice schemas
// ============================================================
export const CreateInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
quantity: z.number().positive('Quantity must be positive'),
unit: z.string().min(1, 'Unit is required'),
unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(),
})
export const CreateInvoiceSchema = z.object({
customer_id: uuid,
invoice_date: isoDate,
due_date: isoDate,
currency: CurrencySchema,
document_type: InvoiceDocumentTypeSchema.optional(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
export const CreateCreditNoteSchema = z.object({
credited_invoice_id: uuid,
reason: z.string().optional(),
})
// ============================================================
// Customer schemas
// ============================================================
export const CreateCustomerSchema = z.object({
name: z.string().min(1, 'Customer name is required'),
customer_type: CustomerTypeSchema,
email: z.string().email('Invalid email address').optional(),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
default_payment_terms: z.number().int().positive().optional(),
notes: z.string().optional(),
})
export const UpdateCustomerSchema = CreateCustomerSchema.partial()
// ============================================================
// Supplier schemas
// ============================================================
export const CreateSupplierSchema = z.object({
name: z.string().min(1, 'Supplier name is required'),
supplier_type: SupplierTypeSchema,
email: z.string().email('Invalid email address').optional(),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
bankgiro: z.string().optional(),
plusgiro: z.string().optional(),
bank_account: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
default_expense_account: accountNumber.optional(),
default_payment_terms: z.number().int().positive().optional(),
default_currency: CurrencySchema.nullable().optional(),
notes: z.string().optional(),
})
export const UpdateSupplierSchema = CreateSupplierSchema.partial()
// ============================================================
// Supplier invoice schemas
// ============================================================
export const CreateSupplierInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
amount: z.number().optional(),
account_number: accountNumber,
vat_rate: z.number().min(0).max(100).optional(),
vat_code: z.string().optional(),
quantity: z.number().optional(),
unit: z.string().optional(),
unit_price: z.number().optional(),
})
export const CreateSupplierInvoiceSchema = z.object({
supplier_id: uuid,
supplier_invoice_number: z.string().min(1, 'Supplier invoice number is required'),
invoice_date: isoDate,
due_date: isoDate,
delivery_date: isoDate.optional(),
currency: CurrencySchema.optional(),
exchange_rate: z.number().positive().optional(),
vat_treatment: VatTreatmentSchema.optional(),
reverse_charge: z.boolean().optional(),
payment_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
})
export const MarkSupplierInvoicePaidSchema = z.object({
amount: z.number().positive().optional(),
payment_date: isoDate.optional(),
exchange_rate_difference: z.number().optional(),
notes: z.string().optional(),
})
export const UpdateSupplierInvoiceSchema = z.object({
supplier_invoice_number: z.string().min(1).optional(),
invoice_date: isoDate.optional(),
due_date: isoDate.optional(),
delivery_date: isoDate.optional(),
payment_reference: z.string().optional(),
notes: z.string().optional(),
})
// ============================================================
// Journal entry schemas
// ============================================================
export const CreateJournalEntryLineSchema = z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
currency: z.string().optional(),
amount_in_currency: z.number().optional(),
exchange_rate: z.number().positive().optional(),
tax_code: z.string().optional(),
cost_center: z.string().optional(),
project: z.string().optional(),
})
export const CreateJournalEntrySchema = z.object({
fiscal_period_id: uuid,
entry_date: isoDate,
description: z.string().min(1, 'Description is required'),
source_type: JournalEntrySourceTypeSchema.default('manual'),
source_id: z.string().optional(),
voucher_series: z.string().optional(),
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
export const CorrectJournalEntrySchema = z.object({
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
// ============================================================
// Transaction schemas
// ============================================================
export const CategorizeTransactionSchema = z.object({
is_business: z.boolean(),
category: TransactionCategorySchema.optional(),
template_id: z.string().optional(),
vat_treatment: VatTreatmentSchema.optional(),
account_override: accountNumber.optional(),
})
export const BookTransactionSchema = z.object({
fiscal_period_id: uuid,
entry_date: isoDate,
description: z.string().min(1, 'Description is required'),
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
})
export const MatchInvoiceSchema = z.object({
invoice_id: uuid,
})
export const MatchSupplierInvoiceSchema = z.object({
supplier_invoice_id: uuid,
})
// ============================================================
// Settings schemas
// ============================================================
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
org_number: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
f_skatt: z.boolean().optional(),
vat_registered: z.boolean().optional(),
vat_number: z.string().optional(),
moms_period: MomsPeriodSchema.nullable().optional(),
fiscal_year_start_month: z.number().int().min(1).max(12).optional(),
preliminary_tax_monthly: z.number().nullable().optional(),
bank_name: z.string().optional(),
clearing_number: z.string().optional(),
account_number: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
accounting_method: AccountingMethodSchema.optional(),
invoice_prefix: z.string().nullable().optional(),
next_invoice_number: z.number().int().positive().optional(),
invoice_default_days: z.number().int().positive().optional(),
invoice_default_notes: z.string().nullable().optional(),
email: z.string().email().optional(),
pays_salaries: z.boolean().optional(),
sector_slug: z.string().nullable().optional(),
}).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
if (data.entity_type === 'enskild_firma' && data.fiscal_year_start_month !== undefined) {
return data.fiscal_year_start_month === 1
}
return true
},
{
message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)',
path: ['fiscal_year_start_month'],
}
)
// ============================================================
// Fiscal period schemas
// ============================================================
export const CreateFiscalPeriodSchema = z.object({
name: z.string().min(1, 'Period name is required'),
period_start: isoDate,
period_end: isoDate,
}).refine(
(data) => data.period_start < data.period_end,
{
message: 'Period start must be before period end',
path: ['period_end'],
}
)
// ============================================================
// Mapping rule schemas
// ============================================================
export const CreateMappingRuleSchema = z.object({
rule_name: z.string().min(1, 'Rule name is required'),
rule_type: MappingRuleTypeSchema,
priority: z.number().int().min(0).optional(),
mcc_codes: z.array(z.string()).optional(),
merchant_pattern: z.string().optional(),
description_pattern: z.string().optional(),
amount_min: z.number().optional(),
amount_max: z.number().optional(),
debit_account: accountNumber,
credit_account: accountNumber,
vat_treatment: z.string().optional(),
risk_level: RiskLevelSchema.optional(),
default_private: z.boolean().optional(),
requires_review: z.boolean().optional(),
confidence_score: z.number().min(0).max(1).optional(),
})
export const EvaluateMappingRulesSchema = z.union([
z.object({ transaction_id: uuid }),
z.object({
description: z.string().optional(),
amount: z.number(),
}).passthrough(),
])
// ============================================================
// Deadline schemas
// ============================================================
export const CreateDeadlineSchema = z.object({
title: z.string().min(1, 'Title is required'),
due_date: isoDate,
due_time: timeString.optional(),
deadline_type: DeadlineTypeSchema,
priority: DeadlinePrioritySchema.optional(),
customer_id: uuid.optional(),
notes: z.string().optional(),
tax_deadline_type: TaxDeadlineTypeSchema.optional(),
tax_period: z.string().optional(),
source: DeadlineSourceSchema.optional(),
linked_report_type: z.string().optional(),
linked_report_period: z.record(z.string(), z.unknown()).optional(),
})
// ============================================================
// Account schemas
// ============================================================
export const CreateAccountSchema = z.object({
account_number: accountNumber,
account_name: z.string().min(1, 'Account name is required'),
account_type: AccountTypeSchema,
normal_balance: NormalBalanceSchema,
plan_type: z.enum(['k1', 'full_bas']).optional(),
description: z.string().optional(),
})
export const UpdateAccountSchema = z.object({
account_name: z.string().min(1).optional(),
is_active: z.boolean().optional(),
description: z.string().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
sru_code: z.string().nullable().optional(),
})
// ============================================================
// Bank reconciliation schemas
// ============================================================
export const BankLinkSchema = z.object({
transaction_id: uuid,
journal_entry_id: uuid,
})
export const BankUnlinkSchema = z.object({
transaction_id: uuid,
})
export const RunReconciliationSchema = z.object({
date_from: isoDate.optional(),
date_to: isoDate.optional(),
dry_run: z.boolean().optional(),
})
// ============================================================
// Report query schemas
// ============================================================
export const VatDeclarationQuerySchema = z.object({
periodType: z.enum(['monthly', 'quarterly', 'yearly']),
year: z.coerce.number().int().min(2000).max(2100),
period: z.coerce.number().int().min(1).max(12),
})
export const ReportPeriodQuerySchema = z.object({
fiscal_period_id: uuid.optional(),
year: z.coerce.number().int().min(2000).max(2100).optional(),
month: z.coerce.number().int().min(1).max(12).optional(),
})
// ============================================================
// Pagination schemas
// ============================================================
export const PaginationQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().nonnegative().default(0),
})
+114
View File
@@ -0,0 +1,114 @@
import { z } from 'zod'
import { NextResponse } from 'next/server'
export interface ValidationSuccess<T> {
success: true
data: T
}
export interface ValidationFailure {
success: false
response: NextResponse
}
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
/**
* Validate a request body against a Zod schema.
*
* Returns `{ success: true, data }` on valid input, or
* `{ success: false, response }` with a 400 NextResponse on failure.
*
* Usage in an API route:
* ```ts
* const result = await validateBody(request, CreateInvoiceSchema)
* if (!result.success) return result.response
* const { data } = result
* ```
*/
export async function validateBody<T>(
request: Request,
schema: z.ZodType<T>,
): Promise<ValidationResult<T>> {
let body: unknown
try {
body = await request.json()
} catch {
return {
success: false,
response: NextResponse.json(
{
error: 'Invalid JSON in request body',
type: 'validation_error',
},
{ status: 400 },
),
}
}
const result = schema.safeParse(body)
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}))
return {
success: false,
response: NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors,
},
{ status: 400 },
),
}
}
return { success: true, data: result.data }
}
/**
* Validate query parameters (from URL searchParams) against a Zod schema.
*
* Usage:
* ```ts
* const params = validateQuery(request, VatDeclarationQuerySchema)
* if (!params.success) return params.response
* const { data } = params
* ```
*/
export function validateQuery<T>(
request: Request,
schema: z.ZodType<T>,
): ValidationResult<T> {
const url = new URL(request.url)
const raw = Object.fromEntries(url.searchParams.entries())
const result = schema.safeParse(raw)
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}))
return {
success: false,
response: NextResponse.json(
{
error: 'Invalid query parameters',
type: 'validation_error',
errors,
},
{ status: 400 },
),
}
}
return { success: true, data: result.data }
}
@@ -0,0 +1,416 @@
import { describe, it, expect } from 'vitest'
import { makeTransaction } from '@/tests/helpers'
import {
BOOKING_TEMPLATES,
getTemplateById,
getTemplatesByGroup,
getTemplatesByMcc,
getTemplateGroups,
searchTemplates,
findMatchingTemplates,
buildMappingResultFromTemplate,
type BookingTemplate,
} from '../booking-templates'
// ============================================================
// Template Data Integrity
// ============================================================
describe('BOOKING_TEMPLATES data integrity', () => {
it('has exactly 100 templates', () => {
expect(BOOKING_TEMPLATES).toHaveLength(100)
})
it('all template IDs are unique', () => {
const ids = BOOKING_TEMPLATES.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('all templates have valid required fields', () => {
for (const t of BOOKING_TEMPLATES) {
expect(t.id).toBeTruthy()
expect(t.name_sv).toBeTruthy()
expect(t.name_en).toBeTruthy()
expect(t.group).toBeTruthy()
expect(['expense', 'income', 'transfer']).toContain(t.direction)
expect(['all', 'enskild_firma', 'aktiebolag']).toContain(t.entity_applicability)
expect(t.debit_account).toMatch(/^\d{4}$/)
expect(t.credit_account).toMatch(/^\d{4}$/)
expect(['full', 'non_deductible', 'conditional']).toContain(t.deductibility)
expect(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH']).toContain(t.risk_level)
expect(typeof t.requires_review).toBe('boolean')
expect(t.impact_score).toBeGreaterThanOrEqual(1)
expect(t.impact_score).toBeLessThanOrEqual(10)
expect(t.auto_match_confidence).toBeGreaterThanOrEqual(0.5)
expect(t.auto_match_confidence).toBeLessThanOrEqual(1.0)
expect(typeof t.default_private).toBe('boolean')
expect(t.fallback_category).toBeTruthy()
expect(t.description_sv).toBeTruthy()
expect(Array.isArray(t.mcc_codes)).toBe(true)
expect(Array.isArray(t.keywords)).toBe(true)
expect(t.keywords.length).toBeGreaterThan(0)
}
})
it('all AB-specific override accounts are valid 4-digit strings', () => {
for (const t of BOOKING_TEMPLATES) {
if (t.debit_account_ab) {
expect(t.debit_account_ab).toMatch(/^\d{4}$/)
}
if (t.credit_account_ab) {
expect(t.credit_account_ab).toMatch(/^\d{4}$/)
}
}
})
it('vat_rate is consistent with vat_treatment', () => {
for (const t of BOOKING_TEMPLATES) {
if (t.vat_treatment === 'standard_25') {
expect(t.vat_rate).toBe(0.25)
} else if (t.vat_treatment === 'reduced_12') {
expect(t.vat_rate).toBe(0.12)
} else if (t.vat_treatment === 'reduced_6') {
expect(t.vat_rate).toBe(0.06)
} else if (t.vat_treatment === 'reverse_charge' || t.vat_treatment === 'export' || t.vat_treatment === 'exempt' || t.vat_treatment === null) {
expect(t.vat_rate).toBe(0)
}
}
})
})
// ============================================================
// Lookup Functions
// ============================================================
describe('getTemplateById', () => {
it('returns correct template for known ID', () => {
const t = getTemplateById('it_saas_subscription')
expect(t).toBeDefined()
expect(t!.name_sv).toBe('Programvara / SaaS-prenumeration')
expect(t!.debit_account).toBe('5420')
})
it('returns undefined for unknown ID', () => {
expect(getTemplateById('nonexistent')).toBeUndefined()
})
})
describe('getTemplatesByGroup', () => {
it('returns templates for the premises group', () => {
const templates = getTemplatesByGroup('premises')
expect(templates.length).toBeGreaterThan(0)
for (const t of templates) {
expect(t.group).toBe('premises')
}
})
it('returns empty array for non-existent group', () => {
expect(getTemplatesByGroup('nonexistent' as never)).toEqual([])
})
})
describe('getTemplatesByMcc', () => {
it('returns templates for MCC 5541 (fuel)', () => {
const templates = getTemplatesByMcc(5541)
expect(templates.length).toBeGreaterThan(0)
expect(templates.some((t) => t.id === 'vehicle_fuel')).toBe(true)
})
it('returns empty array for unknown MCC', () => {
expect(getTemplatesByMcc(9999)).toEqual([])
})
})
describe('getTemplateGroups', () => {
it('returns all 18 groups', () => {
const groups = getTemplateGroups()
expect(groups).toHaveLength(18)
for (const g of groups) {
expect(g.group).toBeTruthy()
expect(g.label_sv).toBeTruthy()
expect(g.label_en).toBeTruthy()
expect(Array.isArray(g.templates)).toBe(true)
}
})
it('every template is in exactly one group', () => {
const groups = getTemplateGroups()
const allTemplates = groups.flatMap((g) => g.templates)
expect(allTemplates).toHaveLength(100)
})
})
// ============================================================
// Search
// ============================================================
describe('searchTemplates', () => {
it('finds templates by Swedish name', () => {
const results = searchTemplates('lokalhyra')
expect(results.length).toBeGreaterThan(0)
expect(results.some((t) => t.id === 'premises_rent')).toBe(true)
})
it('finds templates by English name', () => {
const results = searchTemplates('software')
expect(results.length).toBeGreaterThan(0)
expect(results.some((t) => t.id === 'it_saas_subscription')).toBe(true)
})
it('finds templates by keywords', () => {
const results = searchTemplates('spotify')
expect(results.length).toBeGreaterThan(0)
})
it('returns empty for empty query', () => {
expect(searchTemplates('')).toEqual([])
})
it('filters by entity type', () => {
const results = searchTemplates('pension', 'enskild_firma')
// Should include EF-specific and 'all', but not AB-only
for (const t of results) {
expect(t.entity_applicability).not.toBe('aktiebolag')
}
})
it('supports multi-token search', () => {
const results = searchTemplates('digital annons')
expect(results.some((t) => t.id === 'marketing_online_ads')).toBe(true)
})
})
// ============================================================
// findMatchingTemplates
// ============================================================
describe('findMatchingTemplates', () => {
it('matches by MCC code with high confidence', () => {
const tx = makeTransaction({
amount: -500,
mcc_code: 5541,
description: 'Gas station',
merchant_name: 'OKQ8',
})
const matches = findMatchingTemplates(tx)
expect(matches.length).toBeGreaterThan(0)
expect(matches[0].template.id).toBe('vehicle_fuel')
expect(matches[0].confidence).toBeGreaterThan(0.3)
})
it('matches by keywords in description', () => {
const tx = makeTransaction({
amount: -299,
description: 'Google Ads campaign',
merchant_name: 'Google',
})
const matches = findMatchingTemplates(tx)
expect(matches.some((m) => m.template.id === 'marketing_online_ads')).toBe(true)
})
it('returns empty for a transaction with no signals', () => {
const tx = makeTransaction({
amount: -100,
description: 'XYZ123ABC',
mcc_code: null,
merchant_name: null,
})
const matches = findMatchingTemplates(tx)
expect(matches).toEqual([])
})
it('filters by entity type', () => {
const tx = makeTransaction({
amount: -5000,
description: 'Löneutbetalning',
})
const matches = findMatchingTemplates(tx, 'enskild_firma')
// Personnel salary is AB-only, should not appear
for (const m of matches) {
expect(m.template.entity_applicability).not.toBe('aktiebolag')
}
})
it('does not match expense templates for positive amounts', () => {
const tx = makeTransaction({
amount: 1000,
description: 'Bensin okq8',
mcc_code: 5541,
})
const matches = findMatchingTemplates(tx)
// vehicle_fuel is an expense template, should not match positive amount
expect(matches.every((m) => m.template.direction !== 'expense')).toBe(true)
})
it('returns max 5 results', () => {
const tx = makeTransaction({
amount: -100,
description: 'software subscription cloud hosting domain',
mcc_code: 5817,
})
const matches = findMatchingTemplates(tx)
expect(matches.length).toBeLessThanOrEqual(5)
})
it('results are sorted by confidence descending', () => {
const tx = makeTransaction({
amount: -999,
description: 'Google cloud hosting',
mcc_code: 4816,
})
const matches = findMatchingTemplates(tx)
for (let i = 1; i < matches.length; i++) {
expect(matches[i - 1].confidence).toBeGreaterThanOrEqual(matches[i].confidence)
}
})
})
// ============================================================
// buildMappingResultFromTemplate
// ============================================================
describe('buildMappingResultFromTemplate', () => {
const getTemplate = (id: string): BookingTemplate => {
const t = getTemplateById(id)
if (!t) throw new Error(`Template not found: ${id}`)
return t
}
it('produces valid MappingResult for expense with 25% VAT', () => {
const template = getTemplate('it_saas_subscription')
const tx = makeTransaction({ amount: -1250 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('5420')
expect(result.credit_account).toBe('1930')
expect(result.template_id).toBe('it_saas_subscription')
expect(result.rule).toBeNull()
expect(result.confidence).toBe(1.0)
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(250) // 1250 * 0.25 / 1.25 = 250
})
it('produces valid MappingResult for expense with 12% VAT', () => {
const template = getTemplate('travel_hotel')
const tx = makeTransaction({ amount: -1120 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('5820')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(120) // 1120 * 0.12 / 1.12 = 120
})
it('produces valid MappingResult for expense with 6% VAT', () => {
const template = getTemplate('travel_train')
const tx = makeTransaction({ amount: -530 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(30) // 530 * 0.06 / 1.06 = 30
})
it('produces reverse charge lines for EU purchases', () => {
const template = getTemplate('it_saas_eu')
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(2)
// Fiktiv ingående moms
expect(result.vat_lines[0].account_number).toBe('2645')
expect(result.vat_lines[0].debit_amount).toBe(250)
// Fiktiv utgående moms
expect(result.vat_lines[1].account_number).toBe('2614')
expect(result.vat_lines[1].credit_amount).toBe(250)
})
it('produces no VAT lines for exempt expenses', () => {
const template = getTemplate('premises_rent')
const tx = makeTransaction({ amount: -10000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(0)
})
it('produces no VAT lines for non-deductible templates', () => {
const template = getTemplate('private_withdrawal_ef')
const tx = makeTransaction({ amount: -5000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(0)
expect(result.default_private).toBe(true)
})
it('produces output VAT lines for income with 25% VAT', () => {
const template = getTemplate('revenue_services_25')
const tx = makeTransaction({ amount: 12500 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('1930')
expect(result.credit_account).toBe('3001')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2611')
expect(result.vat_lines[0].credit_amount).toBe(2500) // 12500 * 0.25 / 1.25 = 2500
})
it('produces output VAT lines for income with 12% VAT', () => {
const template = getTemplate('revenue_products_12')
const tx = makeTransaction({ amount: 1120 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2621')
expect(result.vat_lines[0].credit_amount).toBe(120)
})
it('produces output VAT lines for income with 6% VAT', () => {
const template = getTemplate('revenue_products_6')
const tx = makeTransaction({ amount: 1060 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2631')
expect(result.vat_lines[0].credit_amount).toBe(60)
})
it('resolves AB-specific accounts for aktiebolag', () => {
const template = getTemplate('education_course')
const tx = makeTransaction({ amount: -5000 })
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(efResult.debit_account).toBe('6991')
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
expect(abResult.debit_account).toBe('7610')
})
it('resolves AB-specific private account', () => {
const template = getTemplate('private_expense')
const tx = makeTransaction({ amount: -300 })
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(efResult.debit_account).toBe('2013')
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
expect(abResult.debit_account).toBe('2893')
})
it('includes template_id in the MappingResult', () => {
const template = getTemplate('bank_fees')
const tx = makeTransaction({ amount: -49 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.template_id).toBe('bank_fees')
expect(result.rule).toBeNull()
})
it('sets description with template name and transaction description', () => {
const template = getTemplate('vehicle_fuel')
const tx = makeTransaction({ amount: -800, description: 'OKQ8 tankstation' })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.description).toBe('Drivmedel: OKQ8 tankstation')
})
})
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -55,7 +55,7 @@ function generatePerRateLines(
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
for (const item of items) {
const rate = item.vat_rate ?? 25
const rate = item.vat_rate ?? 0
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
group.vatAmount += item.vat_amount || 0
+33 -1
View File
@@ -5,11 +5,13 @@ import {
extractNetAmount,
extractVatAmount,
} from './vat-entries'
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
import type {
MappingRule,
MappingResult,
RiskLevel,
Transaction,
EntityType,
VatJournalLine,
} from '@/types'
@@ -28,7 +30,8 @@ const CAPITALIZATION_THRESHOLD = 29400
*/
export async function evaluateMappingRules(
userId: string,
transaction: Transaction
transaction: Transaction,
entityType?: EntityType
): Promise<MappingResult> {
const supabase = await createClient()
@@ -41,6 +44,9 @@ export async function evaluateMappingRules(
.order('priority', { ascending: true })
if (error || !rules || rules.length === 0) {
// Try template-based matching before default fallback
const templateResult = evaluateTemplateRules(transaction, entityType)
if (templateResult) return templateResult
return getDefaultResult(transaction)
}
@@ -51,9 +57,35 @@ export async function evaluateMappingRules(
}
}
// Try template-based matching before default fallback
const templateResult = evaluateTemplateRules(transaction, entityType)
if (templateResult) return templateResult
return getDefaultResult(transaction)
}
/**
* Evaluate booking templates as a fallback when no DB mapping rule matches.
* Returns the best template match if confidence >= 0.3, otherwise null.
*/
function evaluateTemplateRules(
transaction: Transaction,
entityType?: EntityType
): MappingResult | null {
const matches = findMatchingTemplates(transaction, entityType)
if (matches.length === 0 || matches[0].confidence < 0.3) return null
const best = matches[0]
const result = buildMappingResultFromTemplate(
best.template,
transaction,
entityType || 'enskild_firma'
)
// Override the confidence with the auto-match confidence (not 1.0)
result.confidence = best.confidence
return result
}
/**
* Check if a transaction matches a mapping rule
*/
+3 -2
View File
@@ -5,9 +5,10 @@ const log = createLogger('email')
// Default sender configuration
// Using a fixed From address with dynamic Reply-To
// From: "Företagsnamn via ERP Base" <fakturor@erp-base.se>
// From: "Företagsnamn via ERP Base" <your-from@example.com>
// Reply-To: user's company email from company_settings
const DEFAULT_FROM_EMAIL = 'fakturor@arcim.io'
// Set RESEND_FROM_EMAIL in .env.local to your verified Resend domain
const DEFAULT_FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'noreply@localhost'
// Lazy initialization to avoid errors at build time
let resendClient: Resend | null = null
+200
View File
@@ -0,0 +1,200 @@
/**
* Maps raw errors to user-friendly Swedish messages.
*
* Priority chain:
* 1. Zod validation field errors
* 2. Postgres error code map
* 3. HTTP status code map
* 4. Context-specific fallback
* 5. Generic fallback
*/
type ErrorContext =
| 'invoice'
| 'supplier_invoice'
| 'customer'
| 'supplier'
| 'transaction'
| 'journal_entry'
| 'settings'
| 'auth'
interface GetErrorMessageOptions {
context?: ErrorContext
statusCode?: number
}
// Postgres error codes -> Swedish messages
const POSTGRES_ERROR_MAP: Record<string, string> = {
'23505': 'En post med samma uppgifter finns redan.',
'23503': 'Posten kan inte ändras eftersom den refereras av annan data.',
'23502': 'Ett obligatoriskt fält saknas.',
'42501': 'Du har inte behörighet att utföra denna åtgärd.',
'42P01': 'Resursen kunde inte hittas.',
'23514': 'Värdet uppfyller inte de tillåtna kraven.',
'40001': 'En annan ändring pågick samtidigt. Försök igen.',
'40P01': 'En konflikt uppstod. Försök igen.',
'22P02': 'Ogiltigt värde angavs.',
'22003': 'Värdet är utanför tillåtet intervall.',
}
// HTTP status codes -> Swedish messages
const HTTP_STATUS_MAP: Record<number, string> = {
400: 'Förfrågan innehåller ogiltiga uppgifter.',
401: 'Din session har gått ut. Logga in igen.',
403: 'Du har inte behörighet att utföra denna åtgärd.',
404: 'Resursen kunde inte hittas.',
409: 'En konflikt uppstod. Ladda om sidan och försök igen.',
422: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.',
429: 'För många förfrågningar. Vänta en stund och försök igen.',
500: 'Ett oväntat serverfel uppstod. Försök igen senare.',
502: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
503: 'Tjänsten är tillfälligt otillgänglig. Försök igen om en stund.',
}
// Context-specific fallbacks
const CONTEXT_FALLBACKS: Record<ErrorContext, string> = {
invoice: 'Kunde inte hantera fakturan. Försök igen.',
supplier_invoice: 'Kunde inte hantera leverantörsfakturan. Försök igen.',
customer: 'Kunde inte hantera kunden. Försök igen.',
supplier: 'Kunde inte hantera leverantören. Försök igen.',
transaction: 'Kunde inte hantera transaktionen. Försök igen.',
journal_entry: 'Kunde inte hantera verifikationen. Försök igen.',
settings: 'Kunde inte spara inställningarna. Försök igen.',
auth: 'Ett fel uppstod vid inloggningen. Försök igen.',
}
const GENERIC_FALLBACK = 'Något gick fel. Försök igen.'
/**
* Simple heuristic to detect already-translated Swedish messages.
* If the message contains common Swedish words/patterns, pass it through.
*/
function isSwedishUserMessage(message: string): boolean {
const swedishPatterns = [
/kunde inte/i,
/försök igen/i,
/ogiltigt?/i,
/saknas/i,
/måste/i,
/redan finns/i,
/gick fel/i,
/behörighet/i,
/session/i,
/förfrågan/i,
/obligatorisk/i,
]
return swedishPatterns.some((p) => p.test(message))
}
/**
* Extract a user-friendly message from a Zod validation error shape.
* Returns null if the error is not a Zod error.
*/
function tryParseZodErrors(error: unknown): string | null {
if (typeof error !== 'object' || error === null) return null
const obj = error as Record<string, unknown>
// Check for Zod-style field errors: { fieldName: ["message"] } or { issues: [...] }
if (Array.isArray(obj.issues)) {
const issues = obj.issues as Array<{ message?: string; path?: string[] }>
const messages = issues
.slice(0, 3)
.map((issue) => {
const field = issue.path?.join('.') || ''
const msg = issue.message || 'ogiltigt värde'
return field ? `${field}: ${msg}` : msg
})
if (messages.length > 0) return messages.join('. ')
}
// Check for { errors: { field: ["msg"] } } shape from validateBody
if (typeof obj.errors === 'object' && obj.errors !== null) {
const fieldErrors = obj.errors as Record<string, string[]>
const messages: string[] = []
for (const [field, msgs] of Object.entries(fieldErrors)) {
if (Array.isArray(msgs) && msgs.length > 0) {
messages.push(`${field}: ${msgs[0]}`)
}
if (messages.length >= 3) break
}
if (messages.length > 0) return messages.join('. ')
}
return null
}
/**
* Get a user-friendly Swedish error message from a raw error.
*
* @param error - The raw error. Can be an API response body (object), Error instance, string, or unknown.
* @param options - Optional context and HTTP status code.
*/
export function getErrorMessage(
error: unknown,
options: GetErrorMessageOptions = {}
): string {
const { context, statusCode } = options
// 1. If it's a string, check if it's already Swedish
if (typeof error === 'string' && error.trim()) {
if (isSwedishUserMessage(error)) return error
}
// 2. If it's an object, try various parsing strategies
if (typeof error === 'object' && error !== null) {
const obj = error as Record<string, unknown>
// Try Zod validation errors
const zodMessage = tryParseZodErrors(obj)
if (zodMessage) return zodMessage
// Try Postgres error code
if (typeof obj.code === 'string' && POSTGRES_ERROR_MAP[obj.code]) {
return POSTGRES_ERROR_MAP[obj.code]
}
// Try error.message if it's already a good Swedish message
if (typeof obj.error === 'string' && obj.error.trim()) {
if (isSwedishUserMessage(obj.error)) return obj.error
}
if (typeof obj.message === 'string' && obj.message.trim()) {
if (isSwedishUserMessage(obj.message)) return obj.message
}
}
// 3. Error instance
if (error instanceof Error && error.message.trim()) {
if (isSwedishUserMessage(error.message)) return error.message
}
// 4. HTTP status code map
if (statusCode && HTTP_STATUS_MAP[statusCode]) {
return HTTP_STATUS_MAP[statusCode]
}
// 5. Context-specific fallback
if (context && CONTEXT_FALLBACKS[context]) {
return CONTEXT_FALLBACKS[context]
}
// 6. Generic fallback
return GENERIC_FALLBACK
}
/**
* Helper that parses a Response body and returns a user-friendly error message.
*/
export async function getResponseErrorMessage(
response: Response,
context?: ErrorContext
): Promise<string> {
try {
const body = await response.json()
return getErrorMessage(body, { context, statusCode: response.status })
} catch {
return getErrorMessage(null, { context, statusCode: response.status })
}
}
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import {
getStatusLabel,
getStatusVariant,
getConfidenceLabel,
formatExtractionSummary,
} from '../invoice-inbox-utils'
import type { InboxItemStatus } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
describe('getStatusLabel', () => {
const cases: [InboxItemStatus, string][] = [
['pending', 'Väntar'],
['processing', 'Bearbetar'],
['ready', 'Klar'],
['confirmed', 'Bekräftad'],
['rejected', 'Avvisad'],
['error', 'Fel'],
]
it.each(cases)('returns "%s" → "%s"', (status, expected) => {
expect(getStatusLabel(status)).toBe(expected)
})
})
describe('getStatusVariant', () => {
const cases: [InboxItemStatus, string][] = [
['pending', 'secondary'],
['processing', 'default'],
['ready', 'warning'],
['confirmed', 'success'],
['rejected', 'destructive'],
['error', 'destructive'],
]
it.each(cases)('returns "%s" → "%s"', (status, expected) => {
expect(getStatusVariant(status)).toBe(expected)
})
})
describe('getConfidenceLabel', () => {
it('returns unknown for null', () => {
expect(getConfidenceLabel(null)).toEqual({ label: 'Okänd', variant: 'outline' })
})
it('returns high for >= 0.9', () => {
expect(getConfidenceLabel(0.95)).toEqual({ label: 'Hög', variant: 'success' })
expect(getConfidenceLabel(0.9)).toEqual({ label: 'Hög', variant: 'success' })
})
it('returns medium for 0.7-0.89', () => {
expect(getConfidenceLabel(0.85)).toEqual({ label: 'Medium', variant: 'warning' })
expect(getConfidenceLabel(0.7)).toEqual({ label: 'Medium', variant: 'warning' })
})
it('returns low for < 0.7', () => {
expect(getConfidenceLabel(0.5)).toEqual({ label: 'Låg', variant: 'destructive' })
expect(getConfidenceLabel(0.0)).toEqual({ label: 'Låg', variant: 'destructive' })
})
})
describe('formatExtractionSummary', () => {
it('handles null data', () => {
expect(formatExtractionSummary(null)).toEqual({
supplierName: '',
total: 0,
lineCount: 0,
})
})
it('handles undefined data', () => {
expect(formatExtractionSummary(undefined)).toEqual({
supplierName: '',
total: 0,
lineCount: 0,
})
})
it('extracts summary from complete data', () => {
const data: InvoiceExtractionResult = {
supplier: {
name: 'Acme AB',
orgNumber: '556123-4567',
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
},
invoice: {
invoiceNumber: 'INV-001',
invoiceDate: '2025-01-15',
dueDate: '2025-02-15',
paymentReference: null,
currency: 'SEK',
},
lineItems: [
{ description: 'Item 1', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: null },
{ description: 'Item 2', quantity: 2, unitPrice: 50, lineTotal: 100, vatRate: 25, accountSuggestion: null },
],
totals: { subtotal: 200, vatAmount: 50, total: 250 },
vatBreakdown: [{ rate: 25, base: 200, amount: 50 }],
confidence: 0.92,
}
expect(formatExtractionSummary(data)).toEqual({
supplierName: 'Acme AB',
total: 250,
lineCount: 2,
})
})
it('handles null supplier name', () => {
const data: InvoiceExtractionResult = {
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: null, vatAmount: null, total: null },
vatBreakdown: [],
confidence: 0.5,
}
expect(formatExtractionSummary(data)).toEqual({
supplierName: '',
total: 0,
lineCount: 0,
})
})
})
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
describe('type decoupling', () => {
it('types/index.ts must not import from @/extensions/', () => {
const typesPath = resolve(__dirname, '../../../types/index.ts')
const content = readFileSync(typesPath, 'utf-8')
const extensionImports = content
.split('\n')
.filter((line) => /from\s+['"]@\/extensions\//.test(line))
expect(extensionImports).toEqual([])
})
})
+54
View File
@@ -0,0 +1,54 @@
import type { InboxItemStatus } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
const STATUS_LABELS: Record<InboxItemStatus, string> = {
pending: 'Väntar',
processing: 'Bearbetar',
ready: 'Klar',
confirmed: 'Bekräftad',
rejected: 'Avvisad',
error: 'Fel',
}
export function getStatusLabel(status: InboxItemStatus): string {
return STATUS_LABELS[status] ?? status
}
const STATUS_VARIANTS: Record<InboxItemStatus, string> = {
pending: 'secondary',
processing: 'default',
ready: 'warning',
confirmed: 'success',
rejected: 'destructive',
error: 'destructive',
}
export function getStatusVariant(status: InboxItemStatus): string {
return STATUS_VARIANTS[status] ?? 'outline'
}
export function getConfidenceLabel(confidence: number | null): { label: string; variant: string } {
if (confidence == null) {
return { label: 'Okänd', variant: 'outline' }
}
if (confidence >= 0.9) {
return { label: 'Hög', variant: 'success' }
}
if (confidence >= 0.7) {
return { label: 'Medium', variant: 'warning' }
}
return { label: 'Låg', variant: 'destructive' }
}
export function formatExtractionSummary(
data: InvoiceExtractionResult | null | undefined
): { supplierName: string; total: number; lineCount: number } {
if (!data) {
return { supplierName: '', total: 0, lineCount: 0 }
}
return {
supplierName: data.supplier?.name ?? '',
total: data.totals?.total ?? 0,
lineCount: data.lineItems?.length ?? 0,
}
}
+1
View File
@@ -13,6 +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/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
// Restaurant
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
+22
View File
@@ -0,0 +1,22 @@
'use client'
import { useEffect } from 'react'
/**
* Attaches a `beforeunload` event listener when the form has unsaved changes.
* This guards against browser close, tab close, and page refresh.
*
* Does NOT guard in-app Next.js navigation (App Router has no supported mechanism).
*/
export function useUnsavedChanges(isDirty: boolean) {
useEffect(() => {
if (!isDirty) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [isDirty])
}
+296 -1
View File
@@ -116,6 +116,51 @@ const CAMT053_XML_WITH_STRUCTURED_REF = `<?xml version="1.0" encoding="UTF-8"?>
</BkToCstmrStmt>
</Document>`
const LANSFORSAKRINGAR_CSV = [
'"Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"',
'"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"',
'"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"',
'"2024-01-13";"2024-01-13";"Insättning";"LÖNEUTBETALNING";"25 000,00";"12 877,17"',
].join('\n')
const LANSFORSAKRINGAR_CSV_NO_HEADER = [
'"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"',
'"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"',
].join('\n')
const ICA_BANKEN_CSV = [
'Kontonamn: Lönekonto',
'Kontonummer: 1234 567 890',
'Saldo: 12 877,17',
'Tillgängligt belopp: 12 877,17',
'Period: 2024-01-01 - 2024-01-31',
'Exporterad: 2024-02-01',
'Datum;Text;Belopp;Saldo',
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;ICA MAXI LINDHAGEN;-432,50;12444,67',
'2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const SKANDIA_CSV = [
'Datum;Beskrivning;Belopp;Saldo',
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67',
'2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const SKANDIA_CSV_WITH_BANKKATEGORI = [
'Datum;Beskrivning;Belopp;Saldo;Bankkategori',
'2024-01-15;SPOTIFY AB;-99,00;12345,67;Underhållning',
'2024-01-14;ICA MAXI;-432,50;12444,67;Livsmedel',
].join('\n')
const LUNAR_CSV = [
'Date,Text,Amount,Balance',
'2024-01-15,SPOTIFY AB,"-99,00","12.345,67"',
'2024-01-14,ICA MAXI LINDHAGEN,"-432,50","12.444,67"',
'2024-01-13,LÖNEUTBETALNING,"25.000,00","12.877,17"',
].join('\n')
const UNKNOWN_CSV = [
'id,name,value,timestamp',
'1,Widget A,100,2024-01-15T10:00:00',
@@ -185,6 +230,42 @@ describe('detectFileFormat', () => {
}
})
it('detects Länsförsäkringar CSV from header with "typ" keyword', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV, 'lansforsakringar.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('detects Länsförsäkringar CSV from two adjacent date fields (no header)', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV_NO_HEADER, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('detects ICA Banken CSV from metadata rows before header', () => {
const format = detectFileFormat(ICA_BANKEN_CSV, 'ica.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('ica_banken')
})
it('detects Skandia CSV from "beskrivning" header keyword', () => {
const format = detectFileFormat(SKANDIA_CSV, 'skandia.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('skandia')
})
it('detects Skandia CSV from "bankkategori" header keyword', () => {
const format = detectFileFormat(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('skandia')
})
it('detects Lunar CSV from English headers (date, text, amount, balance)', () => {
const format = detectFileFormat(LUNAR_CSV, 'lunar.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lunar')
})
it('returns null for unrecognized CSV content', () => {
const format = detectFileFormat(UNKNOWN_CSV, 'data.csv')
expect(format).toBeNull()
@@ -426,6 +507,216 @@ describe('parseBankFile — Handelsbanken format', () => {
})
})
describe('parseBankFile — Länsförsäkringar format', () => {
it('parses semicolon-delimited CSV with quoted fields and comma decimal separator', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.format).toBe('lansforsakringar')
expect(result.format_name).toBe('Länsförsäkringar')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts with comma decimal and space thousands', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('handles files without a header row (data-only)', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV_NO_HEADER, 'lf.csv')
expect(result.format).toBe('lansforsakringar')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-432.5)
})
it('calculates stats correctly', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — ICA Banken format', () => {
it('parses semicolon-delimited CSV with metadata rows before header', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.format).toBe('ica_banken')
expect(result.format_name).toBe('ICA Banken')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('skips metadata rows and finds the correct header', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
// No transaction should contain metadata text
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain(expect.stringContaining('Kontonamn'))
expect(descriptions).not.toContain(expect.stringContaining('Exporterad'))
})
it('correctly parses amounts with comma decimal separator', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('calculates stats correctly', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — Skandia format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.format).toBe('skandia')
expect(result.format_name).toBe('Skandia')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts and descriptions', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[1].description).toBe('HEMKÖP FRIDHEMSPLAN')
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('handles files with bankkategori column', () => {
const result = parseBankFile(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv')
expect(result.format).toBe('skandia')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-432.5)
})
it('calculates stats correctly', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — Lunar format', () => {
it('parses comma-delimited CSV with English headers', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.format).toBe('lunar')
expect(result.format_name).toBe('Lunar')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts with comma decimal and period thousand separator', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field with period thousand separator', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.transactions[0].balance).toBe(12345.67)
expect(result.transactions[2].balance).toBe(12877.17)
})
it('calculates stats correctly', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
it('does not confuse Lunar (English) with Nordea (Swedish) headers', () => {
// Nordea has Swedish headers, Lunar has English
const nordeaResult = detectFileFormat(NORDEA_CSV, 'test.csv')
const lunarResult = detectFileFormat(LUNAR_CSV, 'test.csv')
expect(nordeaResult!.id).toBe('nordea')
expect(lunarResult!.id).toBe('lunar')
})
})
describe('parseBankFile — camt.053 XML format', () => {
it('parses XML with credit and debit entries', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
@@ -850,13 +1141,17 @@ describe('getFormat and getAllFormats', () => {
it('getAllFormats returns all registered formats', () => {
const formats = getAllFormats()
expect(formats.length).toBeGreaterThanOrEqual(6)
expect(formats.length).toBeGreaterThanOrEqual(10)
const ids = formats.map((f) => f.id)
expect(ids).toContain('nordea')
expect(ids).toContain('seb')
expect(ids).toContain('swedbank')
expect(ids).toContain('handelsbanken')
expect(ids).toContain('lansforsakringar')
expect(ids).toContain('ica_banken')
expect(ids).toContain('skandia')
expect(ids).toContain('lunar')
expect(ids).toContain('camt053')
expect(ids).toContain('generic_csv')
})
+187
View File
@@ -0,0 +1,187 @@
/**
* ICA Banken CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Datum, Text, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - ~6 metadata rows before the actual data (account info, period, etc.)
* - Header row contains "datum" and "belopp"
* - Must skip metadata lines to find the real header
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
/**
* Check if a line looks like the ICA Banken data header.
* ICA Banken header: semicolon-delimited with "datum", "text", "belopp", "saldo"
*/
function isICAHeader(line: string): boolean {
const lower = line.toLowerCase().replace(/"/g, '')
if (!lower.includes(';')) return false
const fields = lower.split(';').map((f) => f.trim())
return (
fields.some((f) => f === 'datum') &&
fields.some((f) => f === 'belopp') &&
fields.some((f) => f === 'text')
)
}
/**
* Detect ICA Banken format: semicolon-delimited file with metadata lines
* before a header containing "datum" and "belopp".
*/
function detectICABanken(lines: string[]): boolean {
// Look for a header row within the first ~10 lines (skipping metadata)
let metadataCount = 0
for (let i = 0; i < Math.min(lines.length, 10); i++) {
if (isICAHeader(lines[i])) {
// Must have at least 2 metadata rows before header to distinguish from
// other semicolon-delimited formats (SEB, Handelsbanken, LF)
return metadataCount >= 2
}
metadataCount++
}
return false
}
export const icaBankenFormat: BankFileFormat = {
id: 'ica_banken',
name: 'ICA Banken',
description: 'ICA Banken CSV (semicolon-delimited, metadata rows before header)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((l) => l.trim() !== '')
return detectICABanken(lines)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Find the header row
let headerLineIdx = -1
for (let i = 0; i < Math.min(lines.length, 10); i++) {
if (isICAHeader(lines[i])) {
headerLineIdx = i
break
}
}
if (headerLineIdx === -1) {
issues.push({
row: 1,
message: 'Could not find ICA Banken header row',
severity: 'error',
})
return {
format: 'ica_banken',
format_name: 'ICA Banken',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
// Parse header columns
const headers = lines[headerLineIdx].split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'datum')
const descIdx = headers.findIndex((h) => h === 'text')
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: headerLineIdx + 1,
message: 'Could not identify required columns (datum, belopp)',
severity: 'error',
})
return {
format: 'ica_banken',
format_name: 'ICA Banken',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = headerLineIdx + 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'ica_banken',
format_name: 'ICA Banken',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - headerLineIdx - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
@@ -0,0 +1,168 @@
/**
* Länsförsäkringar CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator, double-quoted fields
* Columns: Datum, Bokföringsdag, Typ, Text, Belopp, (Saldo optional)
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Fields are double-quoted
* - Two adjacent date columns (Datum + Bokföringsdag) is unique to Länsförsäkringar
* - No guaranteed header row — detect by structure
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
/**
* Check if a line has the Länsförsäkringar structure:
* two adjacent YYYY-MM-DD date fields in a semicolon-delimited, quoted row.
*/
function isLFRow(line: string): boolean {
if (!line.includes(';')) return false
const fields = parseCSVLine(line, ';').map((f) => f.trim())
return fields.length >= 5 && DATE_RE.test(fields[0]) && DATE_RE.test(fields[1])
}
/**
* Check if a line looks like a Länsförsäkringar header row.
*/
function isLFHeader(line: string): boolean {
const lower = line.toLowerCase().replace(/"/g, '')
return (
lower.includes(';') &&
lower.includes('datum') &&
lower.includes('typ') &&
lower.includes('belopp')
)
}
export const lansforsakringarFormat: BankFileFormat = {
id: 'lansforsakringar',
name: 'Länsförsäkringar',
description: 'Länsförsäkringar CSV (semicolon-delimited, quoted fields)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((l) => l.trim() !== '')
if (lines.length < 1) return false
// Check for header with "typ" keyword (unique to LF among semicolon formats)
if (isLFHeader(lines[0])) return true
// Alternatively: detect data rows with two adjacent date fields
// Check first few non-empty lines for the two-date pattern
for (let i = 0; i < Math.min(lines.length, 3); i++) {
if (isLFRow(lines[i])) return true
}
return false
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Determine if first row is a header or data
let startIdx = 0
let dateIdx = 0
let descIdx = 3
let amountIdx = 4
let balanceIdx = 5
if (isLFHeader(lines[0])) {
// Parse header to find column indices
const headers = parseCSVLine(lines[0], ';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
dateIdx = headers.findIndex((h) => h === 'datum')
if (dateIdx === -1) dateIdx = 0
descIdx = headers.findIndex((h) => h === 'text' || h === 'beskrivning')
if (descIdx === -1) descIdx = 3
amountIdx = headers.findIndex((h) => h === 'belopp')
if (amountIdx === -1) amountIdx = 4
balanceIdx = headers.findIndex((h) => h === 'saldo')
startIdx = 1
}
for (let i = startIdx; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
if (fields.length < 5) {
issues.push({ row: i + 1, message: 'Too few columns', severity: 'warning' })
skippedRows++
continue
}
const date = fields[dateIdx]
const description = fields[descIdx] || 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 && balanceIdx < fields.length ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!DATE_RE.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: description.trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
const totalDataRows = lines.length - startIdx
return {
format: 'lansforsakringar',
format_name: 'Länsförsäkringar',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: totalDataRows,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Lunar CSV format parser
*
* Format: Comma-delimited, comma decimal separator (amounts are quoted)
* Columns: Date, Text, Amount, Balance (English headers)
* Date format: YYYY-MM-DD
* Encoding: UTF-8
*
* Notes:
* - English headers distinguish Lunar from Nordea (Swedish headers)
* - Amounts use comma as decimal separator but are quoted since the file
* delimiter is also comma
* - Thousand separator is period (e.g. "1.234,56")
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
function parseLunarAmount(value: string): number {
// Lunar format: "1.234,56" or "-1.234,56"
// Remove period (thousand separator), replace comma (decimal separator) with period
const cleaned = value.replace(/\./g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const lunarFormat: BankFileFormat = {
id: 'lunar',
name: 'Lunar',
description: 'Lunar CSV (comma-delimited, English headers)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// Lunar: comma-delimited with English headers
// Must NOT contain semicolons, must have "date", "text", "amount", "balance"
return (
!firstLine.includes(';') &&
firstLine.includes('date') &&
firstLine.includes('text') &&
firstLine.includes('amount') &&
firstLine.includes('balance')
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header
const headerLine = lines[0] || ''
const headers = parseCSVLine(headerLine, ',').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'date')
const descIdx = headers.findIndex((h) => h === 'text')
const amountIdx = headers.findIndex((h) => h === 'amount')
const balanceIdx = headers.findIndex((h) => h === 'balance')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (date, amount)',
severity: 'error',
})
return {
format: 'lunar',
format_name: 'Lunar',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseLunarAmount(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseLunarAmount(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'lunar',
format_name: 'Lunar',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+4 -2
View File
@@ -25,12 +25,14 @@ export const sebFormat: BankFileFormat = {
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// SEB headers contain "bokföringsdag" or "bokforingsdatum" and use semicolons
// SEB headers contain "bokföringsdag"/"bokforingsdatum" AND "valutadag"/"verifikationsnummer"
// The secondary check distinguishes SEB from Länsförsäkringar (which also has "bokföringsdag")
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum') ||
firstLine.includes('bokföringsdag'))
firstLine.includes('bokföringsdag')) &&
(firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer'))
)
},
+148
View File
@@ -0,0 +1,148 @@
/**
* Skandia CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Datum, Beskrivning/Text, Belopp, Saldo (possibly Bankkategori)
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Header contains "beskrivning" (unique keyword not used by SEB/Handelsbanken)
* or "bankkategori" (Skandia-specific column)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const skandiaFormat: BankFileFormat = {
id: 'skandia',
name: 'Skandia',
description: 'Skandia CSV (semicolon-delimited)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase().replace(/"/g, '') || ''
if (!firstLine.includes(';')) return false
const fields = firstLine.split(';').map((f) => f.trim())
// "bankkategori" is unique to Skandia
if (fields.some((f) => f.includes('bankkategori'))) return true
// "beskrivning" as a standalone column header with semicolon delimiter
// Must also have "datum" and "belopp" to confirm it's a bank export
// Note: Handelsbanken uses "beskrivning" only as a fallback in descIdx logic,
// but its header detection is "reskontradatum"/"transaktionsdatum" which is checked first
if (
fields.some((f) => f === 'beskrivning') &&
fields.some((f) => f === 'datum') &&
fields.some((f) => f === 'belopp')
) {
return true
}
return false
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'datum')
const descIdx = headers.findIndex((h) => h === 'beskrivning' || h === 'text')
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (datum, belopp)',
severity: 'error',
})
return {
format: 'skandia',
format_name: 'Skandia',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'skandia',
format_name: 'Skandia',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+9
View File
@@ -11,12 +11,17 @@ import { nordeaFormat } from './formats/nordea'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
import { lansforsakringarFormat } from './formats/lansforsakringar'
import { icaBankenFormat } from './formats/ica-banken'
import { skandiaFormat } from './formats/skandia'
import { lunarFormat } from './formats/lunar'
import { camt053Format } from './formats/camt053'
import { genericCSVFormat } from './formats/generic-csv'
/**
* Ordered list of format detectors.
* camt.053 first (XML detection is unambiguous), then bank-specific CSV formats.
* New bank formats go after existing ones but before generic_csv.
* Generic CSV is last — it never auto-detects (manual fallback only).
*/
const FORMATS: BankFileFormat[] = [
@@ -25,6 +30,10 @@ const FORMATS: BankFileFormat[] = [
sebFormat,
swedbankFormat,
handelsbankenFormat,
lansforsakringarFormat,
icaBankenFormat,
skandiaFormat,
lunarFormat,
genericCSVFormat,
]
+4
View File
@@ -47,6 +47,10 @@ export type BankFileFormatId =
| 'seb'
| 'swedbank'
| 'handelsbanken'
| 'lansforsakringar'
| 'ica_banken'
| 'skandia'
| 'lunar'
| 'generic_csv'
| 'camt053'
+3 -3
View File
@@ -289,7 +289,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const vatByRate = new Map<number, { base: number; vat: number }>()
if (hasPerLineVat) {
for (const item of items) {
const rate = item.vat_rate ?? 25
const rate = item.vat_rate ?? 0
const group = vatByRate.get(rate) || { base: 0, vat: 0 }
group.base += Math.abs(item.line_total)
group.vat += Math.abs(item.vat_amount || 0)
@@ -411,7 +411,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency)}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 25}%</Text>
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
{!isDeliveryNote && (
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency)}</Text>
@@ -440,7 +440,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
))
) : (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 25)}%):</Text>
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 0)}%):</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
</View>
)}
+1 -1
View File
@@ -162,7 +162,7 @@ export function getVatTreatmentLabel(treatment: VatTreatment): string {
export function getVatSummaryFromItems(
items: { vat_rate?: number | null }[]
): { label: string; treatment: VatTreatment | null; rate: number | null; isMixed: boolean } {
const rates = new Set(items.map((item) => item.vat_rate ?? 25))
const rates = new Set(items.map((item) => item.vat_rate ?? 0))
if (rates.size === 1) {
const rate = rates.values().next().value!
+42 -1
View File
@@ -1,6 +1,7 @@
import { suggestCategory } from '@/lib/tax/expense-warnings'
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import type { Transaction, TransactionCategory, MappingRule } from '@/types'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
export interface SuggestedCategory {
category: TransactionCategory
@@ -177,3 +178,43 @@ export function mergeAiSuggestions(
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5)
}
// ============================================================
// Template Suggestions
// ============================================================
export interface SuggestedTemplate {
template_id: string
name_sv: string
name_en: string
group: string
debit_account: string
credit_account: string
confidence: number
description_sv: string
risk_level: string
requires_review: boolean
}
/**
* Get suggested booking templates for a transaction.
* Uses multi-signal matching (MCC, keywords, description patterns).
*/
export function getSuggestedTemplates(
transaction: Transaction,
entityType?: EntityType
): SuggestedTemplate[] {
const matches = findMatchingTemplates(transaction, entityType)
return matches.map((m: TemplateMatch) => ({
template_id: m.template.id,
name_sv: m.template.name_sv,
name_en: m.template.name_en,
group: m.template.group,
debit_account: m.template.debit_account,
credit_account: m.template.credit_account,
confidence: m.confidence,
description_sv: m.template.description_sv,
risk_level: m.template.risk_level,
requires_review: m.template.requires_review,
}))
}