Merge remote-tracking branch 'origin/main' into logger
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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'))
|
||||
)
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export type BankFileFormatId =
|
||||
| 'seb'
|
||||
| 'swedbank'
|
||||
| 'handelsbanken'
|
||||
| 'lansforsakringar'
|
||||
| 'ica_banken'
|
||||
| 'skandia'
|
||||
| 'lunar'
|
||||
| 'generic_csv'
|
||||
| 'camt053'
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user