1150930cb9
Typing "0" into Betalningsvillkor on a customer made the form silently unsavable: the form schema had min(1) and no error was rendered for the field, so the user saw nothing happen. 0 days is a real value (betalning direkt / vid mottagande), and the invoice schema already accepted it. Customer and supplier forms now validate whole days 0-365 and show the rule under the field; the API schemas (customer create/update, supplier) accept 0 the same way; and every `|| 30` fallback that would have turned a stored 0 back into 30 on edit or create is `?? 30`. Closes #2070 Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
3120 lines
102 KiB
TypeScript
3120 lines
102 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { HouseworkTypeSchema } from '../schemas'
|
|
import {
|
|
// Enums
|
|
EntityTypeSchema,
|
|
CustomerTypeSchema,
|
|
SupplierTypeSchema,
|
|
InvoiceDocumentTypeSchema,
|
|
VatTreatmentSchema,
|
|
AccountingMethodSchema,
|
|
CurrencySchema,
|
|
TransactionCategorySchema,
|
|
JournalEntrySourceTypeSchema,
|
|
AccountTypeSchema,
|
|
NormalBalanceSchema,
|
|
MappingRuleTypeSchema,
|
|
RiskLevelSchema,
|
|
DeadlineTypeSchema,
|
|
DeadlinePrioritySchema,
|
|
TaxDeadlineTypeSchema,
|
|
MomsPeriodSchema,
|
|
DocumentUploadSourceSchema,
|
|
// Invoice schemas
|
|
CreateInvoiceItemSchema,
|
|
CreateInvoiceSchema,
|
|
UpdateInvoiceSchema,
|
|
CreateCreditNoteSchema,
|
|
MarkInvoicePaidSchema,
|
|
CreateRecurringScheduleSchema,
|
|
// Customer schemas
|
|
CreateCustomerSchema,
|
|
// Supplier schemas
|
|
CreateSupplierSchema,
|
|
// Supplier invoice schemas
|
|
CreateSupplierInvoiceItemSchema,
|
|
CreateSupplierInvoiceSchema,
|
|
MarkSupplierInvoicePaidSchema,
|
|
// Journal entry schemas
|
|
CreateJournalEntryLineSchema,
|
|
CreateJournalEntrySchema,
|
|
// Transaction schemas
|
|
CategorizeTransactionSchema,
|
|
BookTransactionSchema,
|
|
MatchInvoiceSchema,
|
|
MatchSupplierInvoiceSchema,
|
|
// Settings schemas
|
|
UpdateSettingsSchema,
|
|
// Fiscal period schemas
|
|
CreateFiscalPeriodSchema,
|
|
// Mapping rule schemas
|
|
CreateMappingRuleSchema,
|
|
// Deadline schemas
|
|
CreateDeadlineSchema,
|
|
// Account schemas
|
|
CreateAccountSchema,
|
|
UpdateAccountSchema,
|
|
// Reconciliation schemas
|
|
BankLinkSchema,
|
|
BankUnlinkSchema,
|
|
RunReconciliationSchema,
|
|
// Update schemas
|
|
UpdateCustomerSchema,
|
|
UpdateSupplierSchema,
|
|
UpdateSupplierInvoiceSchema,
|
|
// Correct/evaluate schemas
|
|
CorrectJournalEntrySchema,
|
|
EvaluateMappingRulesSchema,
|
|
// Report query schemas
|
|
VatDeclarationQuerySchema,
|
|
PaginationQuerySchema,
|
|
// Employee schemas
|
|
CreateEmployeeSchema,
|
|
} from '../schemas'
|
|
|
|
// ============================================================
|
|
// Helpers: minimal valid objects for composition
|
|
// ============================================================
|
|
|
|
const validUuid = '550e8400-e29b-41d4-a716-446655440000'
|
|
|
|
function validInvoiceItem(overrides = {}) {
|
|
return { description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000, ...overrides }
|
|
}
|
|
|
|
function validInvoice(overrides = {}) {
|
|
return {
|
|
customer_id: validUuid,
|
|
invoice_date: '2025-03-15',
|
|
due_date: '2025-04-14',
|
|
currency: 'SEK' as const,
|
|
items: [validInvoiceItem()],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function validCustomer(overrides = {}) {
|
|
return { name: 'Acme AB', customer_type: 'swedish_business' as const, ...overrides }
|
|
}
|
|
|
|
function validSupplier(overrides = {}) {
|
|
return { name: 'Leverantör AB', supplier_type: 'swedish_business' as const, ...overrides }
|
|
}
|
|
|
|
function validSupplierInvoiceItem(overrides = {}) {
|
|
return { description: 'Material', amount: 5000, account_number: '4010', ...overrides }
|
|
}
|
|
|
|
function validSupplierInvoice(overrides = {}) {
|
|
return {
|
|
supplier_id: validUuid,
|
|
supplier_invoice_number: 'F-2025-001',
|
|
invoice_date: '2025-03-01',
|
|
due_date: '2025-03-31',
|
|
items: [validSupplierInvoiceItem()],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function validJournalEntryLine(overrides = {}) {
|
|
return { account_number: '1930', debit_amount: 1000, credit_amount: 0, ...overrides }
|
|
}
|
|
|
|
function validJournalEntry(overrides = {}) {
|
|
return {
|
|
fiscal_period_id: validUuid,
|
|
entry_date: '2025-03-15',
|
|
description: 'Bank deposit',
|
|
lines: [
|
|
validJournalEntryLine({ account_number: '1930', debit_amount: 1000, credit_amount: 0 }),
|
|
validJournalEntryLine({ account_number: '3001', debit_amount: 0, credit_amount: 1000 }),
|
|
],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Enum schema tests
|
|
// ============================================================
|
|
|
|
describe('Enum schemas', () => {
|
|
it('EntityTypeSchema accepts valid values', () => {
|
|
expect(EntityTypeSchema.safeParse('enskild_firma').success).toBe(true)
|
|
expect(EntityTypeSchema.safeParse('aktiebolag').success).toBe(true)
|
|
})
|
|
|
|
it('EntityTypeSchema rejects invalid values', () => {
|
|
expect(EntityTypeSchema.safeParse('llc').success).toBe(false)
|
|
expect(EntityTypeSchema.safeParse('').success).toBe(false)
|
|
expect(EntityTypeSchema.safeParse(123).success).toBe(false)
|
|
})
|
|
|
|
it('CustomerTypeSchema accepts all 4 types', () => {
|
|
for (const val of ['individual', 'swedish_business', 'eu_business', 'non_eu_business']) {
|
|
expect(CustomerTypeSchema.safeParse(val).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('SupplierTypeSchema accepts 3 types', () => {
|
|
for (const val of ['swedish_business', 'eu_business', 'non_eu_business']) {
|
|
expect(SupplierTypeSchema.safeParse(val).success).toBe(true)
|
|
}
|
|
// individual is not a valid supplier type
|
|
expect(SupplierTypeSchema.safeParse('individual').success).toBe(false)
|
|
})
|
|
|
|
it('VatTreatmentSchema accepts all 6 treatments', () => {
|
|
const treatments = ['standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt']
|
|
for (const val of treatments) {
|
|
expect(VatTreatmentSchema.safeParse(val).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('CurrencySchema accepts supported currencies', () => {
|
|
for (const c of ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']) {
|
|
expect(CurrencySchema.safeParse(c).success).toBe(true)
|
|
}
|
|
expect(CurrencySchema.safeParse('JPY').success).toBe(false)
|
|
})
|
|
|
|
it('TransactionCategorySchema accepts all 16 categories', () => {
|
|
const categories = [
|
|
'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',
|
|
]
|
|
for (const c of categories) {
|
|
expect(TransactionCategorySchema.safeParse(c).success).toBe(true)
|
|
}
|
|
expect(TransactionCategorySchema.safeParse('unknown').success).toBe(false)
|
|
})
|
|
|
|
it('JournalEntrySourceTypeSchema accepts all source types', () => {
|
|
const sources = [
|
|
'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',
|
|
'vat_settlement',
|
|
]
|
|
for (const s of sources) {
|
|
expect(JournalEntrySourceTypeSchema.safeParse(s).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('AccountTypeSchema covers all account classes', () => {
|
|
for (const t of ['asset', 'equity', 'liability', 'revenue', 'expense']) {
|
|
expect(AccountTypeSchema.safeParse(t).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('RiskLevelSchema accepts all risk levels', () => {
|
|
for (const r of ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH']) {
|
|
expect(RiskLevelSchema.safeParse(r).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('InvoiceDocumentTypeSchema accepts all document types', () => {
|
|
for (const t of ['invoice', 'proforma', 'delivery_note']) {
|
|
expect(InvoiceDocumentTypeSchema.safeParse(t).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('AccountingMethodSchema accepts accrual and cash', () => {
|
|
expect(AccountingMethodSchema.safeParse('accrual').success).toBe(true)
|
|
expect(AccountingMethodSchema.safeParse('cash').success).toBe(true)
|
|
expect(AccountingMethodSchema.safeParse('hybrid').success).toBe(false)
|
|
})
|
|
|
|
it('MomsPeriodSchema accepts reporting periods', () => {
|
|
for (const p of ['monthly', 'quarterly', 'yearly']) {
|
|
expect(MomsPeriodSchema.safeParse(p).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('DeadlineTypeSchema and DeadlinePrioritySchema', () => {
|
|
for (const t of ['delivery', 'invoicing', 'report', 'tax', 'other']) {
|
|
expect(DeadlineTypeSchema.safeParse(t).success).toBe(true)
|
|
}
|
|
for (const p of ['critical', 'important', 'normal']) {
|
|
expect(DeadlinePrioritySchema.safeParse(p).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('TaxDeadlineTypeSchema accepts all Swedish tax deadlines', () => {
|
|
const types = [
|
|
'moms_monthly', 'moms_quarterly', 'moms_yearly', 'f_skatt',
|
|
'arbetsgivardeklaration', 'inkomstdeklaration_ef', 'inkomstdeklaration_ab',
|
|
'arsredovisning', 'arsstamma', 'periodisk_sammanstallning',
|
|
]
|
|
for (const t of types) {
|
|
expect(TaxDeadlineTypeSchema.safeParse(t).success).toBe(true)
|
|
}
|
|
// Retired: replaced by the statutory arsstamma deadline (ABL 7:10).
|
|
expect(TaxDeadlineTypeSchema.safeParse('bokslut').success).toBe(false)
|
|
})
|
|
|
|
it('NormalBalanceSchema and MappingRuleTypeSchema', () => {
|
|
for (const b of ['debit', 'credit']) {
|
|
expect(NormalBalanceSchema.safeParse(b).success).toBe(true)
|
|
}
|
|
for (const t of ['mcc_code', 'merchant_name', 'description_pattern', 'amount_threshold', 'combined']) {
|
|
expect(MappingRuleTypeSchema.safeParse(t).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('DocumentUploadSourceSchema accepts all sources', () => {
|
|
for (const s of ['camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system']) {
|
|
expect(DocumentUploadSourceSchema.safeParse(s).success).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Invoice schemas
|
|
// ============================================================
|
|
|
|
describe('CreateInvoiceSchema: ROT/RUT line completeness', () => {
|
|
const paths = (r: { success: boolean; error?: { issues: Array<{ path: PropertyKey[] }> } }) =>
|
|
r.success ? [] : r.error!.issues.map((i) => i.path.join('.'))
|
|
const rutLine = (extra: Record<string, unknown>) => validInvoiceItem({ deduction_type: 'rut', ...extra })
|
|
|
|
it('requires a same-kind arbetstyp and hours > 0 on invoice documents', () => {
|
|
expect(paths(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({})] })))).toEqual(
|
|
expect.arrayContaining(['items.0.work_type', 'items.0.labor_hours']),
|
|
)
|
|
expect(paths(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'BYGG', labor_hours: 2 })] })))).toEqual(['items.0.work_type'])
|
|
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'STAD', labor_hours: 2 })] })).success).toBe(true)
|
|
// Schablontjänst: no hours needed
|
|
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'TVATT' })] })).success).toBe(true)
|
|
})
|
|
|
|
it('does not apply to proformas / delivery notes (server nulls the fields) nor to text rows', () => {
|
|
expect(CreateInvoiceSchema.safeParse(validInvoice({ document_type: 'proforma', items: [rutLine({})] })).success).toBe(true)
|
|
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [validInvoiceItem({ line_type: 'text', description: '', deduction_type: 'rut' })] })).success).toBe(true)
|
|
})
|
|
|
|
it('applies to UpdateInvoiceSchema too', () => {
|
|
const { save_as_draft: _s, ...body } = validInvoice({ items: [rutLine({})] }) as Record<string, unknown>
|
|
expect(UpdateInvoiceSchema.safeParse(body).success).toBe(false)
|
|
expect(UpdateInvoiceSchema.safeParse({ ...body, items: [rutLine({ work_type: 'STAD', labor_hours: 1 })] }).success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('CreateInvoiceSchema', () => {
|
|
it('accepts a valid invoice', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice())
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts invoice with optional fields', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
document_type: 'proforma',
|
|
your_reference: 'John Doe',
|
|
our_reference: 'Jane Doe',
|
|
notes: 'Net 30',
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('payment_link_url accepts a valid https URL', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
payment_link_url: 'https://buy.stripe.com/test_abc123',
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.payment_link_url).toBe('https://buy.stripe.com/test_abc123')
|
|
}
|
|
})
|
|
|
|
it('payment_link_url normalises empty string to undefined (form always sends the field)', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({ payment_link_url: '' }))
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.payment_link_url).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('payment_link_url rejects non-https and malformed values', () => {
|
|
const bad = [
|
|
'http://buy.stripe.com/abc', // plaintext link in a customer email
|
|
'javascript:alert(1)',
|
|
'not a url',
|
|
`https://pay.example.se/${'a'.repeat(2049)}`, // over the 2048 cap
|
|
]
|
|
for (const value of bad) {
|
|
expect(
|
|
CreateInvoiceSchema.safeParse(validInvoice({ payment_link_url: value })).success,
|
|
).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('accepts invoice with per-line VAT rates', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [
|
|
validInvoiceItem({ vat_rate: 0.25 }),
|
|
validInvoiceItem({ description: 'Food', vat_rate: 0.12 }),
|
|
validInvoiceItem({ description: 'Books', vat_rate: 0.06 }),
|
|
],
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing customer_id', () => {
|
|
const { customer_id: _, ...rest } = validInvoice()
|
|
const result = CreateInvoiceSchema.safeParse(rest)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid customer_id (not UUID)', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({ customer_id: 'not-a-uuid' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid date format', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({ invoice_date: '15/03/2025' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid currency', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({ currency: 'JPY' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty items array', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({ items: [] }))
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
const itemsError = result.error.issues.find(i => i.path.includes('items'))
|
|
expect(itemsError?.message).toContain('At least one item')
|
|
}
|
|
})
|
|
|
|
it('rejects item with empty description', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ description: '' })],
|
|
}))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects item with zero quantity', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ quantity: 0 })],
|
|
}))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects item with negative quantity', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ quantity: -5 })],
|
|
}))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects vat_rate > 100', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ vat_rate: 101 })],
|
|
}))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts vat_rate of 25 (standard Swedish VAT)', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ vat_rate: 25 })],
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts vat_rate of 0 (export/exempt)', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ vat_rate: 0 })],
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects invalid document_type', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({ document_type: 'receipt' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('allows negative unit_price (for discounts)', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ unit_price: -100 })],
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
// Regression: the dashboard invoice form always sends the self-billing fields
|
|
// (default '' for a normal invoice). Empty strings must read as "not
|
|
// provided", not fail min(1)/isoDate, or every regular invoice create 400s.
|
|
it('treats empty self-billing strings as omitted (not a validation error)', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
external_invoice_number: '',
|
|
self_billing_agreement_ref: '',
|
|
received_date: '',
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.external_invoice_number).toBeUndefined()
|
|
expect(result.data.self_billing_agreement_ref).toBeUndefined()
|
|
expect(result.data.received_date).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('still accepts real self-billing values', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
is_self_billed: true,
|
|
external_invoice_number: 'CUST-2026-014',
|
|
self_billing_agreement_ref: 'AVTAL-7',
|
|
received_date: '2026-07-07',
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.external_invoice_number).toBe('CUST-2026-014')
|
|
expect(result.data.received_date).toBe('2026-07-07')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('UpdateInvoiceSchema', () => {
|
|
it('accepts a valid update body', () => {
|
|
const result = UpdateInvoiceSchema.safeParse(validInvoice())
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects an empty items array', () => {
|
|
const result = UpdateInvoiceSchema.safeParse(validInvoice({ items: [] }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects an invalid item shape', () => {
|
|
const result = UpdateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ description: '' })],
|
|
}))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('drops save_as_draft: editing a draft never re-creates it', () => {
|
|
const result = UpdateInvoiceSchema.safeParse(validInvoice({ save_as_draft: true }))
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data).not.toHaveProperty('save_as_draft')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('CreateInvoiceItemSchema', () => {
|
|
it('accepts valid item with all fields', () => {
|
|
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ vat_rate: 0.25 }))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts item without vat_rate (uses invoice default)', () => {
|
|
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem())
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects non-numeric quantity', () => {
|
|
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ quantity: 'ten' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts orgnr-shaped brf_org_number values', () => {
|
|
for (const value of ['769600-0000', '7696000000', '167696000000']) {
|
|
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: value }))
|
|
expect(result.success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('normalizes an empty brf_org_number to null', () => {
|
|
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: '' }))
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.brf_org_number).toBeNull()
|
|
})
|
|
|
|
it('rejects malformed brf_org_number values', () => {
|
|
// incl. a 12-digit value without the mandatory sekelsiffra 16 prefix
|
|
for (const value of ['---', '123', '76-96000000', 'ABC600-0000', '123456789012']) {
|
|
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: value }))
|
|
expect(result.success).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('rejects a product row with an empty description', () => {
|
|
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ description: ' ' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects a product row with non-positive quantity', () => {
|
|
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ quantity: 0 }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts a free-text row with an empty description and zero amounts', () => {
|
|
const result = CreateInvoiceItemSchema.safeParse({
|
|
line_type: 'text',
|
|
description: '',
|
|
quantity: 0,
|
|
unit: '',
|
|
unit_price: 0,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts a free-text row carrying explanatory text', () => {
|
|
const result = CreateInvoiceItemSchema.safeParse({
|
|
line_type: 'text',
|
|
description: 'Arbetet utfört enligt offert 2026-04',
|
|
quantity: 0,
|
|
unit: '',
|
|
unit_price: 0,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('CreateCreditNoteSchema', () => {
|
|
it('accepts valid credit note reference', () => {
|
|
const result = CreateCreditNoteSchema.safeParse({ credited_invoice_id: validUuid })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts credit note with reason', () => {
|
|
const result = CreateCreditNoteSchema.safeParse({
|
|
credited_invoice_id: validUuid,
|
|
reason: 'Duplicate billing',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing credited_invoice_id', () => {
|
|
const result = CreateCreditNoteSchema.safeParse({})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-UUID credited_invoice_id', () => {
|
|
const result = CreateCreditNoteSchema.safeParse({ credited_invoice_id: 'INV-001' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('MarkInvoicePaidSchema', () => {
|
|
it('accepts empty object (all fields optional)', () => {
|
|
const result = MarkInvoicePaidSchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts payment_date', () => {
|
|
const result = MarkInvoicePaidSchema.safeParse({ payment_date: '2024-07-15' })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts exchange_rate_difference (positive gain)', () => {
|
|
const result = MarkInvoicePaidSchema.safeParse({ exchange_rate_difference: 200 })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts exchange_rate_difference (negative loss)', () => {
|
|
const result = MarkInvoicePaidSchema.safeParse({ exchange_rate_difference: -300 })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts all fields together', () => {
|
|
const result = MarkInvoicePaidSchema.safeParse({
|
|
payment_date: '2024-07-15',
|
|
exchange_rate_difference: 150.50,
|
|
notes: 'Paid via Wise',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects invalid payment_date format', () => {
|
|
const result = MarkInvoicePaidSchema.safeParse({ payment_date: '15/07/2024' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-number exchange_rate_difference', () => {
|
|
const result = MarkInvoicePaidSchema.safeParse({ exchange_rate_difference: 'big gain' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Customer schemas
|
|
// ============================================================
|
|
|
|
describe('CreateCustomerSchema', () => {
|
|
it('accepts valid customer with minimal fields', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer())
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts customer with all optional fields', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({
|
|
email: 'billing@acme.se',
|
|
phone: '+46701234567',
|
|
contact_person: 'Anna Andersson',
|
|
invoice_email_cc_addresses: ['finance@acme.se'],
|
|
invoice_email_bcc_addresses: ['archive@acme.se'],
|
|
address_line1: 'Storgatan 1',
|
|
address_line2: 'Box 123',
|
|
postal_code: '111 22',
|
|
city: 'Stockholm',
|
|
country: 'Sweden',
|
|
org_number: '556123-4567',
|
|
vat_number: 'SE556123456701',
|
|
default_payment_terms: 30,
|
|
notes: 'Key account',
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects empty name', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ name: '' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects missing name', () => {
|
|
const { name: _, ...rest } = validCustomer()
|
|
const result = CreateCustomerSchema.safeParse(rest)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid customer_type', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ customer_type: 'government' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid email format', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ email: 'not-an-email' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts valid email', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ email: 'test@example.com' }))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects negative payment terms', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ default_payment_terms: -10 }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts zero payment terms (betalning direkt, issue #2070)', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ default_payment_terms: 0 }))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects payment terms above 365 days', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ default_payment_terms: 366 }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-integer payment terms', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ default_payment_terms: 30.5 }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects more than 19 customer invoice copy recipients across CC and BCC', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({
|
|
invoice_email_cc_addresses: Array.from(
|
|
{ length: 10 },
|
|
(_, index) => `copy-${index}@example.test`,
|
|
),
|
|
invoice_email_bcc_addresses: Array.from(
|
|
{ length: 10 },
|
|
(_, index) => `archive-${index}@example.test`,
|
|
),
|
|
}))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// Where an individual's personnummer lands (#1707 follow-up). Synthetic
|
|
// personnummer throughout, never a real one.
|
|
describe('CreateCustomerSchema: personnummer placement', () => {
|
|
it('moves a personnummer-shaped org_number on an individual into personal_number', () => {
|
|
const result = CreateCustomerSchema.safeParse({
|
|
name: 'Anna Andersson',
|
|
customer_type: 'individual',
|
|
org_number: '19900101-1234',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.personal_number).toBe('19900101-1234')
|
|
expect(result.data.org_number).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('strips whitespace from the moved value so it matches the personnummer input forms', () => {
|
|
const result = CreateCustomerSchema.safeParse({
|
|
name: 'Anna Andersson',
|
|
customer_type: 'individual',
|
|
org_number: '19900101 1234',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.personal_number).toBe('199001011234')
|
|
})
|
|
|
|
it('drops org_number when it duplicates personal_number', () => {
|
|
const result = CreateCustomerSchema.safeParse({
|
|
name: 'Anna Andersson',
|
|
customer_type: 'individual',
|
|
org_number: '199001011234',
|
|
personal_number: '19900101-1234',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.personal_number).toBe('19900101-1234')
|
|
expect(result.data.org_number).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('rejects an org_number that is a different personnummer than personal_number', () => {
|
|
const result = CreateCustomerSchema.safeParse({
|
|
name: 'Anna Andersson',
|
|
customer_type: 'individual',
|
|
org_number: '19850505-5555',
|
|
personal_number: '19900101-1234',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
expect(result.error.issues.some((issue) => issue.path.join('.') === 'org_number')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('still rejects a personnummer-shaped org_number on a business customer', () => {
|
|
const result = CreateCustomerSchema.safeParse(validCustomer({ org_number: '19900101-1234' }))
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
expect(result.error.issues.some((issue) => issue.path.join('.') === 'org_number')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('leaves a legal-entity organisationsnummer alone on every customer_type', () => {
|
|
for (const customer_type of ['individual', 'swedish_business'] as const) {
|
|
const result = CreateCustomerSchema.safeParse({ name: 'X', customer_type, org_number: '556677-8899' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.org_number).toBe('556677-8899')
|
|
expect(result.data.personal_number).toBeUndefined()
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Supplier schemas
|
|
// ============================================================
|
|
|
|
describe('CreateSupplierSchema', () => {
|
|
it('accepts valid supplier with minimal fields', () => {
|
|
const result = CreateSupplierSchema.safeParse(validSupplier())
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts supplier with payment details', () => {
|
|
const result = CreateSupplierSchema.safeParse(validSupplier({
|
|
bankgiro: '123-4567',
|
|
plusgiro: '12345-6',
|
|
iban: 'SE1234567890123456789',
|
|
bic: 'ESSESESS',
|
|
default_expense_account: '4010',
|
|
default_payment_terms: 30,
|
|
default_currency: 'SEK',
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects empty name', () => {
|
|
const result = CreateSupplierSchema.safeParse(validSupplier({ name: '' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid supplier_type', () => {
|
|
const result = CreateSupplierSchema.safeParse(validSupplier({ supplier_type: 'individual' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid expense account format', () => {
|
|
const result = CreateSupplierSchema.safeParse(validSupplier({ default_expense_account: '40' }))
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts valid 4-digit expense account', () => {
|
|
const result = CreateSupplierSchema.safeParse(validSupplier({ default_expense_account: '6200' }))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
// The web form submits untouched optional inputs as '' (Björn 2026-08-17:
|
|
// saving with the field left blank failed "Kontonummer måste vara 4 siffror").
|
|
it('treats empty-string expense account as absent', () => {
|
|
const result = CreateSupplierSchema.safeParse(validSupplier({ default_expense_account: '' }))
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.default_expense_account).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('treats empty-string email as absent', () => {
|
|
const result = CreateSupplierSchema.safeParse(validSupplier({ email: '' }))
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.email).toBeUndefined()
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Supplier invoice schemas
|
|
// ============================================================
|
|
|
|
describe('CreateSupplierInvoiceSchema', () => {
|
|
it('accepts valid supplier invoice', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(validSupplierInvoice())
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts invoice with all optional fields', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(validSupplierInvoice({
|
|
delivery_date: '2025-03-15',
|
|
currency: 'EUR',
|
|
exchange_rate: 11.35,
|
|
vat_treatment: 'reverse_charge',
|
|
reverse_charge: true,
|
|
payment_reference: 'OCR-123456',
|
|
notes: 'Quarterly supply',
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing supplier_id', () => {
|
|
const { supplier_id: _, ...rest } = validSupplierInvoice()
|
|
const result = CreateSupplierInvoiceSchema.safeParse(rest)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty supplier invoice number', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({ supplier_invoice_number: '' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty items array', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({ items: [] })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects item with invalid account number', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({
|
|
items: [validSupplierInvoiceItem({ account_number: 'abc' })],
|
|
})
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects zero exchange rate', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({ exchange_rate: 0 })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects negative exchange rate', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({ exchange_rate: -1.5 })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
// The DB CHECK supplier_invoices_exchange_rate_check reads
|
|
// `exchange_rate IS NULL OR (exchange_rate > 0 AND exchange_rate < 100000)`.
|
|
// The schema had no ceiling at all, so a fat-fingered rate reached Postgres
|
|
// and came back as a 23514 the route surfaced as a 500. These three pin the
|
|
// mirror to the constraint, exclusivity included.
|
|
it('rejects an exchange rate at or above the DB ceiling of 100000', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({ exchange_rate: 250000 })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
const issue = result.error?.issues.find((i) => i.path.join('.') === 'exchange_rate')
|
|
expect(issue?.message).toContain('100 000')
|
|
})
|
|
|
|
it('rejects exactly 100000: the CHECK bound is exclusive', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({ exchange_rate: 100000 })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts 99999.99, just inside the exclusive ceiling', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({ exchange_rate: 99999.99 })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts item with legacy quantity/unit_price fields', () => {
|
|
const result = CreateSupplierInvoiceSchema.safeParse(
|
|
validSupplierInvoice({
|
|
items: [validSupplierInvoiceItem({ quantity: 10, unit: 'st', unit_price: 500 })],
|
|
})
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('CreateSupplierInvoiceItemSchema', () => {
|
|
it('accepts valid item', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(validSupplierInvoiceItem())
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects item with 3-digit account number', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ account_number: '401' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects item with 5-digit account number', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ account_number: '40100' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts vat_rate within valid range', () => {
|
|
for (const rate of [0, 0.06, 0.12, 0.25]) {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ vat_rate: rate })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects percent-shaped or non-statutory vat_rate (decimal convention, issue #310)', () => {
|
|
// 25/12/6 are the percent-integer shape (books 2500 % VAT if accepted),
|
|
// 0.19 is a foreign decimal rate, 100 is the old max() boundary.
|
|
for (const rate of [25, 12, 6, 0.19, 100]) {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ vat_rate: rate })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('rejects percent-shaped vat_rate with a unit hint in the message', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ vat_rate: 25 })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
expect(result.error.issues[0].message).toMatch(/decimal fraction/)
|
|
}
|
|
})
|
|
|
|
it('accepts vat_amount up to line_total * vat_rate', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 1250 })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts partial-deduction vat_amount (bilförmån 50%)', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 625 })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects vat_amount above line_total * vat_rate', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 2000 })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts vat_amount with 1-öre rounding tolerance', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ amount: 100.04, vat_rate: 0.25, vat_amount: 25.02 })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts apply_slp as an optional boolean (särskild löneskatt opt-in)', () => {
|
|
const flagged = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ account_number: '7412', vat_rate: 0, apply_slp: true })
|
|
)
|
|
expect(flagged.success).toBe(true)
|
|
|
|
const omitted = CreateSupplierInvoiceItemSchema.safeParse(validSupplierInvoiceItem())
|
|
expect(omitted.success).toBe(true)
|
|
})
|
|
|
|
it('rejects non-boolean apply_slp', () => {
|
|
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ apply_slp: 'yes' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('works with quantity * unit_price line total', () => {
|
|
const overByABit = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({
|
|
amount: undefined,
|
|
quantity: 4,
|
|
unit_price: 100,
|
|
vat_rate: 0.25,
|
|
vat_amount: 200,
|
|
})
|
|
)
|
|
expect(overByABit.success).toBe(false)
|
|
const exact = CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({
|
|
amount: undefined,
|
|
quantity: 4,
|
|
unit_price: 100,
|
|
vat_rate: 0.25,
|
|
vat_amount: 100,
|
|
})
|
|
)
|
|
expect(exact.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('MarkSupplierInvoicePaidSchema', () => {
|
|
it('accepts empty object (all optional)', () => {
|
|
const result = MarkSupplierInvoicePaidSchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts full payment details', () => {
|
|
const result = MarkSupplierInvoicePaidSchema.safeParse({
|
|
amount: 5000,
|
|
payment_date: '2025-03-31',
|
|
exchange_rate_difference: -12.50,
|
|
notes: 'Paid via bank transfer',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects zero amount', () => {
|
|
const result = MarkSupplierInvoicePaidSchema.safeParse({ amount: 0 })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects negative amount', () => {
|
|
const result = MarkSupplierInvoicePaidSchema.safeParse({ amount: -100 })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid payment_date format', () => {
|
|
const result = MarkSupplierInvoicePaidSchema.safeParse({ payment_date: '2025/03/31' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('allows negative exchange_rate_difference (loss)', () => {
|
|
const result = MarkSupplierInvoicePaidSchema.safeParse({ exchange_rate_difference: -50.25 })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Journal entry schemas
|
|
// ============================================================
|
|
|
|
describe('CreateJournalEntrySchema', () => {
|
|
it('accepts valid balanced entry', () => {
|
|
const result = CreateJournalEntrySchema.safeParse(validJournalEntry())
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts entry with optional source_type', () => {
|
|
const result = CreateJournalEntrySchema.safeParse(
|
|
validJournalEntry({ source_type: 'manual' })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts entry with all source types', () => {
|
|
const sourceTypes = [
|
|
'manual', 'bank_transaction', 'invoice_created', 'invoice_paid',
|
|
'storno', 'correction', 'system',
|
|
]
|
|
for (const source_type of sourceTypes) {
|
|
const result = CreateJournalEntrySchema.safeParse(
|
|
validJournalEntry({ source_type })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects entry with only one line (not double-entry)', () => {
|
|
const result = CreateJournalEntrySchema.safeParse(
|
|
validJournalEntry({ lines: [validJournalEntryLine()] })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
const linesError = result.error.issues.find(i => i.path.includes('lines'))
|
|
expect(linesError?.message).toContain('two lines')
|
|
}
|
|
})
|
|
|
|
it('rejects entry with empty lines', () => {
|
|
const result = CreateJournalEntrySchema.safeParse(
|
|
validJournalEntry({ lines: [] })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects missing description', () => {
|
|
const { description: _, ...rest } = validJournalEntry()
|
|
const result = CreateJournalEntrySchema.safeParse(rest)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty description', () => {
|
|
const result = CreateJournalEntrySchema.safeParse(
|
|
validJournalEntry({ description: '' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid fiscal_period_id', () => {
|
|
const result = CreateJournalEntrySchema.safeParse(
|
|
validJournalEntry({ fiscal_period_id: 'not-uuid' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid entry_date format', () => {
|
|
const result = CreateJournalEntrySchema.safeParse(
|
|
validJournalEntry({ entry_date: '2025-3-15' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('CreateJournalEntryLineSchema', () => {
|
|
it('accepts valid debit line', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse(
|
|
validJournalEntryLine({ debit_amount: 1000, credit_amount: 0 })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts valid credit line', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse(
|
|
validJournalEntryLine({ debit_amount: 0, credit_amount: 1000 })
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts line with currency info', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse({
|
|
account_number: '1930',
|
|
debit_amount: 11350,
|
|
credit_amount: 0,
|
|
currency: 'EUR',
|
|
amount_in_currency: 1000,
|
|
exchange_rate: 11.35,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts line with cost center and project', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse({
|
|
...validJournalEntryLine(),
|
|
cost_center: 'CC-100',
|
|
project: 'PROJ-2025-01',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects invalid account number', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse(
|
|
validJournalEntryLine({ account_number: '19' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects account number with letters', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse(
|
|
validJournalEntryLine({ account_number: '193a' })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects negative debit_amount', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse(
|
|
validJournalEntryLine({ debit_amount: -100 })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects negative credit_amount', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse(
|
|
validJournalEntryLine({ credit_amount: -100 })
|
|
)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('defaults debit_amount and credit_amount to 0', () => {
|
|
const result = CreateJournalEntryLineSchema.safeParse({ account_number: '1930' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.debit_amount).toBe(0)
|
|
expect(result.data.credit_amount).toBe(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Transaction schemas
|
|
// ============================================================
|
|
|
|
describe('CategorizeTransactionSchema', () => {
|
|
it('accepts minimal categorization (private)', () => {
|
|
const result = CategorizeTransactionSchema.safeParse({ is_business: false })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts business categorization with details', () => {
|
|
const result = CategorizeTransactionSchema.safeParse({
|
|
is_business: true,
|
|
category: 'expense_office',
|
|
vat_treatment: 'standard_25',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts account override', () => {
|
|
const result = CategorizeTransactionSchema.safeParse({
|
|
is_business: true,
|
|
category: 'expense_equipment',
|
|
account_override: '1250',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing is_business', () => {
|
|
const result = CategorizeTransactionSchema.safeParse({ category: 'private' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-boolean is_business', () => {
|
|
const result = CategorizeTransactionSchema.safeParse({ is_business: 'yes' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid category', () => {
|
|
const result = CategorizeTransactionSchema.safeParse({
|
|
is_business: true,
|
|
category: 'food',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid account_override format', () => {
|
|
const result = CategorizeTransactionSchema.safeParse({
|
|
is_business: true,
|
|
account_override: '12',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('BookTransactionSchema', () => {
|
|
it('accepts valid booking', () => {
|
|
const result = BookTransactionSchema.safeParse({
|
|
fiscal_period_id: validUuid,
|
|
entry_date: '2025-03-15',
|
|
description: 'Office supplies',
|
|
lines: [
|
|
{ account_number: '6100', debit_amount: 800, credit_amount: 0 },
|
|
{ account_number: '2641', debit_amount: 200, credit_amount: 0 },
|
|
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
|
|
],
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects empty description', () => {
|
|
const result = BookTransactionSchema.safeParse({
|
|
fiscal_period_id: validUuid,
|
|
entry_date: '2025-03-15',
|
|
description: '',
|
|
lines: [validJournalEntryLine()],
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty lines', () => {
|
|
const result = BookTransactionSchema.safeParse({
|
|
fiscal_period_id: validUuid,
|
|
entry_date: '2025-03-15',
|
|
description: 'Test',
|
|
lines: [],
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('MatchInvoiceSchema', () => {
|
|
it('accepts valid invoice_id', () => {
|
|
const result = MatchInvoiceSchema.safeParse({ invoice_id: validUuid })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing invoice_id', () => {
|
|
const result = MatchInvoiceSchema.safeParse({})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-UUID invoice_id', () => {
|
|
const result = MatchInvoiceSchema.safeParse({ invoice_id: 'INV-001' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
// manual_exchange_rate lands in invoice_payments.payment_exchange_rate,
|
|
// whose CHECK is `> 0 AND < 100000`. The old `.max(100000)` was inclusive:
|
|
// exactly 100000 passed Zod and then violated the constraint.
|
|
it('rejects exactly 100000 for manual_exchange_rate (exclusive CHECK)', () => {
|
|
const result = MatchInvoiceSchema.safeParse({
|
|
invoice_id: validUuid,
|
|
manual_exchange_rate: 100000,
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts 99999.99 for manual_exchange_rate', () => {
|
|
const result = MatchInvoiceSchema.safeParse({
|
|
invoice_id: validUuid,
|
|
manual_exchange_rate: 99999.99,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects a zero or negative manual_exchange_rate', () => {
|
|
expect(MatchInvoiceSchema.safeParse({
|
|
invoice_id: validUuid,
|
|
manual_exchange_rate: 0,
|
|
}).success).toBe(false)
|
|
expect(MatchInvoiceSchema.safeParse({
|
|
invoice_id: validUuid,
|
|
manual_exchange_rate: -11.5,
|
|
}).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('MatchSupplierInvoiceSchema', () => {
|
|
it('accepts valid supplier_invoice_id', () => {
|
|
const result = MatchSupplierInvoiceSchema.safeParse({ supplier_invoice_id: validUuid })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing supplier_invoice_id', () => {
|
|
const result = MatchSupplierInvoiceSchema.safeParse({})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Settings schemas
|
|
// ============================================================
|
|
|
|
describe('UpdateSettingsSchema', () => {
|
|
it('accepts empty update (no changes)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts partial update', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
company_name: 'My AB',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects more than 19 fixed invoice copy recipients in total', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_email_cc_addresses: Array.from(
|
|
{ length: 10 },
|
|
(_, index) => `copy-${index}@example.test`,
|
|
),
|
|
invoice_email_bcc_addresses: Array.from(
|
|
{ length: 10 },
|
|
(_, index) => `archive-${index}@example.test`,
|
|
),
|
|
})
|
|
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts empty strings when clearing nested invoice payment account fields', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_payment_accounts: {
|
|
SEK: {
|
|
clearing_number: '',
|
|
account_number: '',
|
|
bankgiro: '',
|
|
plusgiro: '',
|
|
iban: '',
|
|
bic: '',
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts a USD payment account with routing number + account number + BIC and no IBAN', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_payment_accounts: {
|
|
USD: {
|
|
bank_name: 'Wise US Inc',
|
|
bic: 'trwius35xxx',
|
|
bank_code: '084 009 519',
|
|
foreign_account_number: '9600 0012 3456 7890',
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
const usd = result.data.invoice_payment_accounts?.USD
|
|
expect(usd?.bic).toBe('TRWIUS35XXX')
|
|
expect(usd?.bank_code).toBe('084009519')
|
|
expect(usd?.foreign_account_number).toBe('9600001234567890')
|
|
}
|
|
})
|
|
|
|
it('accepts a GBP payment account with a dashed sort code', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_payment_accounts: {
|
|
GBP: { bic: 'NWBKGB2L', bank_code: '60-16-13', foreign_account_number: '31926819' },
|
|
},
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects a USD payment account with neither IBAN nor the full routing triple', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_payment_accounts: {
|
|
USD: { bank_code: '084009519', foreign_account_number: '9600001234567890' },
|
|
},
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('still requires an IBAN for an EUR payment account even with a routing triple', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_payment_accounts: {
|
|
EUR: { bic: 'DEUTDEFF', bank_code: '37040044', foreign_account_number: '0532013000' },
|
|
},
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts null when clearing the legacy SEK bank account mirror', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
bank_name: null,
|
|
clearing_number: null,
|
|
account_number: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: null,
|
|
bic: null,
|
|
})
|
|
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts and normalizes a non-Swedish IBAN in the legacy SEK mirror', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
iban: 'gb29 nwbk 6016 1331 9268 19',
|
|
})
|
|
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.iban).toBe('GB29NWBK60161331926819')
|
|
})
|
|
|
|
it('accepts a positive next_arrival_number (supplier-invoice start floor)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ next_arrival_number: 248 })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.next_arrival_number).toBe(248)
|
|
}
|
|
})
|
|
|
|
it('rejects a non-positive next_arrival_number', () => {
|
|
expect(UpdateSettingsSchema.safeParse({ next_arrival_number: 0 }).success).toBe(false)
|
|
expect(UpdateSettingsSchema.safeParse({ next_arrival_number: -5 }).success).toBe(false)
|
|
expect(UpdateSettingsSchema.safeParse({ next_arrival_number: 1.5 }).success).toBe(false)
|
|
})
|
|
|
|
it('accepts vat_registered: true with required vat_number and moms_period', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
vat_registered: true,
|
|
vat_number: 'SE556123456701',
|
|
moms_period: 'quarterly',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts vat_registered: true without vat_number at schema level (route-level check uses effective state)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
vat_registered: true,
|
|
moms_period: 'quarterly',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts vat_registered: true without moms_period at schema level (route-level check uses effective state)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
vat_registered: true,
|
|
vat_number: 'SE556123456701',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('normalises vat_number (lowercase, spaces, hyphens) to the canonical SE+12 form', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
vat_registered: true,
|
|
vat_number: 'se 556123-4567 01',
|
|
moms_period: 'quarterly',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.vat_number).toBe('SE556123456701')
|
|
}
|
|
})
|
|
|
|
it('rejects vat_number with 14 digits (the SE + 12-digit personnummer + 01 bug)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
vat_registered: true,
|
|
vat_number: 'SE19900101123401',
|
|
moms_period: 'quarterly',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
const vatError = result.error.issues.find(i => i.path.includes('vat_number'))
|
|
expect(vatError?.message).toContain('SE följt av 12 siffror')
|
|
}
|
|
})
|
|
|
|
it('allows aktiebolag with kontantmetoden (BFL 5 kap. 2 §)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
entity_type: 'aktiebolag',
|
|
accounting_method: 'cash',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('allows aktiebolag with faktureringsmetoden', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
entity_type: 'aktiebolag',
|
|
accounting_method: 'accrual',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('allows enskild firma with kontantmetoden', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
entity_type: 'enskild_firma',
|
|
accounting_method: 'cash',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts full update', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
entity_type: 'aktiebolag',
|
|
company_name: 'Tech AB',
|
|
org_number: '556123-4567',
|
|
f_skatt: true,
|
|
vat_registered: true,
|
|
vat_number: 'SE556123456701',
|
|
moms_period: 'quarterly',
|
|
fiscal_year_start_month: 7,
|
|
accounting_method: 'accrual',
|
|
invoice_default_days: 30,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('enforces BFL 3 kap: enskild firma must start in January', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
entity_type: 'enskild_firma',
|
|
fiscal_year_start_month: 7,
|
|
})
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
const bflError = result.error.issues.find(i =>
|
|
i.path.includes('fiscal_year_start_month')
|
|
)
|
|
expect(bflError?.message).toContain('BFL')
|
|
}
|
|
})
|
|
|
|
it('allows enskild firma with January start', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
entity_type: 'enskild_firma',
|
|
fiscal_year_start_month: 1,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('allows aktiebolag with any start month', () => {
|
|
for (let month = 1; month <= 12; month++) {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
entity_type: 'aktiebolag',
|
|
fiscal_year_start_month: month,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects fiscal_year_start_month out of range', () => {
|
|
expect(UpdateSettingsSchema.safeParse({ fiscal_year_start_month: 0 }).success).toBe(false)
|
|
expect(UpdateSettingsSchema.safeParse({ fiscal_year_start_month: 13 }).success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid accounting_method', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ accounting_method: 'hybrid' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts null moms_period (unregistered)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ moms_period: null })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('validates tax deadline filing-profile fields', () => {
|
|
expect(UpdateSettingsSchema.safeParse({
|
|
vat_taxable_base_over_40m: true,
|
|
vat_has_eu_trade: true,
|
|
vat_filing_method: 'electronic',
|
|
periodisk_sammanstallning_enabled: true,
|
|
periodisk_sammanstallning_filing_method: 'paper',
|
|
}).success).toBe(true)
|
|
expect(UpdateSettingsSchema.safeParse({ vat_filing_method: 'fax' }).success).toBe(false)
|
|
expect(UpdateSettingsSchema.safeParse({
|
|
periodisk_sammanstallning_filing_method: 'fax',
|
|
}).success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid email', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ email: 'not-email' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-integer invoice_default_days', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ invoice_default_days: 30.5 })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
describe('swish', () => {
|
|
it('accepts a Swish-företag number (123XXXXXXX)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ swish: '1234567890' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.swish).toBe('1234567890')
|
|
})
|
|
|
|
it('accepts a Swedish mobile number (07XXXXXXXX)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ swish: '0701234567' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.swish).toBe('0701234567')
|
|
})
|
|
|
|
it('strips whitespace and hyphens before validating', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ swish: '123 456 78 90' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.swish).toBe('1234567890')
|
|
})
|
|
|
|
it('rejects a non-Swish-företag, non-mobile number', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ swish: '0123456789' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts empty string for clearing the value', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ swish: '' })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts invoice_show_swish toggle', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ invoice_show_swish: false })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('send_invoice_reminders', () => {
|
|
it('accepts the kill-switch toggle', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ send_invoice_reminders: false })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('reminder day thresholds', () => {
|
|
it('accepts integer thresholds from 1 through 365', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
reminder_days_level_1: 7,
|
|
reminder_days_level_2: 21,
|
|
reminder_days_level_3: 365,
|
|
})
|
|
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it.each([0, 366, 1.5])('rejects invalid threshold %s', (days) => {
|
|
const result = UpdateSettingsSchema.safeParse({ reminder_days_level_1: days })
|
|
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('invoice_email_texts', () => {
|
|
it('accepts a valid nested partial', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_email_texts: { sv: { body: 'Tack för din beställning!' } },
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.invoice_email_texts).toEqual({
|
|
sv: { body: 'Tack för din beställning!' },
|
|
})
|
|
}
|
|
})
|
|
|
|
it('accepts both languages with all four fields', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_email_texts: {
|
|
sv: {
|
|
subject: 'Faktura {fakturanummer}',
|
|
greeting: 'Hejsan,',
|
|
body: 'Här kommer fakturan.',
|
|
signoff: 'Allt gott,',
|
|
},
|
|
en: {
|
|
subject: 'Invoice {fakturanummer}',
|
|
greeting: 'Hello,',
|
|
body: 'Please find the invoice attached.',
|
|
signoff: 'Best,',
|
|
},
|
|
},
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts null to clear all overrides', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ invoice_email_texts: null })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.invoice_email_texts).toBeNull()
|
|
})
|
|
|
|
it('rejects body over 2000 characters', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_email_texts: { sv: { body: 'x'.repeat(2001) } },
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects subject over 200 characters', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_email_texts: { sv: { subject: 'x'.repeat(201) } },
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects a non-string field value', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_email_texts: { sv: { subject: 123 } },
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('strips unknown keys inside a language object', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
invoice_email_texts: { sv: { body: 'Hej', subjct: 'typo' } },
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.invoice_email_texts).toEqual({ sv: { body: 'Hej' } })
|
|
}
|
|
})
|
|
|
|
it('rejects a bare string as the column value', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ invoice_email_texts: 'Tack!' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('reminder_text_overrides', () => {
|
|
it('accepts a valid nested partial', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
reminder_text_overrides: { level_2: { body: 'Betala nu, tack.' } },
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.reminder_text_overrides).toEqual({
|
|
level_2: { body: 'Betala nu, tack.' },
|
|
})
|
|
}
|
|
})
|
|
|
|
it('accepts all three levels with subject and body', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
reminder_text_overrides: {
|
|
level_1: { subject: 'Påminnelse: {fakturanummer}', body: 'Vänligen betala.' },
|
|
level_2: { subject: 'Andra påminnelsen', body: 'Betala omgående.' },
|
|
level_3: { subject: 'Inkassovarning', body: 'Sista påminnelsen innan inkasso.' },
|
|
},
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts null to clear all overrides', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ reminder_text_overrides: null })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.reminder_text_overrides).toBeNull()
|
|
})
|
|
|
|
it('rejects body over 2000 characters', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
reminder_text_overrides: { level_1: { body: 'x'.repeat(2001) } },
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects subject over 200 characters', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
reminder_text_overrides: { level_1: { subject: 'x'.repeat(201) } },
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects a non-string field value', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
reminder_text_overrides: { level_1: { subject: 123 } },
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('strips unknown keys inside a level object', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
reminder_text_overrides: { level_1: { body: 'Hej', subjct: 'typo' } },
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.reminder_text_overrides).toEqual({ level_1: { body: 'Hej' } })
|
|
}
|
|
})
|
|
|
|
it('rejects a bare string as the column value', () => {
|
|
const result = UpdateSettingsSchema.safeParse({ reminder_text_overrides: 'Betala!' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('default_voucher_series_per_source_type', () => {
|
|
it('accepts a partial map that omits source types (regression: Zod 4 enum-keyed z.record is exhaustive)', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
default_voucher_series_per_source_type: { manual: 'A', bank_transaction: 'C' },
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts an empty map', () => {
|
|
expect(
|
|
UpdateSettingsSchema.safeParse({ default_voucher_series_per_source_type: {} }).success,
|
|
).toBe(true)
|
|
})
|
|
|
|
it('rejects a series value that is not a single A-Z letter', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
default_voucher_series_per_source_type: { manual: 'ab' },
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects an unknown source_type key', () => {
|
|
const result = UpdateSettingsSchema.safeParse({
|
|
default_voucher_series_per_source_type: { not_a_source_type: 'A' },
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Fiscal period schemas
|
|
// ============================================================
|
|
|
|
describe('CreateFiscalPeriodSchema', () => {
|
|
it('accepts valid period', () => {
|
|
const result = CreateFiscalPeriodSchema.safeParse({
|
|
name: 'FY 2025',
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects end before start', () => {
|
|
const result = CreateFiscalPeriodSchema.safeParse({
|
|
name: 'FY 2025',
|
|
period_start: '2025-12-31',
|
|
period_end: '2025-01-01',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
expect(result.error.issues[0].message).toContain('before')
|
|
}
|
|
})
|
|
|
|
it('rejects same start and end date', () => {
|
|
const result = CreateFiscalPeriodSchema.safeParse({
|
|
name: 'FY 2025',
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-01-01',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty name', () => {
|
|
const result = CreateFiscalPeriodSchema.safeParse({
|
|
name: '',
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid date format', () => {
|
|
const result = CreateFiscalPeriodSchema.safeParse({
|
|
name: 'FY 2025',
|
|
period_start: 'Jan 1, 2025',
|
|
period_end: '2025-12-31',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Mapping rule schemas
|
|
// ============================================================
|
|
|
|
describe('CreateMappingRuleSchema', () => {
|
|
it('accepts valid rule', () => {
|
|
const result = CreateMappingRuleSchema.safeParse({
|
|
rule_name: 'Office rent',
|
|
rule_type: 'merchant_name',
|
|
merchant_pattern: 'Vasakronan',
|
|
debit_account: '5010',
|
|
credit_account: '1930',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts rule with all optional fields', () => {
|
|
const result = CreateMappingRuleSchema.safeParse({
|
|
rule_name: 'Restaurant meals',
|
|
rule_type: 'mcc_code',
|
|
priority: 5,
|
|
mcc_codes: ['5812', '5811'],
|
|
debit_account: '6071',
|
|
credit_account: '1930',
|
|
vat_treatment: 'reduced_12',
|
|
risk_level: 'LOW',
|
|
default_private: false,
|
|
requires_review: true,
|
|
confidence_score: 0.85,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing debit_account', () => {
|
|
const result = CreateMappingRuleSchema.safeParse({
|
|
rule_name: 'Test',
|
|
rule_type: 'merchant_name',
|
|
credit_account: '1930',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid account format', () => {
|
|
const result = CreateMappingRuleSchema.safeParse({
|
|
rule_name: 'Test',
|
|
rule_type: 'merchant_name',
|
|
debit_account: '50',
|
|
credit_account: '1930',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects confidence_score > 1', () => {
|
|
const result = CreateMappingRuleSchema.safeParse({
|
|
rule_name: 'Test',
|
|
rule_type: 'merchant_name',
|
|
debit_account: '5010',
|
|
credit_account: '1930',
|
|
confidence_score: 1.5,
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects negative confidence_score', () => {
|
|
const result = CreateMappingRuleSchema.safeParse({
|
|
rule_name: 'Test',
|
|
rule_type: 'merchant_name',
|
|
debit_account: '5010',
|
|
credit_account: '1930',
|
|
confidence_score: -0.1,
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Deadline schemas
|
|
// ============================================================
|
|
|
|
describe('CreateDeadlineSchema', () => {
|
|
it('accepts valid deadline', () => {
|
|
const result = CreateDeadlineSchema.safeParse({
|
|
title: 'Momsdeklaration Q1',
|
|
due_date: '2025-05-12',
|
|
deadline_type: 'tax',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts deadline with all optional fields', () => {
|
|
const result = CreateDeadlineSchema.safeParse({
|
|
title: 'Momsdeklaration Q1',
|
|
due_date: '2025-05-12',
|
|
due_time: '23:59',
|
|
deadline_type: 'tax',
|
|
priority: 'critical',
|
|
customer_id: validUuid,
|
|
notes: 'Submit via Skatteverket',
|
|
tax_deadline_type: 'moms_quarterly',
|
|
tax_period: '2025-Q1',
|
|
source: 'system',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing title', () => {
|
|
const result = CreateDeadlineSchema.safeParse({
|
|
due_date: '2025-05-12',
|
|
deadline_type: 'tax',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty title', () => {
|
|
const result = CreateDeadlineSchema.safeParse({
|
|
title: '',
|
|
due_date: '2025-05-12',
|
|
deadline_type: 'tax',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid due_time format', () => {
|
|
const result = CreateDeadlineSchema.safeParse({
|
|
title: 'Test',
|
|
due_date: '2025-05-12',
|
|
deadline_type: 'tax',
|
|
due_time: '25:00',
|
|
})
|
|
// Note: regex accepts 25:00: business logic validates actual time values
|
|
// This test documents the current behavior
|
|
const parsed = CreateDeadlineSchema.safeParse({
|
|
title: 'Test',
|
|
due_date: '2025-05-12',
|
|
deadline_type: 'tax',
|
|
due_time: 'noon',
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('accepts due_time with seconds', () => {
|
|
const result = CreateDeadlineSchema.safeParse({
|
|
title: 'Test',
|
|
due_date: '2025-05-12',
|
|
deadline_type: 'tax',
|
|
due_time: '23:59:59',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Account schemas
|
|
// ============================================================
|
|
|
|
describe('CreateAccountSchema', () => {
|
|
it('accepts valid BAS account', () => {
|
|
const result = CreateAccountSchema.safeParse({
|
|
account_number: '6200',
|
|
account_name: 'Telefon & internet',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts with optional plan_type and description', () => {
|
|
const result = CreateAccountSchema.safeParse({
|
|
account_number: '1510',
|
|
account_name: 'Kundfordringar',
|
|
account_type: 'asset',
|
|
normal_balance: 'debit',
|
|
plan_type: 'k1',
|
|
description: 'Accounts receivable from customers',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects non-4-digit account number', () => {
|
|
expect(CreateAccountSchema.safeParse({
|
|
account_number: '62',
|
|
account_name: 'Test',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
}).success).toBe(false)
|
|
|
|
expect(CreateAccountSchema.safeParse({
|
|
account_number: '62000',
|
|
account_name: 'Test',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
}).success).toBe(false)
|
|
})
|
|
|
|
it('rejects account number with letters', () => {
|
|
const result = CreateAccountSchema.safeParse({
|
|
account_number: '620A',
|
|
account_name: 'Test',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty account_name', () => {
|
|
const result = CreateAccountSchema.safeParse({
|
|
account_number: '6200',
|
|
account_name: '',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid account_type', () => {
|
|
const result = CreateAccountSchema.safeParse({
|
|
account_number: '6200',
|
|
account_name: 'Test',
|
|
account_type: 'cost',
|
|
normal_balance: 'debit',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Bank reconciliation schemas
|
|
// ============================================================
|
|
|
|
describe('BankLinkSchema', () => {
|
|
it('accepts valid link', () => {
|
|
const result = BankLinkSchema.safeParse({
|
|
transaction_id: validUuid,
|
|
journal_entry_id: validUuid,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing transaction_id', () => {
|
|
const result = BankLinkSchema.safeParse({ journal_entry_id: validUuid })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects missing journal_entry_id', () => {
|
|
const result = BankLinkSchema.safeParse({ transaction_id: validUuid })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-UUID values', () => {
|
|
const result = BankLinkSchema.safeParse({
|
|
transaction_id: 'txn-123',
|
|
journal_entry_id: 'je-456',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Report query schemas
|
|
// ============================================================
|
|
|
|
describe('VatDeclarationQuerySchema', () => {
|
|
it('accepts valid monthly query', () => {
|
|
const result = VatDeclarationQuerySchema.safeParse({
|
|
periodType: 'monthly',
|
|
year: '2025',
|
|
period: '3',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.year).toBe(2025)
|
|
expect(result.data.period).toBe(3)
|
|
}
|
|
})
|
|
|
|
it('accepts valid quarterly query', () => {
|
|
const result = VatDeclarationQuerySchema.safeParse({
|
|
periodType: 'quarterly',
|
|
year: '2025',
|
|
period: '2',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('coerces string numbers to numbers', () => {
|
|
const result = VatDeclarationQuerySchema.safeParse({
|
|
periodType: 'yearly',
|
|
year: '2025',
|
|
period: '1',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(typeof result.data.year).toBe('number')
|
|
expect(typeof result.data.period).toBe('number')
|
|
}
|
|
})
|
|
|
|
it('rejects year below 2000', () => {
|
|
const result = VatDeclarationQuerySchema.safeParse({
|
|
periodType: 'monthly',
|
|
year: '1999',
|
|
period: '1',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects year above 2100', () => {
|
|
const result = VatDeclarationQuerySchema.safeParse({
|
|
periodType: 'monthly',
|
|
year: '2101',
|
|
period: '1',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects period below 1', () => {
|
|
const result = VatDeclarationQuerySchema.safeParse({
|
|
periodType: 'monthly',
|
|
year: '2025',
|
|
period: '0',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects period above 12', () => {
|
|
const result = VatDeclarationQuerySchema.safeParse({
|
|
periodType: 'monthly',
|
|
year: '2025',
|
|
period: '13',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid periodType', () => {
|
|
const result = VatDeclarationQuerySchema.safeParse({
|
|
periodType: 'biweekly',
|
|
year: '2025',
|
|
period: '1',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('PaginationQuerySchema', () => {
|
|
it('accepts valid pagination', () => {
|
|
const result = PaginationQuerySchema.safeParse({ limit: '25', offset: '50' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.limit).toBe(25)
|
|
expect(result.data.offset).toBe(50)
|
|
}
|
|
})
|
|
|
|
it('applies defaults when empty', () => {
|
|
const result = PaginationQuerySchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.limit).toBe(50)
|
|
expect(result.data.offset).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('rejects limit above 100', () => {
|
|
const result = PaginationQuerySchema.safeParse({ limit: '101' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects limit below 1', () => {
|
|
const result = PaginationQuerySchema.safeParse({ limit: '0' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects negative offset', () => {
|
|
const result = PaginationQuerySchema.safeParse({ offset: '-1' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Update schemas (partial variants)
|
|
// ============================================================
|
|
|
|
describe('UpdateCustomerSchema', () => {
|
|
it('accepts empty update (all fields optional)', () => {
|
|
const result = UpdateCustomerSchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts partial update', () => {
|
|
const result = UpdateCustomerSchema.safeParse({ name: 'New Name' })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts full update (same as create)', () => {
|
|
const result = UpdateCustomerSchema.safeParse(validCustomer({
|
|
email: 'new@acme.se',
|
|
phone: '+46701111111',
|
|
}))
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects invalid email in partial update', () => {
|
|
const result = UpdateCustomerSchema.safeParse({ email: 'not-email' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid customer_type in partial update', () => {
|
|
const result = UpdateCustomerSchema.safeParse({ customer_type: 'government' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects an invalid customer invoice copy address', () => {
|
|
const result = UpdateCustomerSchema.safeParse({
|
|
invoice_email_cc_addresses: ['not-an-email'],
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('UpdateSupplierSchema', () => {
|
|
it('accepts empty update', () => {
|
|
const result = UpdateSupplierSchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts partial update', () => {
|
|
const result = UpdateSupplierSchema.safeParse({
|
|
name: 'New Supplier',
|
|
bankgiro: '999-8888',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
// Update routes pass fields straight into .update(), where undefined keys
|
|
// are dropped (unchanged) and null writes NULL. An empty string from a
|
|
// cleared form field must therefore become null, or clearing does nothing.
|
|
it('maps empty-string expense account to null so clearing persists', () => {
|
|
const result = UpdateSupplierSchema.safeParse({ default_expense_account: '' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.default_expense_account).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('maps empty-string email to null so clearing persists', () => {
|
|
const result = UpdateSupplierSchema.safeParse({ email: '' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.email).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('leaves omitted fields absent', () => {
|
|
const result = UpdateSupplierSchema.safeParse({ name: 'New Supplier' })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect('default_expense_account' in result.data).toBe(false)
|
|
expect('email' in result.data).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('still rejects a malformed expense account on update', () => {
|
|
const result = UpdateSupplierSchema.safeParse({ default_expense_account: '54' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid expense account format', () => {
|
|
const result = UpdateSupplierSchema.safeParse({ default_expense_account: '40' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid supplier_type', () => {
|
|
const result = UpdateSupplierSchema.safeParse({ supplier_type: 'individual' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('UpdateSupplierInvoiceSchema', () => {
|
|
it('accepts empty update', () => {
|
|
const result = UpdateSupplierInvoiceSchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts partial update with dates', () => {
|
|
const result = UpdateSupplierInvoiceSchema.safeParse({
|
|
due_date: '2025-04-30',
|
|
payment_reference: 'OCR-999',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts all fields', () => {
|
|
const result = UpdateSupplierInvoiceSchema.safeParse({
|
|
supplier_invoice_number: 'F-2025-002',
|
|
invoice_date: '2025-03-01',
|
|
due_date: '2025-04-01',
|
|
delivery_date: '2025-03-15',
|
|
payment_reference: 'REF-123',
|
|
notes: 'Updated notes',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects invalid date format', () => {
|
|
const result = UpdateSupplierInvoiceSchema.safeParse({ due_date: '2025/04/30' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty supplier_invoice_number', () => {
|
|
const result = UpdateSupplierInvoiceSchema.safeParse({ supplier_invoice_number: '' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('UpdateAccountSchema', () => {
|
|
it('accepts empty update', () => {
|
|
const result = UpdateAccountSchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts partial update', () => {
|
|
const result = UpdateAccountSchema.safeParse({
|
|
account_name: 'Nytt kontonamn',
|
|
is_active: false,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts nullable fields', () => {
|
|
const result = UpdateAccountSchema.safeParse({
|
|
description: null,
|
|
default_vat_code: null,
|
|
sru_code: null,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects empty account_name', () => {
|
|
const result = UpdateAccountSchema.safeParse({ account_name: '' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-boolean is_active', () => {
|
|
const result = UpdateAccountSchema.safeParse({ is_active: 'yes' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts a valid default_vat_rate (0/0.06/0.12/0.25/null)', () => {
|
|
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0 }).success).toBe(true)
|
|
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0.06 }).success).toBe(true)
|
|
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0.12 }).success).toBe(true)
|
|
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0.25 }).success).toBe(true)
|
|
expect(UpdateAccountSchema.safeParse({ default_vat_rate: null }).success).toBe(true)
|
|
})
|
|
|
|
it('rejects a default_vat_rate outside the allowed set', () => {
|
|
expect(UpdateAccountSchema.safeParse({ default_vat_rate: 0.2 }).success).toBe(false)
|
|
expect(CreateAccountSchema.safeParse({
|
|
account_number: '3740',
|
|
account_name: 'Öres- och kronutjämning',
|
|
account_type: 'revenue',
|
|
normal_balance: 'debit',
|
|
default_vat_rate: 0.5,
|
|
}).success).toBe(false)
|
|
})
|
|
|
|
it('accepts supported account VAT treatments and rejects unknown values', () => {
|
|
expect(UpdateAccountSchema.safeParse({
|
|
default_vat_treatment: 'reverse_charge_eu_goods',
|
|
}).success).toBe(true)
|
|
expect(UpdateAccountSchema.safeParse({
|
|
default_vat_treatment: 'reverse_charge_non_eu_services',
|
|
}).success).toBe(true)
|
|
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: null }).success).toBe(true)
|
|
expect(UpdateAccountSchema.safeParse({ default_vat_treatment: 'eu_purchase' }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Bank reconciliation new schemas
|
|
// ============================================================
|
|
|
|
describe('BankUnlinkSchema', () => {
|
|
it('accepts valid transaction_id', () => {
|
|
const result = BankUnlinkSchema.safeParse({ transaction_id: validUuid })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects missing transaction_id', () => {
|
|
const result = BankUnlinkSchema.safeParse({})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-UUID transaction_id', () => {
|
|
const result = BankUnlinkSchema.safeParse({ transaction_id: 'txn-123' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('RunReconciliationSchema', () => {
|
|
it('accepts empty object (all optional)', () => {
|
|
const result = RunReconciliationSchema.safeParse({})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts full options', () => {
|
|
const result = RunReconciliationSchema.safeParse({
|
|
date_from: '2025-01-01',
|
|
date_to: '2025-03-31',
|
|
dry_run: true,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts dry_run false', () => {
|
|
const result = RunReconciliationSchema.safeParse({ dry_run: false })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects invalid date_from format', () => {
|
|
const result = RunReconciliationSchema.safeParse({ date_from: '2025/01/01' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects non-boolean dry_run', () => {
|
|
const result = RunReconciliationSchema.safeParse({ dry_run: 'yes' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Correct journal entry schema
|
|
// ============================================================
|
|
|
|
describe('CorrectJournalEntrySchema', () => {
|
|
it('accepts valid correction with balanced lines', () => {
|
|
const result = CorrectJournalEntrySchema.safeParse({
|
|
lines: [
|
|
validJournalEntryLine({ account_number: '6200', debit_amount: 500, credit_amount: 0 }),
|
|
validJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 500 }),
|
|
],
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects single line (not double-entry)', () => {
|
|
const result = CorrectJournalEntrySchema.safeParse({
|
|
lines: [validJournalEntryLine()],
|
|
})
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
const linesError = result.error.issues.find(i => i.path.includes('lines'))
|
|
expect(linesError?.message).toContain('two lines')
|
|
}
|
|
})
|
|
|
|
it('rejects empty lines array', () => {
|
|
const result = CorrectJournalEntrySchema.safeParse({ lines: [] })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects missing lines', () => {
|
|
const result = CorrectJournalEntrySchema.safeParse({})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid account number in lines', () => {
|
|
const result = CorrectJournalEntrySchema.safeParse({
|
|
lines: [
|
|
validJournalEntryLine({ account_number: '62' }),
|
|
validJournalEntryLine({ account_number: '1930' }),
|
|
],
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('accepts an optional description and trims it', () => {
|
|
const result = CorrectJournalEntrySchema.safeParse({
|
|
description: ' Rättelse: Skulder till närstående personer, kortfristig del ',
|
|
lines: [
|
|
validJournalEntryLine({ account_number: '6200', debit_amount: 500, credit_amount: 0 }),
|
|
validJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 500 }),
|
|
],
|
|
})
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.description).toBe('Rättelse: Skulder till närstående personer, kortfristig del')
|
|
}
|
|
})
|
|
|
|
it('rejects a blank description (omit it to use the server fallback)', () => {
|
|
const result = CorrectJournalEntrySchema.safeParse({
|
|
description: ' ',
|
|
lines: [
|
|
validJournalEntryLine({ account_number: '6200', debit_amount: 500, credit_amount: 0 }),
|
|
validJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 500 }),
|
|
],
|
|
})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Evaluate mapping rules schema
|
|
// ============================================================
|
|
|
|
describe('EvaluateMappingRulesSchema', () => {
|
|
it('accepts valid transaction_id', () => {
|
|
const result = EvaluateMappingRulesSchema.safeParse({ transaction_id: validUuid })
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts raw transaction data with amount', () => {
|
|
const result = EvaluateMappingRulesSchema.safeParse({
|
|
description: 'Office supplies',
|
|
amount: -500,
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts raw data with all fields', () => {
|
|
const result = EvaluateMappingRulesSchema.safeParse({
|
|
description: 'Spotify',
|
|
amount: -129,
|
|
merchant_name: 'Spotify AB',
|
|
mcc_code: '5815',
|
|
date: '2025-03-15',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects non-UUID transaction_id', () => {
|
|
// First branch fails (invalid UUID), second branch matches only if amount is present
|
|
const result = EvaluateMappingRulesSchema.safeParse({ transaction_id: 'not-uuid' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects empty object (no transaction_id and no amount)', () => {
|
|
const result = EvaluateMappingRulesSchema.safeParse({})
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('rejects missing amount in raw data', () => {
|
|
const result = EvaluateMappingRulesSchema.safeParse({ description: 'Test' })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Cross-schema consistency tests
|
|
// ============================================================
|
|
|
|
describe('Cross-schema consistency', () => {
|
|
it('account_number format is enforced identically across schemas', () => {
|
|
// All schemas that accept account_number should use the same 4-digit rule
|
|
const invalidAccounts = ['12', '123', '12345', 'ABCD', '1a3b', '']
|
|
|
|
for (const acct of invalidAccounts) {
|
|
// Journal entry line
|
|
expect(CreateJournalEntryLineSchema.safeParse(
|
|
validJournalEntryLine({ account_number: acct })
|
|
).success).toBe(false)
|
|
|
|
// Supplier invoice item
|
|
expect(CreateSupplierInvoiceItemSchema.safeParse(
|
|
validSupplierInvoiceItem({ account_number: acct })
|
|
).success).toBe(false)
|
|
|
|
// Account creation
|
|
expect(CreateAccountSchema.safeParse({
|
|
account_number: acct,
|
|
account_name: 'Test',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
}).success).toBe(false)
|
|
|
|
// Mapping rule accounts
|
|
expect(CreateMappingRuleSchema.safeParse({
|
|
rule_name: 'Test',
|
|
rule_type: 'merchant_name',
|
|
debit_account: acct,
|
|
credit_account: '1930',
|
|
}).success).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('date format is enforced identically across schemas', () => {
|
|
const invalidDates = ['2025/03/15', '15-03-2025', 'Mar 15 2025', '2025-3-15', '']
|
|
|
|
for (const date of invalidDates) {
|
|
expect(CreateInvoiceSchema.safeParse(
|
|
validInvoice({ invoice_date: date })
|
|
).success).toBe(false)
|
|
|
|
expect(CreateFiscalPeriodSchema.safeParse({
|
|
name: 'Test', period_start: date, period_end: '2025-12-31',
|
|
}).success).toBe(false)
|
|
|
|
expect(CreateDeadlineSchema.safeParse({
|
|
title: 'Test', due_date: date, deadline_type: 'tax',
|
|
}).success).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('UUID format is enforced identically across schemas', () => {
|
|
const invalidUuids = ['not-a-uuid', '123', '', '550e8400-e29b-41d4-a716']
|
|
|
|
for (const id of invalidUuids) {
|
|
expect(CreateInvoiceSchema.safeParse(
|
|
validInvoice({ customer_id: id })
|
|
).success).toBe(false)
|
|
|
|
expect(MatchInvoiceSchema.safeParse({ invoice_id: id }).success).toBe(false)
|
|
|
|
expect(BankLinkSchema.safeParse({
|
|
transaction_id: id, journal_entry_id: validUuid,
|
|
}).success).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Error message quality tests
|
|
// ============================================================
|
|
|
|
describe('Error messages', () => {
|
|
it('provides field path in validation errors', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
|
items: [validInvoiceItem({ description: '' })],
|
|
}))
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
const issue = result.error.issues[0]
|
|
expect(issue.path).toContain('items')
|
|
}
|
|
})
|
|
|
|
it('reports all errors, not just the first', () => {
|
|
const result = CreateInvoiceSchema.safeParse({
|
|
// Missing everything
|
|
})
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
// Should report errors for customer_id, invoice_date, due_date, currency, items
|
|
expect(result.error.issues.length).toBeGreaterThanOrEqual(4)
|
|
}
|
|
})
|
|
|
|
it('custom messages are human-readable', () => {
|
|
const result = CreateInvoiceSchema.safeParse(validInvoice({ items: [] }))
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
const msg = result.error.issues[0].message
|
|
expect(msg).toMatch(/item/i)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Integration with existing fixture factories
|
|
// ============================================================
|
|
|
|
describe('Integration with test helpers', () => {
|
|
// These tests demonstrate that Zod schemas align with the fixture factories
|
|
// from tests/helpers.ts, ensuring schema and test data stay in sync.
|
|
|
|
it('CreateCustomerSchema matches makeCustomer() shape', () => {
|
|
// Simulate the shape produced by makeCustomer()
|
|
const customerData = {
|
|
name: 'Test Customer 1',
|
|
customer_type: 'swedish_business',
|
|
email: 'customer-1@test.com',
|
|
phone: '+46701234567',
|
|
address_line1: 'Testgatan 1',
|
|
postal_code: '111 22',
|
|
city: 'Stockholm',
|
|
country: 'SE',
|
|
default_payment_terms: 30,
|
|
}
|
|
const result = CreateCustomerSchema.safeParse(customerData)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('CreateSupplierSchema matches makeSupplier() shape', () => {
|
|
const supplierData = {
|
|
name: 'Test Supplier 1',
|
|
supplier_type: 'swedish_business',
|
|
email: 'supplier-1@test.com',
|
|
default_expense_account: '4010',
|
|
default_payment_terms: 30,
|
|
default_currency: 'SEK',
|
|
}
|
|
const result = CreateSupplierSchema.safeParse(supplierData)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('CreateJournalEntrySchema validates balanced entries from fixture', () => {
|
|
const entryInput = {
|
|
fiscal_period_id: validUuid,
|
|
entry_date: '2025-01-15',
|
|
description: 'Test entry',
|
|
source_type: 'manual',
|
|
lines: [
|
|
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 8000 },
|
|
{ account_number: '2611', debit_amount: 0, credit_amount: 2000 },
|
|
],
|
|
}
|
|
const result = CreateJournalEntrySchema.safeParse(entryInput)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('CreateInvoiceSchema matches makeInvoice() shape', () => {
|
|
const invoiceData = {
|
|
customer_id: validUuid,
|
|
invoice_date: '2025-01-15',
|
|
due_date: '2025-02-14',
|
|
currency: 'SEK',
|
|
items: [
|
|
{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 },
|
|
],
|
|
}
|
|
const result = CreateInvoiceSchema.safeParse(invoiceData)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('CreateSupplierInvoiceSchema matches makeSupplierInvoice() shape', () => {
|
|
const supplierInvoiceData = {
|
|
supplier_id: validUuid,
|
|
supplier_invoice_number: 'F-2025-001',
|
|
invoice_date: '2025-01-15',
|
|
due_date: '2025-02-14',
|
|
items: [
|
|
{ description: 'Materials', amount: 5000, account_number: '4010' },
|
|
],
|
|
}
|
|
const result = CreateSupplierInvoiceSchema.safeParse(supplierInvoiceData)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Employee bank-account validation (CreateEmployeeSchema)
|
|
// ============================================================
|
|
|
|
describe('CreateEmployeeSchema bank details', () => {
|
|
const baseEmployee = {
|
|
first_name: 'Anna',
|
|
last_name: 'Andersson',
|
|
personnummer: '199001011234',
|
|
employment_type: 'employee' as const,
|
|
employment_start: '2026-01-01',
|
|
salary_type: 'monthly' as const,
|
|
monthly_salary: 30000,
|
|
f_skatt_status: 'a_skatt' as const,
|
|
is_sidoinkomst: false,
|
|
tax_table_number: 33,
|
|
tax_municipality: 'Stockholm',
|
|
}
|
|
|
|
it('accepts an employee with no bank details', () => {
|
|
const result = CreateEmployeeSchema.safeParse(baseEmployee)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts a valid clearing + account pair', () => {
|
|
const result = CreateEmployeeSchema.safeParse({
|
|
...baseEmployee,
|
|
clearing_number: '6000',
|
|
bank_account_number: '1234567',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('accepts a 5-digit Swedbank clearing', () => {
|
|
const result = CreateEmployeeSchema.safeParse({
|
|
...baseEmployee,
|
|
clearing_number: '83279',
|
|
bank_account_number: '1234567',
|
|
})
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects a malformed clearing number', () => {
|
|
const result = CreateEmployeeSchema.safeParse({
|
|
...baseEmployee,
|
|
clearing_number: '12',
|
|
bank_account_number: '1234567',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
expect(result.error.issues.some((i) => i.path.includes('clearing_number'))).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects a clearing without an account', () => {
|
|
const result = CreateEmployeeSchema.safeParse({
|
|
...baseEmployee,
|
|
clearing_number: '6000',
|
|
})
|
|
expect(result.success).toBe(false)
|
|
if (!result.success) {
|
|
expect(result.error.issues.some((i) => i.path.includes('bank_account_number'))).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('CreateRecurringScheduleSchema send_hour', () => {
|
|
const base = {
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Retainer',
|
|
day_of_month: 15,
|
|
items: [{ description: 'Service', quantity: 1, unit_price: 1000 }],
|
|
}
|
|
|
|
it('defaults send_hour to 8 when omitted', () => {
|
|
const result = CreateRecurringScheduleSchema.safeParse(base)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.send_hour).toBe(8)
|
|
})
|
|
|
|
it('accepts a valid send_hour', () => {
|
|
const result = CreateRecurringScheduleSchema.safeParse({ ...base, send_hour: 14 })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.send_hour).toBe(14)
|
|
})
|
|
|
|
it('rejects an out-of-range send_hour', () => {
|
|
expect(CreateRecurringScheduleSchema.safeParse({ ...base, send_hour: 24 }).success).toBe(false)
|
|
expect(CreateRecurringScheduleSchema.safeParse({ ...base, send_hour: -1 }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('CreateRecurringScheduleSchema interval_months', () => {
|
|
const base = {
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Retainer',
|
|
day_of_month: 15,
|
|
items: [{ description: 'Service', quantity: 1, unit_price: 1000 }],
|
|
}
|
|
|
|
it('defaults interval_months to 1 (monthly) when omitted', () => {
|
|
const result = CreateRecurringScheduleSchema.safeParse(base)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.interval_months).toBe(1)
|
|
})
|
|
|
|
it('accepts quarterly, half-yearly and yearly intervals', () => {
|
|
for (const interval of [3, 6, 12]) {
|
|
const result = CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: interval })
|
|
expect(result.success).toBe(true)
|
|
if (result.success) expect(result.data.interval_months).toBe(interval)
|
|
}
|
|
})
|
|
|
|
it('rejects out-of-range or fractional intervals', () => {
|
|
expect(CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: 0 }).success).toBe(false)
|
|
expect(CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: 13 }).success).toBe(false)
|
|
expect(CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: 1.5 }).success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('CreateSalaryLineItemSchema: derived-only item types', () => {
|
|
it("rejects manual 'oresavrundning' lines (only the calculator may write them)", async () => {
|
|
const { CreateSalaryLineItemSchema, UpdateSalaryLineItemSchema } = await import('../schemas')
|
|
const base = {
|
|
salary_run_employee_id: '3f0a2f60-0000-4000-8000-000000000001',
|
|
description: 'Öresavrundning',
|
|
amount: 0.4,
|
|
}
|
|
expect(
|
|
CreateSalaryLineItemSchema.safeParse({ ...base, item_type: 'oresavrundning' }).success,
|
|
).toBe(false)
|
|
expect(
|
|
CreateSalaryLineItemSchema.safeParse({ ...base, item_type: 'bonus' }).success,
|
|
).toBe(true)
|
|
expect(UpdateSalaryLineItemSchema.safeParse({ item_type: 'oresavrundning' }).success).toBe(false)
|
|
expect(UpdateSalaryLineItemSchema.safeParse({ item_type: 'bonus' }).success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('HouseworkTypeSchema (articles.housework_type)', () => {
|
|
it('stores a Skatteverket work-type code upper-cased', () => {
|
|
expect(HouseworkTypeSchema.parse('stad')).toBe('STAD')
|
|
expect(HouseworkTypeSchema.parse('BYGG')).toBe('BYGG')
|
|
})
|
|
|
|
it('accepts the bare kind ROT / RUT (deduction only, no arbetstyp pre-fill)', () => {
|
|
expect(HouseworkTypeSchema.parse('rut')).toBe('RUT')
|
|
expect(HouseworkTypeSchema.parse('ROT')).toBe('ROT')
|
|
})
|
|
|
|
it('treats empty string as clear and passes null/undefined through', () => {
|
|
expect(HouseworkTypeSchema.parse('')).toBeNull()
|
|
expect(HouseworkTypeSchema.parse(' ')).toBeNull()
|
|
expect(HouseworkTypeSchema.parse(null)).toBeNull()
|
|
expect(HouseworkTypeSchema.parse(undefined)).toBeUndefined()
|
|
})
|
|
|
|
it('rejects values the invoice editor could never interpret', () => {
|
|
for (const bad of ['1', '0', 'Ja', 'SNICKERI', 'RUT-städning']) {
|
|
expect(HouseworkTypeSchema.safeParse(bad).success).toBe(false)
|
|
}
|
|
})
|
|
})
|