Files
accounted/lib/api/schemas.ts
T
Jakob WennbergandClaude Opus 4.7 07a7964e8d fix(supplier-invoices): guard against duplicate payment when bank tx already booked (#461)
* fix(supplier-invoices): guard against duplicate payment when bank tx already booked

Two-pronged fix for a UX trap where a supplier invoice could be marked paid
even though the bank payment was already booked on 2440, creating a duplicate
verifikation.

Prong A — mark-paid duplicate guard: before booking, scan for an unlinked
outgoing bank transaction matching this supplier (merchant_name ILIKE) within
±2% / ±60 days. If found, return 409 SI_PAID_LIKELY_DUPLICATE with candidates
so the UI can offer "link existing" instead. Override via { force: true }.

Prong B — categorize match suggestion: when the user assigns 2440 directly on
a negative business transaction and an open supplier invoice from the same
supplier covers the same amount, return 409 TX_CATEGORIZE_SUGGEST_SI_MATCH
with candidates and route the user to match-supplier-invoice. Override via
{ confirm_no_match: true }.

Frontend dialogs added on the supplier-invoice detail page and the
transactions inbox. Partial payments skip the mark-paid guard (deliberate
action). Tests cover the 409 path, the override path, and the no-candidates
happy path on both routes.

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

* fix(supplier-invoices): apply PR review fixes to duplicate-payment guards

- Add `is_business = true` filter to mark-paid candidate query so private
  bank withdrawals don't surface as false-positive duplicates
- Escape LIKE wildcards (`%`, `_`, `\`) in both ILIKE patterns to avoid
  silent over-matching when a supplier/merchant name contains those chars
- Round paymentAmount and remaining_amount to 2 decimals before the
  partial-payment guard comparison to avoid float-equality fragility
- Require credit account to be in the 1xxx (bank/cash) series for the
  Prong B 2440 intercept so 2440 against clearing/equity accounts isn't
  misinterpreted as a supplier payment
- Extract DUPLICATE_AMOUNT_TOLERANCE_PCT (0.02) and
  DUPLICATE_DATE_WINDOW_DAYS (60) into a shared helper module with the
  LIKE-escape utility

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

* fix(supplier-invoices): broaden 244x match, audit log overrides, drop JE id from response

Second-round PR review fixes:

- Widen Prong B regex from /^2440$/ to /^244\d$/ so payments mapped to BAS
  sub-accounts (e.g. 2441 leverantörsskulder i utländsk valuta) also trigger
  the suggestion (swedish-invoice-compliance bot)
- Log a structured warning when force=true or confirm_no_match=true is honored,
  with the relevant context (amount, date, accounts) so the override is
  traceable per BFNAR 2013:2 kap 8 (behandlingshistorik)
- Drop journal_entry_id from the SI_PAID_LIKELY_DUPLICATE candidate response
  payload (data minimization, GDPR Art.5(1)(c)); the UI never rendered it

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

* fix(supplier-invoices): third-round PR review — date window on Prong B, length cap, VAT message

- Add the missing date window to the Prong B categorize candidate query
  (swedish-compliance bot): without it, an open invoice from years back can
  surface as a "match" for an unrelated bank transaction. Uses the shared
  DUPLICATE_DATE_WINDOW_DAYS against invoice_date.
- Cap supplier/merchant names to 200 chars before they enter escapeLikePattern
  (OWASP V1.2.5 / ISO A.8.28). Bounds DB work on pathological inputs.
- Log a structured warning when the mark-paid guard is skipped because the
  invoice has no resolved supplier name (BFL 5 kap 7 § — motpart should be
  identifiable; the absence is itself worth surfacing).
- Update the SI-match suggestion error and the matching UI copy to call out
  the actual compliance risk: a duplicate 244x posting double-deducts ingående
  moms (ML 8 kap 3 §), not just bookkeeping symmetry.
- Reword "Bokför på 2440 ändå" to "Bokför på leverantörsskulder ändå" now that
  the regex covers BAS sub-accounts 244x.

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

* fix(supplier-invoices): correct Prong B framing — duplicate verifikation, not VAT double-deduction

Latest swedish-compliance review correctly walked back the earlier
finding that asked for ML 8 kap 3 § VAT framing. Plain 244x
categorization via account_override does not include VAT lines (account
class 2), so the risk is a duplicate verifikation (BFL 5 kap 5 §), not
a double VAT deduction. Update both the structured error message and
the dialog body to reflect the actual mechanism.

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-05-13 11:38:19 +02:00

1025 lines
36 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(),
force: z.boolean().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(),
confirm_no_match: z.boolean().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 CreateTransactionFromDocumentSchema = z.object({
inbox_item_id: uuid,
amount: z.number().refine((n) => n !== 0, 'Amount must be non-zero'),
transaction_date: isoDate,
description: z.string().min(1).max(500),
})
export const MatchSupplierInvoiceSchema = z.object({
supplier_invoice_id: uuid,
})
// ============================================================
// Settings schemas
// ============================================================
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
org_number: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
f_skatt: z.boolean().optional(),
vat_registered: z.boolean().optional(),
vat_number: z.string().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_show_logo: z.boolean().optional(),
invoice_show_company_name: z.boolean().optional(),
invoice_company_name_position: z.enum(['header', 'footer']).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(),
// Salary payment file
preferred_payment_format: z.enum(['bg_lb', 'pain001']).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.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),
})
export const PendingOperationsBulkSchema = z.object({
ids: z.array(z.string().uuid()).min(1).max(100),
})
// ============================================================
// 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'),
})
// ============================================================
// Register import schemas (customers, suppliers)
// ============================================================
const ImportedCustomerRowSchema = z.object({
row_index: z.number().int(),
name: z.string().min(1),
customer_type: CustomerTypeSchema,
org_number: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
address_line1: z.string().nullable(),
address_line2: z.string().nullable(),
postal_code: z.string().nullable(),
city: z.string().nullable(),
country: z.string(),
vat_number: z.string().nullable(),
default_payment_terms: z.number().int().min(0).max(365),
notes: z.string().nullable(),
})
export const CustomerImportExecuteSchema = z.object({
rows: z.array(ImportedCustomerRowSchema).min(1, 'At least one row is required'),
update_duplicates: z.boolean(),
})
const ImportedSupplierRowSchema = z.object({
row_index: z.number().int(),
name: z.string().min(1),
supplier_type: SupplierTypeSchema,
org_number: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
address_line1: z.string().nullable(),
address_line2: z.string().nullable(),
postal_code: z.string().nullable(),
city: z.string().nullable(),
country: z.string(),
vat_number: z.string().nullable(),
bankgiro: z.string().nullable(),
plusgiro: z.string().nullable(),
bank_account: z.string().nullable(),
iban: z.string().nullable(),
bic: z.string().nullable(),
default_payment_terms: z.number().int().min(0).max(365),
default_currency: z.string(),
notes: z.string().nullable(),
})
export const SupplierImportExecuteSchema = z.object({
rows: z.array(ImportedSupplierRowSchema).min(1, 'At least one row is required'),
update_duplicates: z.boolean(),
})
// ============================================================
// 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', 'none'])
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_bike', '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 EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other'])
export const CreateEmployeeBenefitSchema = z.object({
benefit_type: EmployeeBenefitTypeSchema,
description: z.string().min(1).max(200),
monthly_value: z.number().nonnegative().optional(),
/** For bike benefit: annual market value of the förmån. The server computes
* monthly_value = max(0, annual 3000) / 12 per Skatteverket schablon. */
annual_market_value: z.number().nonnegative().optional(),
valid_from: isoDate,
valid_to: isoDate.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
is_active: z.boolean().optional(),
}).superRefine((data, ctx) => {
if (data.benefit_type === 'bike') {
if (data.annual_market_value === undefined && data.monthly_value === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Cykelförmån kräver årligt marknadsvärde',
path: ['annual_market_value'],
})
}
} else if (data.monthly_value === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Månatligt förmånsvärde krävs',
path: ['monthly_value'],
})
}
})
export const UpdateEmployeeBenefitSchema = z.object({
description: z.string().min(1).max(200).optional(),
monthly_value: z.number().nonnegative().optional(),
annual_market_value: z.number().nonnegative().optional(),
valid_from: isoDate.optional(),
valid_to: isoDate.nullable().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
is_active: z.boolean().optional(),
})
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 })
// ── Absence (frånvaro) per-day records ──────────────────────────────
//
// Drives sjuklönelagen calculations (karensavdrag boundary, återinsjuknande
// 5-day merge, högriskskydd 12-month cap, day 14/15 FK transition) and AGI
// 2025+ <Frånvarouppgift> per-event reporting. The salary calculator derives
// line items from these rows; users do not enter absence as line items.
export const AbsenceTypeSchema = z.enum([
'sick',
'vab',
'parental',
'pregnancy',
'care_relative',
'study',
'other_leave',
])
export const UpsertAbsenceDaySchema = z.object({
absence_date: isoDate,
absence_type: AbsenceTypeSchema,
hours: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
salary_run_employee_id: uuid.optional(),
})
export const AbsenceRangeQuerySchema = z.object({
from: isoDate,
to: isoDate,
}).refine((data) => data.from <= data.to, {
message: '`from` måste vara före eller lika med `to`',
path: ['from'],
})
// ── Worked-hours per-day records (hourly employees) ─────────────────
//
// Drives base salary calculation for hourly (timanställd) employees:
// `baseSalary = hourly_rate × Σ hours`. Mirrors absence days deliberately —
// same calendar UX, half-day mixing with absence enforced by the 24h cap
// trigger. The calculator sums these per pay period at calculate time.
export const UpsertWorkedDaySchema = z.object({
work_date: isoDate,
hours: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
salary_run_employee_id: uuid.optional(),
})
export const WorkedHoursRangeQuerySchema = z.object({
from: isoDate,
to: isoDate,
}).refine((data) => data.from <= data.to, {
message: '`from` måste vara före eller lika med `to`',
path: ['from'],
})
export const BatchUpsertWorkedDaysSchema = z.object({
// 100-row sanity cap: typical use is one pay period (~22 weekdays). A larger
// value usually indicates the caller is iterating wrong.
dates: z.array(isoDate).min(1).max(100),
hours: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
salary_run_employee_id: uuid.optional(),
})
// ============================================================
// 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),
})
export const AttachDocumentSchema = z.object({
document_id: uuid,
})