Files
accounted/lib/api/schemas.ts
T
Jakob WennbergandClaude Opus 4.7 951bdb4e66 feat(bookkeeping): show per-account saldo on journal entry form (#562)
* feat(bookkeeping): show per-account saldo on journal entry form

Adds a "Saldo" column to the journal entry form so bookkeepers can see
the current balance of each account as of the entry date while drafting
a voucher. Useful context for booking bank withdrawals, VAT clearings,
and other balance-sensitive operations.

- New GET /api/bookkeeping/account-balances?accounts=...&as_of=...
  returns per-account net (debit - credit) over posted entries up to
  and including the requested date. Batched in chunks of 200 entry IDs
  to stay under PostgREST IN-list limits.
- JournalEntryForm fetches balances debounced 150ms on changes to the
  set of selected account numbers or the entry date; carries forward
  previously-known values so the cell doesn't flash to a skeleton on
  re-fetch.
- Saldo is reference-only: it reflects "balance before this entry" and
  intentionally ignores the draft lines the user is currently editing.
- Renders in both desktop (table column) and mobile (per-line caption)
  layouts. Tabular-nums, muted, right-aligned.

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

* fix(bookkeeping): correct saldo semantics — IB + period-only, BS vs P&L

Address Swedish compliance review on PR #562:

1. P&L accounts (class 3-8) no longer show a since-inception cumulative
   sum. They reset each räkenskapsår per BFNAR 2013:2; the saldo now
   reflects current-period activity only, matching trial-balance
   semantics. BS accounts (class 1-2) continue to include IB.

2. Opening balances are now sourced via the canonical
   getOpeningBalances() helper, which reads the explicit
   opening_balance_entry_id set by year-end closing or SIE import.
   Previously, summing journal_entry_lines from inception returned 0
   for SIE-imported companies whose IB lives in a separate entry that
   the old query happened to include — and the wrong value once
   year-end ran and an OB entry was set without exclusion logic.

3. Relabel "Saldo" -> "Saldo (före)" / "Balance (before)" so the UI
   communicates that the figure excludes the draft being edited
   (BFNAR 2013:2 kap 8 self-documentation requirement).

4. Stop forwarding raw Supabase error.message to the client; log
   server-side via the structured logger and return a generic
   'Internal server error' to avoid leaking schema details.

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

* fix(bookkeeping): reject future as_of dates on account-balances endpoint

Both compliance reviewers on PR #562 flagged this independently: a
future as_of date would include posted entries dated after today in
the activity window, producing a misleading "balance before this
entry" hint that could drive incorrect verifikat entries
(swedish-compliance-review-bot) or be used for future-date probing
(SOC 2 PI1.1, GDPR Art.25(2)).

- AccountBalancesQuerySchema.as_of now refines to <= today.
- JournalEntryForm collapses the loading skeleton to 0 on any non-OK
  response so the saldo column doesn't get stuck spinning when a
  user enters a future entry_date (which the form's separate period
  validation already handles).

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

* fix(bookkeeping): compare as_of guard against Europe/Stockholm date

swedish-compliance-review-bot caught this on the previous fix: the
future-date guard used new Date().toISOString().slice(0, 10), which is
UTC. Between 00:00–02:00 CET (or 00:00–03:00 CEST), a Swedish
bookkeeper's local "today" is one day ahead of UTC, so entering their
Stockholm-local date would be rejected as a future date.

Compare against Europe/Stockholm-local date via toLocaleDateString
('sv-SE'), which renders YYYY-MM-DD natively, so string comparison
remains correct across DST.

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-23 09:21:31 +02:00

1231 lines
45 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'
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
// ============================================================
// 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',
'inbox_item',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'supplier_invoice_privately_paid',
'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 PsPeriodTypeSchema = z.enum(['monthly', 'quarterly'])
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(),
})
// ============================================================
// Recurring invoice schedule schemas
// ============================================================
// Swedish VAT rates per ML 17 kap 24§ p.9 — null means "use customer default
// from getAvailableVatRates". Any other value would produce a non-compliant
// invoice (buyer cannot deduct ingående moms). Cron-time validation against
// the customer's allowed set still runs in executeRecurringSchedule.
export const RecurringScheduleItemSchema = 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').default('st'),
unit_price: z.number(),
vat_rate: z
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
.nullable()
.optional(),
})
export const CreateRecurringScheduleSchema = z.object({
customer_id: uuid,
name: z.string().min(1, 'Schedule name is required').max(200),
day_of_month: z.number().int().min(1).max(31),
payment_terms_days: z.number().int().min(0).max(90).default(30),
currency: CurrencySchema.default('SEK'),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
auto_send: z.boolean().default(false),
// Optional: when to first run. Defaults to next occurrence of day_of_month
// (today if day_of_month === today, otherwise next month).
start_date: isoDate.optional(),
items: z.array(RecurringScheduleItemSchema).min(1, 'At least one item is required'),
})
export const UpdateRecurringScheduleSchema = z.object({
customer_id: uuid.optional(),
name: z.string().min(1).max(200).optional(),
day_of_month: z.number().int().min(1).max(31).optional(),
payment_terms_days: z.number().int().min(0).max(90).optional(),
currency: CurrencySchema.optional(),
your_reference: z.string().nullable().optional(),
our_reference: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
auto_send: z.boolean().optional(),
status: z.enum(['active', 'paused']).optional(),
// Replace all items if provided. Omit to keep existing items unchanged.
items: z.array(RecurringScheduleItemSchema).min(1).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(),
// Bypass the duplicate-payment guard. Set after the user reviews the
// candidate list returned by INVOICE_PAID_LIKELY_DUPLICATE and confirms
// none of them are this payment. v1 callers must use a fresh
// Idempotency-Key on the retry — the original is body-hash bound.
force: z.boolean().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(),
personal_number: z
.string()
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number')
.optional()
.nullable(),
language: z.enum(['sv', 'en']).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(),
paid_with_private_funds: z.boolean().optional(),
// For paid_with_private_funds: the date the owner paid out-of-pocket.
// Defaults to invoice_date (common for kvitto where the two coincide).
payment_date: isoDate.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 A–Z').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 BookInboxItemDirectlySchema = z.object({
fiscal_period_id: uuid,
entry_date: isoDate,
description: z.string().min(1, 'Beskrivning krävs'),
notes: z.string().max(2000).optional(),
lines: z.array(CreateJournalEntryLineSchema).min(2, 'Minst två rader krävs för dubbel bokföring'),
transaction_id: uuid.optional(),
})
export const MatchInvoiceSchema = z
.object({
invoice_id: uuid,
// Bypass the soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE).
// Set after the user reviews the candidate verifikation and confirms it
// is not this payment. v1 callers must use a fresh Idempotency-Key on
// the retry — the original is body-hash bound.
force: z.boolean().optional(),
// Required whenever force=true. Echoes the journal_entry_id of the
// candidate the user reviewed in the duplicate-payment-check pre-flight.
// The server re-detects the candidate and refuses force=true unless the
// re-detected id matches this value. That binds the override to a
// specific, user-seen duplicate so an automation can't sweep through
// force=true to bypass the guard without ever consulting the candidate.
expected_journal_entry_id: uuid.optional(),
})
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
message: 'expected_journal_entry_id is required when force=true',
path: ['expected_journal_entry_id'],
})
export const LinkTransactionJournalEntrySchema = z.object({
journal_entry_id: uuid,
// Optional invoice to settle alongside the link. When provided, the
// server inserts an invoice_payments row pointing at the existing JE
// and flips the invoice status with the same optimistic-lock pattern
// as the match-invoice route. Omit to only link the bank transaction
// (e.g. when the JE doesn't relate to a customer invoice).
invoice_id: uuid.optional(),
})
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(),
periodisk_sammanstallning_period: PsPeriodTypeSchema.optional(),
tax_contact_name: z.string().max(200).nullable().optional(),
tax_contact_phone: z.string().max(40).nullable().optional(),
tax_contact_email: z.string().email().nullable().optional().or(z.literal('')),
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('')),
swish: z.string()
.transform(normaliseSwish)
.pipe(
z.string().refine(
isValidSwish,
'Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)',
),
)
.nullable()
.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(),
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 A–Z').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_swish: 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(),
// Automation
send_invoice_reminders: z.boolean().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(),
// BAS settlement account to reconcile against (e.g. '1930', '1932'). Defaults
// to '1930' server-side so existing clients stay correct.
account_number: z
.string()
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
.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(),
})
export const AccountBalancesQuerySchema = z.object({
accounts: z
.string()
.transform((s) => s.split(',').map((a) => a.trim()).filter(Boolean))
.pipe(z.array(accountNumber).min(1).max(50)),
// Reject future dates — a saldo "as of tomorrow" would include unposted
// future entries (if any) and mislead the bookkeeper about the true
// pre-entry state of the ledger. Compared in Europe/Stockholm so a Swedish
// bookkeeper working in the 00:00–02:00 CET window (after midnight UTC has
// not yet passed) isn't rejected for entering their local today's date.
as_of: isoDate.refine(
(d) => d <= new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Stockholm' }),
{ message: 'as_of cannot be in the future' },
),
})
// ============================================================
// 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 A–Z').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', 'semesterersattning'])
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'],
})
}
// Phase 5 PR-1 carry-over (PR-2 enforcement): if vaxa_stod_eligible is set,
// require vaxa_stod_start. The end date is optional (some eligibility
// windows run open-ended until the maximum benefit period is reached).
// Birth-year age gate (the actual eligibility rule — born 2003-2007 for
// 2026) is checked at calculation-time by the engine, not here, because
// it depends on the payment year of each run — a 22-year-old at hire
// becomes 23 the next year and the rate switches without a row edit.
if (data.vaxa_stod_eligible && !data.vaxa_stod_start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat',
path: ['vaxa_stod_start'],
})
}
if (
data.vaxa_stod_start &&
data.vaxa_stod_end &&
data.vaxa_stod_end < data.vaxa_stod_start
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Växa-stödets slutdatum måste vara efter startdatumet',
path: ['vaxa_stod_end'],
})
}
})
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'],
})
}
// Växa-stöd schema-level consistency check. The schema can only see what
// the PATCH body carries; the route layer is responsible for merged-
// state validation (i.e. an existing employee with vaxa_stod_start
// already set can have vaxa_stod_eligible flipped on without also
// sending start in the body). What the schema CAN enforce:
// - If the body enables vaxa_stod AND clears vaxa_stod_start explicitly
// (sending null), reject — that would orphan the eligibility flag.
// - If the body sets vaxa_stod_eligible=true AND vaxa_stod_start is
// present in the body but invalid relative to vaxa_stod_end, reject.
// The first case isn't currently expressible via .partial() (null != absent),
// so the practical schema-level check is the second one. The route
// layer will add a merged-state check when needed.
if (
data.vaxa_stod_eligible === true &&
'vaxa_stod_start' in data &&
!data.vaxa_stod_start
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat',
path: ['vaxa_stod_start'],
})
}
if (
data.vaxa_stod_start &&
data.vaxa_stod_end &&
data.vaxa_stod_end < data.vaxa_stod_start
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Växa-stödets slutdatum måste vara efter startdatumet',
path: ['vaxa_stod_end'],
})
}
})
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 A–Z').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,
})