66a4027f1e
- Update BAS account catalog with comprehensive SRU codes and K2 flags - Add currency revaluation service with tests and API route - Add expenses page and account deletion API - Enhance booking templates with new patterns and improved tests - Improve transaction categorization with template picker and description matching - Polish dashboard, onboarding, import, and transaction UIs - Refactor year-end service for multi-step closing - Move SRU generator to ne-bilaga, remove standalone SRU export - Remove unused dev docs, mock data, and extension hooks - Add invoice delivery note sequences migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
534 lines
17 KiB
TypeScript
534 lines
17 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
// ============================================================
|
|
// Shared primitives
|
|
// ============================================================
|
|
|
|
/** UUID v4 string */
|
|
const uuid = z.string().uuid()
|
|
|
|
/** ISO date string (YYYY-MM-DD) */
|
|
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
|
|
|
|
/** BAS account number — always a string of 4 digits */
|
|
const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
|
|
|
|
/** Positive monetary amount (> 0) */
|
|
const positiveAmount = z.number().positive()
|
|
|
|
/** Non-negative monetary amount (>= 0) */
|
|
const nonNegativeAmount = z.number().nonnegative()
|
|
|
|
/** Time string (HH:MM or HH:MM:SS) */
|
|
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
|
|
|
|
// ============================================================
|
|
// Enum schemas (matching types/index.ts)
|
|
// ============================================================
|
|
|
|
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
|
|
|
|
export const CustomerTypeSchema = z.enum([
|
|
'individual',
|
|
'swedish_business',
|
|
'eu_business',
|
|
'non_eu_business',
|
|
])
|
|
|
|
export const SupplierTypeSchema = z.enum([
|
|
'swedish_business',
|
|
'eu_business',
|
|
'non_eu_business',
|
|
])
|
|
|
|
export const InvoiceStatusSchema = z.enum([
|
|
'draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited',
|
|
])
|
|
|
|
export const InvoiceDocumentTypeSchema = z.enum([
|
|
'invoice', 'proforma', 'delivery_note',
|
|
])
|
|
|
|
export const SupplierInvoiceStatusSchema = z.enum([
|
|
'registered', 'approved', 'paid', 'partially_paid', 'overdue', 'disputed', 'credited',
|
|
])
|
|
|
|
export const VatTreatmentSchema = z.enum([
|
|
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
|
|
])
|
|
|
|
export const AccountingMethodSchema = z.enum(['accrual', 'cash'])
|
|
|
|
export const CurrencySchema = z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
|
|
|
|
export const TransactionCategorySchema = z.enum([
|
|
'income_services',
|
|
'income_products',
|
|
'income_other',
|
|
'expense_equipment',
|
|
'expense_software',
|
|
'expense_travel',
|
|
'expense_office',
|
|
'expense_marketing',
|
|
'expense_professional_services',
|
|
'expense_education',
|
|
'expense_representation',
|
|
'expense_consumables',
|
|
'expense_vehicle',
|
|
'expense_telecom',
|
|
'expense_bank_fees',
|
|
'expense_card_fees',
|
|
'expense_currency_exchange',
|
|
'expense_other',
|
|
'private',
|
|
'uncategorized',
|
|
])
|
|
|
|
export const JournalEntrySourceTypeSchema = z.enum([
|
|
'manual',
|
|
'bank_transaction',
|
|
'invoice_created',
|
|
'invoice_paid',
|
|
'invoice_cash_payment',
|
|
'credit_note',
|
|
'salary_payment',
|
|
'opening_balance',
|
|
'year_end',
|
|
'storno',
|
|
'correction',
|
|
'import',
|
|
'system',
|
|
'supplier_invoice_registered',
|
|
'supplier_invoice_paid',
|
|
'supplier_invoice_cash_payment',
|
|
'supplier_credit_note',
|
|
'currency_revaluation',
|
|
])
|
|
|
|
export const AccountTypeSchema = z.enum([
|
|
'asset', 'equity', 'liability', 'revenue', 'expense',
|
|
])
|
|
|
|
export const NormalBalanceSchema = z.enum(['debit', 'credit'])
|
|
|
|
export const MappingRuleTypeSchema = z.enum([
|
|
'mcc_code', 'merchant_name', 'description_pattern', 'amount_threshold', 'combined',
|
|
])
|
|
|
|
export const RiskLevelSchema = z.enum(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH'])
|
|
|
|
export const DeadlineTypeSchema = z.enum([
|
|
'delivery', 'invoicing', 'report', 'tax', 'other',
|
|
])
|
|
|
|
export const DeadlinePrioritySchema = z.enum(['critical', 'important', 'normal'])
|
|
|
|
export const TaxDeadlineTypeSchema = z.enum([
|
|
'moms_monthly',
|
|
'moms_quarterly',
|
|
'moms_yearly',
|
|
'f_skatt',
|
|
'arbetsgivardeklaration',
|
|
'inkomstdeklaration_ef',
|
|
'inkomstdeklaration_ab',
|
|
'arsredovisning',
|
|
'periodisk_sammanstallning',
|
|
'bokslut',
|
|
])
|
|
|
|
export const DeadlineSourceSchema = z.enum(['system', 'user'])
|
|
|
|
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
|
|
|
|
export const DocumentUploadSourceSchema = z.enum([
|
|
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
|
|
])
|
|
|
|
// ============================================================
|
|
// Invoice schemas
|
|
// ============================================================
|
|
|
|
export const CreateInvoiceItemSchema = z.object({
|
|
description: z.string().min(1, 'Item description is required'),
|
|
quantity: z.number().positive('Quantity must be positive'),
|
|
unit: z.string().min(1, 'Unit is required'),
|
|
unit_price: z.number(),
|
|
vat_rate: z.number().min(0).max(100).optional(),
|
|
})
|
|
|
|
export const CreateInvoiceSchema = z.object({
|
|
customer_id: uuid,
|
|
invoice_date: isoDate,
|
|
due_date: isoDate,
|
|
currency: CurrencySchema,
|
|
document_type: InvoiceDocumentTypeSchema.optional(),
|
|
your_reference: z.string().optional(),
|
|
our_reference: z.string().optional(),
|
|
notes: z.string().optional(),
|
|
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
|
|
})
|
|
|
|
export const CreateCreditNoteSchema = z.object({
|
|
credited_invoice_id: uuid,
|
|
reason: z.string().optional(),
|
|
})
|
|
|
|
export const MarkInvoicePaidSchema = z.object({
|
|
payment_date: isoDate.optional(),
|
|
exchange_rate_difference: z.number().optional(),
|
|
notes: z.string().optional(),
|
|
})
|
|
|
|
// ============================================================
|
|
// Customer schemas
|
|
// ============================================================
|
|
|
|
export const CreateCustomerSchema = z.object({
|
|
name: z.string().min(1, 'Customer name is required'),
|
|
customer_type: CustomerTypeSchema,
|
|
email: z.string().email('Invalid email address').optional(),
|
|
phone: z.string().optional(),
|
|
address_line1: z.string().optional(),
|
|
address_line2: z.string().optional(),
|
|
postal_code: z.string().optional(),
|
|
city: z.string().optional(),
|
|
country: z.string().optional(),
|
|
org_number: z.string().optional(),
|
|
vat_number: z.string().optional(),
|
|
default_payment_terms: z.number().int().positive().optional(),
|
|
notes: z.string().optional(),
|
|
})
|
|
|
|
export const UpdateCustomerSchema = CreateCustomerSchema.partial()
|
|
|
|
// ============================================================
|
|
// Supplier schemas
|
|
// ============================================================
|
|
|
|
export const CreateSupplierSchema = z.object({
|
|
name: z.string().min(1, 'Supplier name is required'),
|
|
supplier_type: SupplierTypeSchema,
|
|
email: z.string().email('Invalid email address').optional(),
|
|
phone: z.string().optional(),
|
|
address_line1: z.string().optional(),
|
|
address_line2: z.string().optional(),
|
|
postal_code: z.string().optional(),
|
|
city: z.string().optional(),
|
|
country: z.string().optional(),
|
|
org_number: z.string().optional(),
|
|
vat_number: z.string().optional(),
|
|
bankgiro: z.string().optional(),
|
|
plusgiro: z.string().optional(),
|
|
bank_account: z.string().optional(),
|
|
iban: z.string().optional(),
|
|
bic: z.string().optional(),
|
|
default_expense_account: accountNumber.optional(),
|
|
default_payment_terms: z.number().int().positive().optional(),
|
|
default_currency: CurrencySchema.nullable().optional(),
|
|
notes: z.string().optional(),
|
|
})
|
|
|
|
export const UpdateSupplierSchema = CreateSupplierSchema.partial()
|
|
|
|
// ============================================================
|
|
// Supplier invoice schemas
|
|
// ============================================================
|
|
|
|
export const CreateSupplierInvoiceItemSchema = z.object({
|
|
description: z.string().min(1, 'Item description is required'),
|
|
amount: z.number().optional(),
|
|
account_number: accountNumber,
|
|
vat_rate: z.number().min(0).max(100).optional(),
|
|
vat_code: z.string().optional(),
|
|
quantity: z.number().optional(),
|
|
unit: z.string().optional(),
|
|
unit_price: z.number().optional(),
|
|
})
|
|
|
|
export const CreateSupplierInvoiceSchema = z.object({
|
|
supplier_id: uuid,
|
|
supplier_invoice_number: z.string().min(1, 'Supplier invoice number is required'),
|
|
invoice_date: isoDate,
|
|
due_date: isoDate,
|
|
delivery_date: isoDate.optional(),
|
|
currency: CurrencySchema.optional(),
|
|
exchange_rate: z.number().positive().optional(),
|
|
vat_treatment: VatTreatmentSchema.optional(),
|
|
reverse_charge: z.boolean().optional(),
|
|
payment_reference: z.string().optional(),
|
|
notes: z.string().optional(),
|
|
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
|
|
})
|
|
|
|
export const MarkSupplierInvoicePaidSchema = z.object({
|
|
amount: z.number().positive().optional(),
|
|
payment_date: isoDate.optional(),
|
|
exchange_rate_difference: z.number().optional(),
|
|
notes: z.string().optional(),
|
|
})
|
|
|
|
export const UpdateSupplierInvoiceSchema = z.object({
|
|
supplier_invoice_number: z.string().min(1).optional(),
|
|
invoice_date: isoDate.optional(),
|
|
due_date: isoDate.optional(),
|
|
delivery_date: isoDate.optional(),
|
|
payment_reference: z.string().optional(),
|
|
notes: z.string().optional(),
|
|
})
|
|
|
|
// ============================================================
|
|
// Journal entry schemas
|
|
// ============================================================
|
|
|
|
export const CreateJournalEntryLineSchema = z.object({
|
|
account_number: accountNumber,
|
|
debit_amount: nonNegativeAmount.default(0),
|
|
credit_amount: nonNegativeAmount.default(0),
|
|
line_description: z.string().optional(),
|
|
currency: z.string().optional(),
|
|
amount_in_currency: z.number().optional(),
|
|
exchange_rate: z.number().positive().optional(),
|
|
tax_code: z.string().optional(),
|
|
cost_center: z.string().optional(),
|
|
project: z.string().optional(),
|
|
})
|
|
|
|
export const CreateJournalEntrySchema = z.object({
|
|
fiscal_period_id: uuid,
|
|
entry_date: isoDate,
|
|
description: z.string().min(1, 'Description is required'),
|
|
source_type: JournalEntrySourceTypeSchema.default('manual'),
|
|
source_id: z.string().optional(),
|
|
voucher_series: z.string().optional(),
|
|
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
|
|
})
|
|
|
|
export const CorrectJournalEntrySchema = z.object({
|
|
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
|
|
})
|
|
|
|
// ============================================================
|
|
// Transaction schemas
|
|
// ============================================================
|
|
|
|
export const CategorizeTransactionSchema = z.object({
|
|
is_business: z.boolean(),
|
|
category: TransactionCategorySchema.optional(),
|
|
template_id: z.string().optional(),
|
|
vat_treatment: VatTreatmentSchema.optional(),
|
|
account_override: accountNumber.optional(),
|
|
user_description: z.string().max(500).optional(),
|
|
inbox_item_id: z.string().uuid().optional(),
|
|
})
|
|
|
|
export const BookTransactionSchema = z.object({
|
|
fiscal_period_id: uuid,
|
|
entry_date: isoDate,
|
|
description: z.string().min(1, 'Description is required'),
|
|
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
|
|
})
|
|
|
|
export const MatchInvoiceSchema = z.object({
|
|
invoice_id: uuid,
|
|
})
|
|
|
|
export const MatchSupplierInvoiceSchema = z.object({
|
|
supplier_invoice_id: uuid,
|
|
})
|
|
|
|
export const DescribeTransactionSchema = z.object({
|
|
description: z.string().min(3).max(500),
|
|
})
|
|
|
|
export const BatchDescribeSchema = z.object({
|
|
merchant_name: z.string().min(1),
|
|
template_id: z.string().min(1),
|
|
is_business: z.boolean(),
|
|
user_description: z.string().max(500).optional(),
|
|
})
|
|
|
|
// ============================================================
|
|
// Settings schemas
|
|
// ============================================================
|
|
|
|
export const UpdateSettingsSchema = z.object({
|
|
entity_type: EntityTypeSchema.optional(),
|
|
company_name: z.string().optional(),
|
|
org_number: z.string().optional(),
|
|
address_line1: z.string().optional(),
|
|
address_line2: z.string().optional(),
|
|
postal_code: z.string().optional(),
|
|
city: z.string().optional(),
|
|
country: z.string().optional(),
|
|
f_skatt: z.boolean().optional(),
|
|
vat_registered: z.boolean().optional(),
|
|
vat_number: z.string().optional(),
|
|
moms_period: MomsPeriodSchema.nullable().optional(),
|
|
fiscal_year_start_month: z.number().int().min(1).max(12).optional(),
|
|
preliminary_tax_monthly: z.number().nullable().optional(),
|
|
bank_name: z.string().optional(),
|
|
clearing_number: z.string().optional(),
|
|
account_number: z.string().optional(),
|
|
iban: z.string().optional(),
|
|
bic: z.string().optional(),
|
|
accounting_method: AccountingMethodSchema.optional(),
|
|
invoice_prefix: z.string().nullable().optional(),
|
|
next_invoice_number: z.number().int().positive().optional(),
|
|
invoice_default_days: z.number().int().positive().optional(),
|
|
invoice_default_notes: z.string().nullable().optional(),
|
|
email: z.string().email().optional(),
|
|
pays_salaries: z.boolean().optional(),
|
|
sector_slug: z.string().nullable().optional(),
|
|
}).refine(
|
|
(data) => {
|
|
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
|
if (data.entity_type === 'enskild_firma' && data.fiscal_year_start_month !== undefined) {
|
|
return data.fiscal_year_start_month === 1
|
|
}
|
|
return true
|
|
},
|
|
{
|
|
message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)',
|
|
path: ['fiscal_year_start_month'],
|
|
}
|
|
)
|
|
|
|
// ============================================================
|
|
// Fiscal period schemas
|
|
// ============================================================
|
|
|
|
export const CreateFiscalPeriodSchema = z.object({
|
|
name: z.string().min(1, 'Period name is required'),
|
|
period_start: isoDate,
|
|
period_end: isoDate,
|
|
}).refine(
|
|
(data) => data.period_start < data.period_end,
|
|
{
|
|
message: 'Period start must be before period end',
|
|
path: ['period_end'],
|
|
}
|
|
)
|
|
|
|
// ============================================================
|
|
// Mapping rule schemas
|
|
// ============================================================
|
|
|
|
export const CreateMappingRuleSchema = z.object({
|
|
rule_name: z.string().min(1, 'Rule name is required'),
|
|
rule_type: MappingRuleTypeSchema,
|
|
priority: z.number().int().min(0).optional(),
|
|
mcc_codes: z.array(z.string()).optional(),
|
|
merchant_pattern: z.string().optional(),
|
|
description_pattern: z.string().optional(),
|
|
amount_min: z.number().optional(),
|
|
amount_max: z.number().optional(),
|
|
debit_account: accountNumber,
|
|
credit_account: accountNumber,
|
|
vat_treatment: z.string().optional(),
|
|
risk_level: RiskLevelSchema.optional(),
|
|
default_private: z.boolean().optional(),
|
|
requires_review: z.boolean().optional(),
|
|
confidence_score: z.number().min(0).max(1).optional(),
|
|
})
|
|
|
|
export const EvaluateMappingRulesSchema = z.union([
|
|
z.object({ transaction_id: uuid }),
|
|
z.object({
|
|
description: z.string().optional(),
|
|
amount: z.number(),
|
|
}).passthrough(),
|
|
])
|
|
|
|
// ============================================================
|
|
// Deadline schemas
|
|
// ============================================================
|
|
|
|
export const CreateDeadlineSchema = z.object({
|
|
title: z.string().min(1, 'Title is required'),
|
|
due_date: isoDate,
|
|
due_time: timeString.optional(),
|
|
deadline_type: DeadlineTypeSchema,
|
|
priority: DeadlinePrioritySchema.optional(),
|
|
customer_id: uuid.optional(),
|
|
notes: z.string().optional(),
|
|
tax_deadline_type: TaxDeadlineTypeSchema.optional(),
|
|
tax_period: z.string().optional(),
|
|
source: DeadlineSourceSchema.optional(),
|
|
linked_report_type: z.string().optional(),
|
|
linked_report_period: z.record(z.string(), z.unknown()).optional(),
|
|
})
|
|
|
|
// ============================================================
|
|
// Account schemas
|
|
// ============================================================
|
|
|
|
export const CreateAccountSchema = z.object({
|
|
account_number: accountNumber,
|
|
account_name: z.string().min(1, 'Account name is required'),
|
|
account_type: AccountTypeSchema,
|
|
normal_balance: NormalBalanceSchema,
|
|
plan_type: z.enum(['k1', 'full_bas']).optional(),
|
|
description: z.string().optional(),
|
|
})
|
|
|
|
export const UpdateAccountSchema = z.object({
|
|
account_name: z.string().min(1).optional(),
|
|
is_active: z.boolean().optional(),
|
|
description: z.string().nullable().optional(),
|
|
default_vat_code: z.string().nullable().optional(),
|
|
sru_code: z.string().nullable().optional(),
|
|
})
|
|
|
|
// ============================================================
|
|
// Bank reconciliation schemas
|
|
// ============================================================
|
|
|
|
export const BankLinkSchema = z.object({
|
|
transaction_id: uuid,
|
|
journal_entry_id: uuid,
|
|
})
|
|
|
|
export const BankUnlinkSchema = z.object({
|
|
transaction_id: uuid,
|
|
})
|
|
|
|
export const RunReconciliationSchema = z.object({
|
|
date_from: isoDate.optional(),
|
|
date_to: isoDate.optional(),
|
|
dry_run: z.boolean().optional(),
|
|
})
|
|
|
|
// ============================================================
|
|
// Report query schemas
|
|
// ============================================================
|
|
|
|
export const VatDeclarationQuerySchema = z.object({
|
|
periodType: z.enum(['monthly', 'quarterly', 'yearly']),
|
|
year: z.coerce.number().int().min(2000).max(2100),
|
|
period: z.coerce.number().int().min(1).max(12),
|
|
})
|
|
|
|
export const ReportPeriodQuerySchema = z.object({
|
|
fiscal_period_id: uuid.optional(),
|
|
year: z.coerce.number().int().min(2000).max(2100).optional(),
|
|
month: z.coerce.number().int().min(1).max(12).optional(),
|
|
})
|
|
|
|
// ============================================================
|
|
// VAT validation schemas
|
|
// ============================================================
|
|
|
|
export const ValidateVatNumberSchema = z.object({
|
|
vat_number: z.string().min(4, 'VAT number must be at least 4 characters'),
|
|
customer_id: uuid.optional(),
|
|
})
|
|
|
|
// ============================================================
|
|
// Pagination schemas
|
|
// ============================================================
|
|
|
|
export const PaginationQuerySchema = z.object({
|
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
offset: z.coerce.number().int().nonnegative().default(0),
|
|
})
|