Files
accounted/lib/api/schemas.ts
T
Mattsson 1af977950b Ai/full autonomous flow (#359)
* Refactor bookkeeping error handling and introduce new error classes

- Introduced new error classes for better error categorization:
  - JournalEntryNotBalancedError
  - FiscalPeriodNotFoundError
  - EntryDateOutsideFiscalPeriodError
  - JournalEntryNotFoundError
  - CannotReverseNonPostedError
  - CannotCorrectNonPostedError
  - EntryAlreadyReversedError
  - CurrencyRevaluationAlreadyExistsError
  - InvalidMappingResultError
  - BookkeepingDatabaseError

- Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones.
- Enhanced error response handling in get-error-message.ts to provide localized messages for new error types.
- Added unit tests for new error classes and error handling functions to ensure correctness and coverage.

* feat(ai): implement AI proposal application and persistence

- Add apply.ts to handle the application of AI proposals, including match and booking steps.
- Introduce persist.ts for inserting and managing AI requests and proposals, ensuring unique constraints.
- Create re-validate.ts for validating proposals before acceptance, checking for stale conditions.
- Define database migrations for ai_requests and ai_proposals tables, including constraints and indexes.
- Enhance journal_entries with AI provenance tracking, linking entries to AI proposals.
- Update categorization_templates to distinguish AI-corrected templates.
- Add company settings for toggling AI flow and managing backfill processes.
- Extend processing_history to include AI-related events for better tracking.

* feat: add uncategorized transactions API and UI for transaction selection

- Implemented a new API endpoint for fetching uncategorized transactions with pagination and filtering options.
- Created ChangeTransactionDialog component for selecting alternative transactions based on AI proposals.
- Developed ReceiptDetailDialog to display detailed information about receipts, including upload functionality.
- Added TransactionDetailDialog for viewing transaction details with links to the transaction list.
- Introduced receipt quality assessment logic to evaluate extracted receipt data.
- Implemented feature flagging for the AI bookkeeping agent to control availability in different environments.

* feat: add manual receipt extraction dialog and integrate AWS Textract for expense analysis

- Added ManualExtractDialog component for user input when AI fails to extract receipt data.
- Implemented ReceiptsList component to manage and display uploaded receipts, including upload and rescan functionalities.
- Introduced Textract integration for analyzing expenses, extracting fields like total, vendor, and date.
- Updated package.json to include @aws-sdk/client-textract dependency.

* fix(ai): handle livsmedel VAT transition (12% → 6%) in booking prompt and re-validate guard

Add date-aware guidance to BOOKING_SYSTEM_PROMPT for the temporary livsmedel
VAT cut (Prop. 2025/26:55, 2026-04-01 to 2027-12-31), with restaurang/servering
carve-out at 12%. Add a re-validate safety net that rejects clearly-stale rate
labels for grocery-chain merchants relative to the entry date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:32:15 +02:00

857 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
/** 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(),
})
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
export const CreateInvoiceSchema = z.object({
customer_id: uuid,
invoice_date: isoDate,
due_date: isoDate,
delivery_date: optionalIsoDate,
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(),
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).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: optionalIsoDate,
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: optionalIsoDate,
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().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav AZ').optional(),
notes: z.string().max(2000).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(),
counterparty_template_id: z.string().uuid().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,
})
// ============================================================
// Settings schemas
// ============================================================
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
trade_name: z.string().nullable().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().regex(/^SE\d{12}$/, 'Momsregistreringsnummer måste vara SE följt av 12 siffror').nullable().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().max(100, 'Banknamn får vara max 100 tecken').optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').optional().or(z.literal('')),
bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer (7-8 siffror)').nullable().optional().or(z.literal('')),
plusgiro: z.string().regex(/^\d{1,7}-\d{1}$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')),
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(),
phone: z.string().optional(),
email: z.string().email().optional().or(z.literal('')),
website: z.string().optional().or(z.literal('')),
pays_salaries: z.boolean().optional(),
sector_slug: z.string().nullable().optional(),
// Bookkeeping lock
bookkeeping_locked_through: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Ogiltigt datumformat (YYYY-MM-DD)').nullable().optional(),
auto_lock_period_days: z.number().int().positive().nullable().optional(),
// Voucher series
default_voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav AZ').optional(),
// Invoice PDF settings
ore_rounding: z.boolean().optional(),
invoice_show_ocr: z.boolean().optional(),
invoice_show_bankgiro: z.boolean().optional(),
invoice_show_plusgiro: z.boolean().optional(),
invoice_late_fee_text: z.string().nullable().optional(),
invoice_credit_terms_text: z.string().nullable().optional(),
// AI agent flow
ai_flow_enabled: z.boolean().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'],
}
).refine(
(data) => {
// BFNAR 2006:1: Aktiebolag must use accrual accounting (faktureringsmetoden)
if (data.entity_type === 'aktiebolag' && data.accounting_method !== undefined) {
return data.accounting_method === 'accrual'
}
return true
},
{
message: 'Aktiebolag måste använda faktureringsmetoden (BFNAR 2006:1)',
path: ['accounting_method'],
}
)
// ============================================================
// 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.nullish(),
deadline_type: DeadlineTypeSchema,
priority: DeadlinePrioritySchema.nullish(),
customer_id: uuid.nullish(),
notes: z.string().nullish(),
tax_deadline_type: TaxDeadlineTypeSchema.nullish(),
tax_period: z.string().nullish(),
source: DeadlineSourceSchema.optional(),
linked_report_type: z.string().nullish(),
linked_report_period: z.record(z.string(), z.unknown()).nullish(),
})
// ============================================================
// 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().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
sru_code: z.string().nullable().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),
})
// ============================================================
// Event log schemas
// ============================================================
export const EventsQuerySchema = z.object({
after: z.coerce.number().int().nonnegative().optional(),
types: z.string()
.transform(s => s.split(',').map(t => t.trim()).filter(Boolean))
.optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
})
// ============================================================
// Pending operations schemas
// ============================================================
export const PendingOperationsQuerySchema = z.object({
status: z.enum(['pending', 'committed', 'rejected']).default('pending'),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().nonnegative().default(0),
})
// ============================================================
// Voucher gap schemas
// ============================================================
export const VoucherGapQuerySchema = z.object({
fiscal_period_id: uuid,
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav AZ').optional(),
})
export const SaveGapExplanationSchema = z.object({
fiscal_period_id: uuid,
voucher_series: z.string().default('A'),
gap_start: z.number().int().positive(),
gap_end: z.number().int().positive(),
explanation: z.string().min(1).max(500),
})
// ============================================================
// Opening balance import schemas
// ============================================================
export const OpeningBalanceExecuteSchema = z.object({
fiscal_period_id: uuid,
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount,
credit_amount: nonNegativeAmount,
})).min(2, 'At least two lines are required for double-entry'),
})
// ============================================================
// Salary schemas
// ============================================================
export const EmploymentTypeSchema = z.enum(['employee', 'company_owner', 'board_member'])
export const SalaryTypeSchema = z.enum(['monthly', 'hourly'])
export const FSkattStatusSchema = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'])
export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln'])
export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected'])
export const SalaryLineItemTypeSchema = z.enum([
'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission',
'gross_deduction_pension', 'gross_deduction_other',
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other',
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
'vab', 'parental_leave', 'vacation',
'traktamente_taxfree', 'traktamente_taxable',
'mileage_taxfree', 'mileage_taxable',
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
'net_deduction_other',
'correction', 'other',
])
// Base employee object (no refinements — safe for .partial())
const EmployeeSchemaBase = z.object({
first_name: z.string().min(1).max(200),
last_name: z.string().min(1).max(200),
personnummer: z.string().regex(/^\d{12}$/, 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)'),
employment_type: EmploymentTypeSchema.default('employee'),
employment_start: isoDate,
employment_end: isoDate.optional(),
employment_degree: z.number().min(1).max(100).default(100),
salary_type: SalaryTypeSchema.default('monthly'),
monthly_salary: z.number().nonnegative().optional(),
hourly_rate: z.number().nonnegative().optional(),
tax_table_number: z.number().int().min(29).max(42).optional(),
tax_column: z.number().int().min(1).max(6).default(1),
tax_municipality: z.string().max(100).optional(),
is_sidoinkomst: z.boolean().default(false),
f_skatt_status: FSkattStatusSchema.default('a_skatt'),
clearing_number: z.string().max(10).optional(),
bank_account_number: z.string().max(20).optional(),
vacation_rule: VacationRuleSchema.default('procentregeln'),
vacation_days_per_year: z.number().int().min(25).max(40).default(25),
semestertillagg_rate: z.number().min(0).max(0.05).default(0.0043),
email: z.string().email().optional(),
phone: z.string().max(20).optional(),
address_line1: z.string().max(200).optional(),
postal_code: z.string().max(10).optional(),
city: z.string().max(100).optional(),
vaxa_stod_eligible: z.boolean().default(false),
vaxa_stod_start: isoDate.optional(),
vaxa_stod_end: isoDate.optional(),
})
export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) => {
// Salary amount required based on salary_type
if (data.salary_type === 'monthly' && (data.monthly_salary === undefined || data.monthly_salary === null || data.monthly_salary <= 0)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Månadslön krävs och måste vara större än 0 för månadslöneform',
path: ['monthly_salary'],
})
}
if (data.salary_type === 'hourly' && (data.hourly_rate === undefined || data.hourly_rate === null || data.hourly_rate <= 0)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Timlön krävs och måste vara större än 0 för timlöneform',
path: ['hourly_rate'],
})
}
// Tax table required for A-skatt employees (not sidoinkomst)
if (data.f_skatt_status === 'a_skatt' && !data.is_sidoinkomst && !data.tax_table_number) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Skattetabell krävs för A-skatt anställda (baseras på folkbokföringskommun)',
path: ['tax_table_number'],
})
}
// Tax municipality recommended when tax table is set
if (data.tax_table_number && !data.tax_municipality) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Folkbokföringskommun bör anges för att dokumentera skattetabellens underlag',
path: ['tax_municipality'],
})
}
})
export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => {
// Only validate salary when salary_type is being changed in this update
if (data.salary_type === 'monthly' && data.monthly_salary !== undefined && data.monthly_salary <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Månadslön måste vara större än 0 för månadslöneform',
path: ['monthly_salary'],
})
}
if (data.salary_type === 'hourly' && data.hourly_rate !== undefined && data.hourly_rate <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Timlön måste vara större än 0 för timlöneform',
path: ['hourly_rate'],
})
}
// If setting salary_type, require the corresponding salary field
if (data.salary_type === 'monthly' && !('monthly_salary' in data)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Månadslön måste anges vid byte till månadslöneform',
path: ['monthly_salary'],
})
}
if (data.salary_type === 'hourly' && !('hourly_rate' in data)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Timlön måste anges vid byte till timlöneform',
path: ['hourly_rate'],
})
}
})
export const CreateSalaryRunSchema = z.object({
period_year: z.number().int().min(2020).max(2100),
period_month: z.number().int().min(1).max(12),
payment_date: isoDate,
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav AZ').default('A'),
notes: z.string().max(2000).optional(),
})
export const AddEmployeeToRunSchema = z.object({
employee_id: uuid,
hours_worked: z.number().nonnegative().optional(),
})
export const CreateSalaryLineItemSchema = z.object({
salary_run_employee_id: uuid,
item_type: SalaryLineItemTypeSchema,
description: z.string().min(1).max(500),
quantity: z.number().optional(),
unit_price: z.number().optional(),
amount: z.number(),
is_taxable: z.boolean().default(true),
is_avgift_basis: z.boolean().default(true),
is_vacation_basis: z.boolean().default(true),
is_gross_deduction: z.boolean().default(false),
is_net_deduction: z.boolean().default(false),
account_number: accountNumber.optional(),
sort_order: z.number().int().default(0),
})
export const UpdateSalaryLineItemSchema = CreateSalaryLineItemSchema.partial().omit({ salary_run_employee_id: true })
// ============================================================
// AI agent flow schemas
// ============================================================
const BookingProposalLineSchema = z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount,
credit_amount: nonNegativeAmount,
description: z.string().min(1).max(500),
})
const BookingProposalCounterpartyTemplateSchema = z.object({
counterparty_name: z.string().min(1).max(200),
debit_account: accountNumber,
credit_account: accountNumber,
vat_treatment: VatTreatmentSchema.nullable(),
category: TransactionCategorySchema.nullable(),
})
// Edit payload: the user's edited version of a booking proposal. Used in
// the /accept endpoint when the user adjusted accounts/VAT before approving.
export const EditBookingProposalSchema = z.object({
lines: z.array(BookingProposalLineSchema).min(2),
vat_treatment: VatTreatmentSchema.nullable(),
default_private: z.boolean(),
counterparty_template_proposal: BookingProposalCounterpartyTemplateSchema.nullable(),
fiscal_period_id: uuid,
entry_date: isoDate,
description: z.string().min(1).max(500),
})
// For match proposals, editing just means picking a different transaction.
export const EditMatchProposalSchema = z.object({
matched_transaction_id: uuid,
})
export const AcceptProposalSchema = z.object({
version: z.number().int().nonnegative(),
edits: z.union([EditBookingProposalSchema, EditMatchProposalSchema]).optional(),
})
// Change the matched transaction on a pending match proposal without
// accepting it. Source tells us whether the user picked one of the AI's
// own alternatives, an AI-regenerated suggestion, or a manually-chosen
// transaction — kept on edit_diff for learning signal.
export const ChangeMatchProposalSchema = z.object({
version: z.number().int().nonnegative(),
matched_transaction_id: uuid,
source: z.enum(['user_alternative', 'user_manual', 'ai_regenerated']),
})
export const RejectProposalSchema = z.object({
version: z.number().int().nonnegative(),
reason: z.string().max(500).optional(),
})
export const BatchAcceptSchema = z.object({
proposal_ids: z.array(uuid).min(1).max(50),
})
export const ResolveRequestSchema = z.object({
response: z.record(z.string(), z.unknown()).optional(),
})
export const StartBackfillSchema = z.object({}).strict()
export const RememberLearningSchema = z.object({
proposal_id: uuid,
counterparty_name: z.string().min(1).max(200),
debit_account: accountNumber,
credit_account: accountNumber,
vat_treatment: VatTreatmentSchema.nullable(),
category: TransactionCategorySchema.nullable(),
})
export const ListProposalsQuerySchema = z.object({
status: z
.enum(['pending', 'accepted', 'rejected', 'skipped', 'invalidated'])
.optional(),
step_type: z.enum(['match', 'booking']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
})