2c2743eb79
* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup - middleware: read BankID enrichment from the bankid_enrichment table (the extension_data path has been dead since the multi-tenant refactor), so company-less BankID users land on /select-company instead of the manual wizard - BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the give-up limit; guard overlapping ticks so completion runs exactly once (a double /complete regenerated the magic link and invalidated the first, failing logins intermittently); retry clicks wait out the start cooldown instead of silently no-oping; Swedish messages for 429/unknown start errors - bankid/complete: all-or-nothing signup — delete the created user when the identity insert, app_metadata update, or magic-link generation fails, so a retry starts clean instead of hitting account_exists with an unusable account - bankid/unlink: read-merge-write app_metadata so has_password survives unlink (BankID-only users could otherwise strand themselves with no login method) - login: BankID "create account" CTA now links to /register instead of dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: move secondary guides into docs/, delete dead root files Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md (renamed EXTENSIONS.md) into a new docs/ folder and update all path references (README, setup.sh, .dockerignore image rules, docker-publish workflow comment, _example-branding, lib/branding/service.ts). Delete two dead root files: customer.json (stray API-test payload) and findings.md (point-in-time swarm audit export, criticals already filed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(api): security & correctness hardening + withRouteContext MFA migration across API routes Audit of ~100 app/api routes. Highlights: Security - agent/conversations: list leaked colleagues' titles + message previews (company-scoped RLS, no user filter) -> user-scoped - calendar/feed PUT: raw body into .update() allowed feed_token fixation on a public unauthenticated URL -> strict schema, content toggles only - bokslutsdispositioner: unbounded schablonintaktRate could inflate the IL 30 kap 25% periodiseringsfond cap base -> bounded - agent profile/composer/onboarding: viewers could rewrite the agent profile while sibling /verify blocked them -> role-gated Correctness - account-totals / listAssets: unbounded queries silently truncated at 1000 rows (under-counted money; skipped assets at year-end depreciation) -> fetchAllRows with stable order (+3 more pagination fixes) - voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could show "no gaps" when the check never ran) -> surfaced - 5 phantom-success writes (OK on zero matched rows) fixed - assets K3 component-sum validated against stale acquisition_cost -> fixed - invite silent email-send failure -> response carries email_sent; deadlines/calendar cast-then-check JSON crashes -> Zod Convention - ~44 legacy routes converted to withRouteContext (MFA); added Zod validation, corrected status codes, console.* -> lib/logger Response shapes preserved for existing callers. ~110 new tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): save a booking as a reusable template from Bokför direkt Add a "Spara som mall" action to the manual booking dialog so users can capture a kontering they just worked out as a booking template — right where they figured out how something should be booked. - derive amount-parameterised template lines from the concrete booking (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with its rate snapped to the nearest standard rate, the rest = business ratios; line labels come from the loaded BAS chart) - extract the shared TemplateForm out of BookingTemplatesPanel so the booking dialog reuses the same editor, live preview and convertibility hints instead of duplicating them - save via the existing POST /api/settings/booking-templates endpoint Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer Bolagsverket rejected a user's filed årsredovisning with "Balansräkning och resultaträkning ska inte innehålla kontonummer": the PDF built every statement row as per-account "1930 Företagskonto" lines while the iXBRL filing path already aggregated to statutory posts, so the two artifacts diverged. The PDF statements now derive from the same K2 risbs mapping the iXBRL document uses (mapTrialBalancesToK2), via a new statement-rows.ts that emits post-level rows in uppställningsform order for both the K2 and K3 templates. Also fixed along the way: - Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load and render; the old PDF had no comparatives at all. - mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass nudges) flow into ArsredovisningData.warnings so the wizard flags a non-fileable document before download. - Flerårsöversikt current/previous year overridden with the mapper's strict-3000–3799 Nettoomsattning, mirroring build-input's duplicate-fact rule, so the FB table ties to the RR. - FB eget kapital-table is post-level and drops obeskattade reserver (never eget kapital); K3 equity-changes statement uses real prior-year opening balances with derived utdelning/nyemission residuals that tie the roll-forward exactly to booked UB. - build-input dedupes warnings now that the PDF path runs the same mapping. Regression test asserts no RR/BR label ever contains a four-digit account number again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): diagnose untransferred prior-year results behind balance-sheet differens Prod incident (97 kr): a multi-year SIE migration lacked one year's omforing av arets resultat; the residual corrupted every later derived opening balance and Balansrakningen showed a bare "Differens: 97 kr" with no explanation. Continuity checking cannot catch this failure mode (prior-year UB and derived IB match per-account by construction) - the invariant that actually breaks is per-year P&L = 0 for all non-latest years. - lib/reports/imbalance-diagnosis.ts: shared detector (findUntransferredResults + buildImbalanceDiagnosis) - Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced, naming the exact culprit years; rendered in web views + PDF; MCP gnubok_get_balance_sheet inherits the field via spread - SIE import: parse-time warning when a completed year's vouchers leave a P&L residual, plus a post-import DB walk surfacing culprits as warnings and structured details.untransferredResults; the Arcim migration workspace previously dropped result.warnings entirely and now renders them - opening-balance/correct: pre-flight the company lock date and return 409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in the client message) instead of the retryable 500 that invited blind retries; catch-path maps a raced trigger rejection to the same code Diagnosis runs only on unbalanced paths (zero cost when healthy) and never fails the report or the import. No migration, nothing persisted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: production error remediation — FX rates, deadlines, log levels, correction relink Batch of fixes for recurring Vercel runtime errors: - Riksbanken FX rates: persistent read-through cache (exchange_rates table), one retry honoring Retry-After on 429/5xx, bounded ingest concurrency, and an honest fallback — most recent cached observation or null, never a hardcoded rate silently booked into amount_sek. Unrated transactions stay repairable via refresh-exchange-rate. - Tax deadline regeneration inserts replacement rows before deleting the superseded set, so a failed insert no longer wipes a company's deadlines (the 23502 user_id regression did exactly that). Migration makes deadlines.user_id nullable for system-generated rows. - Route wrappers + errorResponse log 4xx outcomes at warn so only genuine 5xx reach Vercel's runtime-error clustering; client-supplied /api/log telemetry demoted to warn as well. - application/json documents (raw PSD2 responses archived per BFL) validate as parseable JSON with object/array root instead of always failing the magic-byte check. - correctEntry surfaces document-relink failures to callers, and the BFL document-immutability trigger now allows relinking underlag from a reversed entry to its correction (migration + pg test). - Middleware clears stale session cookies on /api requests too, using scope 'local' so cleanup doesn't re-trigger the failed token refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(skatteverket): persist token health and stop retrying dead consents Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE, TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code and timestamp — SKV per-flow refresh tokens live 65 minutes, so once expired nothing recovers without a fresh BankID consent. The AGI kvittens and skattekonto sync crons skip flagged connections instead of failing every night, and the settings panel prompts for re-consent proactively. A successful reconnect resets the row to active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts A bank returning N same-currency accounts used to map them all onto the currency default (1930/1932/1933/1934), tripping the UNIQUE (company_id, ledger_account) constraint per-account — swallowed errors left accounts silently unmirrored. allocatePsd2LedgerAccount now hands out the currency default first, then free 1931–1959 sub-account slots, skipping slots held by any existing row. - Callback persists allocations to accounts_data so the picker pre-fills reality; reconnect reuses previously mirrored ledgers instead of re-deriving (a user remap to 1935 survives). - Selection save resolves effective ledgers up front and rejects duplicates or cross-connection conflicts with a 400 instead of silently skipping the mirror. - Bank error codes + psu_type are forwarded to the settings page for every OAuth error, keying the Handelsbanken corporate fullmakt guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): stage exact journal lines on categorization previews Categorization previews only carried debit/credit accounts, the GROSS amount, and separate VAT rows — read together that looks like an unbalanced 'gross on cost account + VAT debit' entry, and it misled both users and agents into rejecting correct proposals. The MCP preview and the pending-operation PATCH now materialize the exact lines the commit executor will post (net cost line, VAT line, gross bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives them from the new mapping instead of spreading stale staged lines. ApprovalCard and /pending render the verifikat lines, falling back to the legacy summary only for operations staged before this fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): prune unused imported accounts from the chart SIE imports routinely bring in hundreds of accounts that were never used and clutter the kontoplan. New account_usage_counts RPC (one grouped query instead of a count per account) backs GET /api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune deletes zero-usage accounts — dry-run first, then an explicit account list capped at 2000. Accounts with journal lines are skipped, never deleted. The chart manager shows a usage column and a prune dialog grouping custom accounts vs unused BAS-seeded ones. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces Credit-note creation now copies default_dimensions and per-line dimensions from the original, so the reversing journal entry nets against the same dimension cells instead of dropping them. List/detail responses expose the dimension fields, and the OpenAPI spec snapshot follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: batch serial Supabase round-trips on hot dashboard paths Every dashboard render pays the layout's query chain, so serialized awaits are direct wall-clock: the layout, chat conversation, invoice detail, supplier detail, select-company, and agent-onboarding pages now run their independent lookups in parallel batches, and getCompanyCapabilities folds its disabled-config read into the same round-trip. JournalEntryList hydrates the saved fiscal-year scope optimistically instead of serializing the first entries fetch behind the fiscal-periods request. The supplier detail page filters invoices server-side via a new supplier_id query param instead of fetching the whole company ledger, and the invoice editor (with its framer-motion dependency) lazy-loads so it stops shipping with the invoice list bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): one-click runs, payslip delivery, payments settings, run cockpit Salary P1 batch, driving the 20-click flow toward 3 clicks: - One-click 'Starta lönekörning': POST /api/salary/runs accepts an empty body and resolves defaults server-side — period follows the latest non-corrected run, payment date from the new salary_pay_day setting, series from the per-source-type map. The separate /salary/runs/new page is gone. - Run detail page rebuilt as a step-railed cockpit (progress rail, KPI cards, employee ledger, journal preview) on a deliberately wider canvas; components extracted to components/salary/run/. - Payslip delivery: tokenized public payslip pages (/payslip/[token], backed by salary_payslip_links) plus per-employee email send with PDF — employees need no account, and the middleware exempts the route from auth redirects. - Payments settings: salary pay day, default bank, and pain.001 vs Bankgirot Lön format with per-bank upload instructions and an LB sunset warning (banks retire LB during 2026). - AGI panel: full submission status flows (stale drafts, signing links, kvittens polling, error reports); tax payment panel with skattekonto shortcut and mark-as-paid. - Salary calendar bulk editing, employee benefits/tax-card polish, municipality tax-table lookup improvements. messages/sv+en also carry the strings for the account-prune, skatteverket-reconsent, and banking surfaces committed just before this. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: adopt Next 16 proxy.ts convention + repo housekeeping - Rename middleware.ts to proxy.ts with the proxy() export (Next 16 renamed the middleware convention; behavior unchanged). - Exclude dev_docs/ from tsconfig so stray snippets in planning docs don't break the build type-check. - Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to lock in the withRouteContext migration from 5cfd2b76. - template-library uses roundOre() instead of inline rounding. - database.md: drop account_balances from the key-tables list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): robust service-role detection in correction document relink relink_documents_to_correction() keyed its service-role branch on auth.role(), which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the pg-real harness no longer populate. Genuine service-role callers (pending-ops executor / MCP approve) landed in the auth gate and could not relink underlag. Read the role from the request.jwt.claims JSON directly, mirroring the canonical link_voucher_rpcs_tenant_guard convention. Validated on staging. Also: harden the salary run page's error paths (res.json().catch) against non-JSON error bodies, and roll back the pg-real service-role case in finally so an aborted transaction cannot poison a pooled connection for the next test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(documents): restore journal_entry_line_id link durability (BFL 7 kap) Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to guard journal_entry_id but left journal_entry_line_id to the metadata trigger, which exempts draft-linked docs -- and the entry-level trigger only fired on UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all. That let a set journal_entry_line_id be cleared to NULL, breaking the "link durable from first set" invariant (document-immutability.pg regression). Widen the trigger to fire on journal_entry_line_id too and guard it with the same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays allowed; clearing/re-pointing a set value is blocked, status-independent). The correction-relink GUC path, which legitimately clears line_id when moving underlag to the posted correction, stays exempt. Validated on staging. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2433 lines
97 KiB
TypeScript
2433 lines
97 KiB
TypeScript
import { z } from 'zod'
|
||
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
|
||
import { normalizeVatNumber } from '@/lib/vat/vat-number'
|
||
import { isSaneDateString } from '@/lib/utils'
|
||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
|
||
import type { AuditAction } from '@/types'
|
||
|
||
// ============================================================
|
||
// 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')
|
||
|
||
/**
|
||
* ISO date that must also be a real, in-range calendar date: not just the
|
||
* right shape. Backed by the shared `isSaneDateString` rule (also used by the
|
||
* transaction form) so a 6-digit year or impossible date can't slip through
|
||
* for user-entered dates. Use this over `isoDate` for free-text date input.
|
||
*/
|
||
const saneIsoDate = z
|
||
.string()
|
||
.refine(isSaneDateString, 'Invalid or out-of-range date (expected YYYY-MM-DD, year 1900-2100)')
|
||
|
||
/** 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()
|
||
|
||
/** BAS class-3 revenue account: exactly 4 digits starting with 3 (försäljning/intäkt). */
|
||
const revenueAccount = z
|
||
.string()
|
||
.regex(/^3\d{3}$/, 'Revenue account must be a 4-digit BAS class-3 account (3xxx)')
|
||
|
||
/** Swedish VAT rate as an integer percent. */
|
||
const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
|
||
|
||
/** 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')
|
||
|
||
/** Periodisering: interim accounts. Förutbetalda kostnader live on 17xx. */
|
||
const prepaidExpenseAccount = z
|
||
.string()
|
||
.regex(/^17\d{2}$/, 'Balanskonto för periodiserad kostnad måste vara ett 17xx-konto')
|
||
|
||
/** Periodisering: förutbetalda intäkter live on 29xx. */
|
||
const deferredRevenueAccount = z
|
||
.string()
|
||
.regex(/^29\d{2}$/, 'Balanskonto för periodiserad intäkt måste vara ett 29xx-konto')
|
||
|
||
/**
|
||
* Shared periodisering period rules for invoice line items: both dates or
|
||
* neither, end after start, and a 2-120 calendar month span. The amount-side
|
||
* rules differ per item shape and stay in each schema's superRefine.
|
||
*/
|
||
function validateAccrualPeriod(
|
||
item: { accrual_period_start?: string | null; accrual_period_end?: string | null },
|
||
ctx: z.RefinementCtx,
|
||
): void {
|
||
const start = item.accrual_period_start
|
||
const end = item.accrual_period_end
|
||
if (!start && !end) return
|
||
if (!start || !end) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['accrual_period_start'],
|
||
message: 'Ange både periodens start och slut för periodisering',
|
||
})
|
||
return
|
||
}
|
||
if (end < start) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['accrual_period_end'],
|
||
message: 'Periodens slut måste vara efter dess start',
|
||
})
|
||
return
|
||
}
|
||
const months = countCalendarMonths(start, end)
|
||
if (months < 2) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['accrual_period_end'],
|
||
message: 'Periodisering kräver minst 2 kalendermånader',
|
||
})
|
||
}
|
||
if (months > 120) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['accrual_period_end'],
|
||
message: 'Periodisering kan omfatta högst 120 månader',
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Enum schemas (matching types/index.ts)
|
||
// ============================================================
|
||
|
||
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
|
||
|
||
export const AccountingFrameworkSchema = z.enum(['k2', 'k3'])
|
||
|
||
/**
|
||
* Single K3 component (BFNAR 2012:1 ch.17.4, komponentavskrivning).
|
||
*
|
||
* Used inside AssetCreateSchema / AssetUpdateSchema's `k3_components` array.
|
||
* The cross-component invariant (sum of `cost` equals asset `acquisition_cost`)
|
||
* lives in `validateComponents` from `lib/bokslut/assets/k3-components.ts`
|
||
* and is called by the route-layer refinement: it cannot be expressed in
|
||
* a single-object schema. Component-level checks (cost > 0, salvage ≤ cost,
|
||
* positive useful life) are reinforced by `validateComponents` too so any
|
||
* future caller that uses just the validator gets the same guarantees.
|
||
*
|
||
* `salvage_value` is optional; the engine treats omission as 0.
|
||
*/
|
||
export const K3ComponentSchema = z.object({
|
||
name: z.string().min(1, 'Komponentens namn krävs.'),
|
||
cost: z.number().positive('Anskaffningsvärdet måste vara större än 0.'),
|
||
useful_life_months: z.number().int().positive('Nyttjandeperioden måste vara ett positivt heltal månader.'),
|
||
salvage_value: z.number().nonnegative().optional(),
|
||
})
|
||
|
||
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',
|
||
'reminder_fee',
|
||
'accrual',
|
||
'result_appropriation',
|
||
])
|
||
|
||
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
|
||
export const VoucherSequenceNextQuerySchema = z.object({
|
||
period_id: uuid.optional(),
|
||
series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
|
||
source_type: JournalEntrySourceTypeSchema.optional(),
|
||
date: isoDate.optional(),
|
||
})
|
||
|
||
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({
|
||
// 'text' = free-text or blank spacer row: description only, amounts ignored
|
||
// and excluded from totals/bookkeeping. Defaults to 'product'. Callers still
|
||
// send quantity/unit/unit_price for text rows (the form sends 0/''/0), so
|
||
// the inferred shape stays consistent for downstream code.
|
||
line_type: z.enum(['product', 'text']).optional(),
|
||
description: z.string().max(2000),
|
||
quantity: z.number(),
|
||
unit: z.string(),
|
||
unit_price: z.number(),
|
||
vat_rate: z.number().min(0).max(100).optional(),
|
||
// Article linkage. `article_id` ties the line to a catalog article (text
|
||
// rows omit it). `revenue_account` is the optional BAS class-3 override the
|
||
// engine books to; the API validates it against chart_of_accounts before use.
|
||
article_id: uuid.nullable().optional(),
|
||
revenue_account: revenueAccount.nullable().optional(),
|
||
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
|
||
// the client schema: the API computes it from rot-rut-rules.ts so a
|
||
// tampered client can't expand the 1513 receivable beyond the line total.
|
||
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
|
||
labor_hours: z.number().nonnegative().nullable().optional(),
|
||
work_type: z.string().max(64).nullable().optional(),
|
||
housing_designation: z.string().max(128).nullable().optional(),
|
||
apartment_number: z.string().max(32).nullable().optional(),
|
||
// Bostadsrättsföreningens orgnr (XSD BrfOrgNrTYPE). If present it must be
|
||
// a real orgnr shape: 10 digits (optional dash after position 6) or the
|
||
// 12-digit sekelsiffra form, or Skatteverkets schemavalidering rejects
|
||
// the whole file at upload time. Empty string = cleared field → null.
|
||
brf_org_number: z
|
||
.union([
|
||
z.string().regex(/^(\d{6}-?\d{4}|16\d{10})$/, 'Ogiltigt organisationsnummer (10 siffror, ev. med bindestreck)'),
|
||
z.literal(''),
|
||
])
|
||
.transform((v) => v || null)
|
||
.nullable()
|
||
.optional(),
|
||
// Periodisering (förutbetald intäkt): defer the line's net revenue over
|
||
// the service period. The revenue entry credits the 29xx interim account
|
||
// instead of the revenue account; output VAT is never deferred.
|
||
accrual_period_start: isoDate.nullable().optional(),
|
||
accrual_period_end: isoDate.nullable().optional(),
|
||
accrual_balance_account: deferredRevenueAccount.nullable().optional(),
|
||
// Dimensions PR7: per-item bag merged over the invoice's
|
||
// default_dimensions on the revenue line this item books to.
|
||
dimensions: DimensionsBagSchema.optional(),
|
||
})
|
||
.superRefine((item, ctx) => {
|
||
validateAccrualPeriod(item, ctx)
|
||
const hasAccrual = Boolean(item.accrual_period_start || item.accrual_period_end)
|
||
if (hasAccrual) {
|
||
if (item.line_type === 'text') {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['accrual_period_start'],
|
||
message: 'Textrader kan inte periodiseras',
|
||
})
|
||
}
|
||
if (item.deduction_type) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['accrual_period_start'],
|
||
message: 'ROT/RUT-rader kan inte periodiseras',
|
||
})
|
||
}
|
||
if (item.quantity * item.unit_price <= 0) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['accrual_period_start'],
|
||
message: 'Endast rader med positivt belopp kan periodiseras',
|
||
})
|
||
}
|
||
}
|
||
// Free-text rows skip the product-line requirements (description may be
|
||
// empty for a spacer; quantity/unit/price are ignored).
|
||
if (item.line_type === 'text') return
|
||
if (item.description.trim().length === 0) {
|
||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['description'], message: 'Item description is required' })
|
||
}
|
||
if (item.quantity <= 0) {
|
||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['quantity'], message: 'Quantity must be positive' })
|
||
}
|
||
if (item.unit.length === 0) {
|
||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['unit'], message: 'Unit is required' })
|
||
}
|
||
})
|
||
|
||
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(),
|
||
// ROT/RUT claim info. The personnummer is plaintext on the wire and gets
|
||
// encrypted server-side before it ever hits the DB (see encryptPersonnummer
|
||
// in lib/salary/personnummer.ts). `deduction_housing_designation` is the
|
||
// fastighetsbeteckning at invoice level: required when any ROT item is
|
||
// present (enforced via rot-rut-rules.validateInvoice in the API).
|
||
deduction_personnummer: z.string().max(20).optional(),
|
||
deduction_housing_designation: z.string().max(128).optional(),
|
||
// ROT i bostadsrätt: lägenhetsnummer + föreningens orgnr replace the
|
||
// fastighetsbeteckning (Begaran.xsd: LagenhetsNr + BrfOrgNr). Stamped onto
|
||
// the rot lines server-side, same as deduction_housing_designation.
|
||
deduction_apartment_number: z.string().max(25).optional(),
|
||
// Same orgnr shape rule as items[].brf_org_number; empty string = not set.
|
||
deduction_brf_org_number: z
|
||
.union([
|
||
z.string().regex(/^(\d{6}-?\d{4}|16\d{10})$/, 'Ogiltigt organisationsnummer (10 siffror, ev. med bindestreck)'),
|
||
z.literal(''),
|
||
])
|
||
.transform((v) => v || undefined)
|
||
.optional(),
|
||
// When true, save as an unnumbered draft: skip F-series allocation and the
|
||
// invoice.created event until the user finalizes via POST /invoices/{id}/finalize
|
||
// ("Granska och skapa"). An unnumbered draft is not yet an issued faktura
|
||
// (ML 17 kap 24§), so it can be hard-deleted with no gap in the number series.
|
||
save_as_draft: z.boolean().optional(),
|
||
// Per-invoice öresavrundning toggle (display-only). Omitted → stored as null,
|
||
// which inherits company_settings.ore_rounding when rendering totals.
|
||
ore_rounding: z.boolean().optional(),
|
||
// Dimensions PR7: invoice-level bag applied to every generated journal line;
|
||
// items[].dimensions merge over it per revenue line.
|
||
default_dimensions: DimensionsBagSchema.optional(),
|
||
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
|
||
})
|
||
|
||
// Update (edit) an existing DRAFT invoice in place. Same shape as create minus
|
||
// `save_as_draft`: editing never (re)creates a draft or allocates a number, it
|
||
// only rewrites the draft's header + line items. The PATCH route guards that the
|
||
// target is still a draft (status='draft', no journal entry, not self-billed).
|
||
export const UpdateInvoiceSchema = CreateInvoiceSchema.omit({ save_as_draft: true })
|
||
|
||
export const CreateCreditNoteSchema = z.object({
|
||
credited_invoice_id: uuid,
|
||
reason: z.string().optional(),
|
||
})
|
||
|
||
// ============================================================
|
||
// Rot/rut begäran om utbetalning (Skatteverkets husavdragstjänst)
|
||
// ============================================================
|
||
|
||
export const RotRutPayoutFileSchema = z.object({
|
||
deduction_type: z.enum(['rot', 'rut']),
|
||
invoice_ids: z.array(uuid).min(1).max(500),
|
||
// NamnPaBegaran: the XSD caps it at 16 chars; omitted → generated.
|
||
name: z.string().min(1).max(16).optional(),
|
||
})
|
||
|
||
export const RotRutRequestPatchSchema = z.object({
|
||
status: z.enum(['submitted', 'paid', 'partially_paid', 'rejected', 'cancelled']),
|
||
// Godkänt belopp from Skatteverkets beslut. Only meaningful together with
|
||
// paid/partially_paid/rejected.
|
||
decided_total: nonNegativeAmount.optional(),
|
||
})
|
||
|
||
export const RotRutSettleSchema = z.object({
|
||
payment_date: isoDate,
|
||
// Defaults server-side to decided_total ?? requested_total.
|
||
amount: z.number().positive().optional(),
|
||
// BAS 19xx account the payout landed on (1920 Bank, 1930 Företagskonto, …).
|
||
// Omitted → 1930. The engine validates existence against chart_of_accounts.
|
||
bank_account: z
|
||
.string()
|
||
.regex(/^19\d{2}$/, 'Bankkontot måste vara ett BAS 19xx-konto')
|
||
.optional(),
|
||
})
|
||
|
||
// ============================================================
|
||
// Articles (artikelregister)
|
||
// ============================================================
|
||
|
||
export const ArticleTypeSchema = z.enum(['vara', 'tjanst'])
|
||
|
||
export const CreateArticleSchema = z.object({
|
||
name: z.string().min(1, 'Article name is required').max(200),
|
||
type: ArticleTypeSchema.optional(),
|
||
unit: z.string().min(1).max(32).optional(),
|
||
price_excl_vat: nonNegativeAmount,
|
||
vat_rate: vatRatePercent.optional(),
|
||
// Optional BAS class-3 revenue-account override. Null/omitted = derive from
|
||
// the invoice's VAT treatment (current behaviour).
|
||
revenue_account: revenueAccount.nullable().optional(),
|
||
// Margin/display only; never posted.
|
||
cost_price: nonNegativeAmount.nullable().optional(),
|
||
ean: z.string().max(32).nullable().optional(),
|
||
// ROT/RUT arbetstyp; only meaningful for type === 'tjanst'.
|
||
housework_type: z.string().max(64).nullable().optional(),
|
||
name_en: z.string().max(200).nullable().optional(),
|
||
notes: z.string().max(2000).nullable().optional(),
|
||
// Manual article number; omit to auto-generate via generate_article_number.
|
||
article_number: z.string().max(64).nullable().optional(),
|
||
})
|
||
|
||
// PATCH allows every create field plus toggling the soft-delete flag.
|
||
export const UpdateArticleSchema = CreateArticleSchema.partial().extend({
|
||
active: z.boolean().optional(),
|
||
})
|
||
|
||
// Self-billing received (mottagen självfaktura, ML 17 kap 15§). The customer
|
||
// issued the invoice on our behalf; for us it is a sale. We store the
|
||
// counterparty's number in external_invoice_number and never assign one from
|
||
// our own series. No ROT/RUT (that is a B2C, own-issued concept), so the item
|
||
// schema is the lean revenue-only shape: vat_rate is constrained to the legal
|
||
// Swedish set so the booked output VAT is always reportable.
|
||
export const SelfBillingInvoiceItemSchema = 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)])
|
||
.optional(),
|
||
})
|
||
|
||
export const CreateSelfBillingInvoiceSchema = z.object({
|
||
customer_id: uuid,
|
||
external_invoice_number: z.string().min(1, 'External invoice number is required').max(64),
|
||
self_billing_agreement_ref: z.string().max(128).optional(),
|
||
invoice_date: isoDate,
|
||
received_date: isoDate,
|
||
due_date: isoDate,
|
||
currency: CurrencySchema,
|
||
notes: z.string().optional(),
|
||
items: z.array(SelfBillingInvoiceItemSchema).min(1, 'At least one item is required'),
|
||
})
|
||
|
||
// ============================================================
|
||
// 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(),
|
||
// Dimensions PR7: user-edited payment lines keep their tags (the
|
||
// no-override path re-propagates the invoice's default_dimensions).
|
||
dimensions: DimensionsBagSchema.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(),
|
||
// Manual VAT override. When provided, the engine books this exact amount to
|
||
// 2641/2645 instead of recomputing line_total × vat_rate. Use for partial-
|
||
// deductible cases (bilförmån 50%, representation 300 kr-tak), foreign-
|
||
// currency rounding, or POS receipts where supplier-side rounding makes the
|
||
// VAT off by öre.
|
||
vat_amount: z.number().min(0).optional(),
|
||
// Self-assessed VAT rate for omvänd skattskyldighet (reverse charge). The
|
||
// supplier charges no VAT (vat_rate stays 0); this is the Swedish statutory
|
||
// rate the buyer self-assesses at: 25% huvudregel default, 12%/6% for
|
||
// reduced-rated services (ML 6 kap 34 §). Must be a statutory rate.
|
||
reverse_charge_rate: z
|
||
.number()
|
||
.refine((r) => r === 0.06 || r === 0.12 || r === 0.25, {
|
||
message: 'reverse_charge_rate must be 0.06, 0.12, or 0.25',
|
||
})
|
||
.optional(),
|
||
vat_code: z.string().optional(),
|
||
quantity: z.number().optional(),
|
||
unit: z.string().optional(),
|
||
unit_price: z.number().optional(),
|
||
// Periodisering (förutbetald kostnad): defer the line's net cost over the
|
||
// service period. The registration entry debits the 17xx interim account
|
||
// instead of account_number; input VAT is never deferred.
|
||
accrual_period_start: isoDate.nullable().optional(),
|
||
accrual_period_end: isoDate.nullable().optional(),
|
||
accrual_balance_account: prepaidExpenseAccount.nullable().optional(),
|
||
// Dimensions PR7: per-item bag merged over the invoice's
|
||
// default_dimensions on the expense line this item books to.
|
||
dimensions: DimensionsBagSchema.optional(),
|
||
}).refine(
|
||
(item) => {
|
||
if (item.vat_amount == null) return true
|
||
const lineTotal = item.amount != null
|
||
? item.amount
|
||
: (item.quantity ?? 1) * (item.unit_price ?? 0)
|
||
const vatRate = item.vat_rate ?? 0.25
|
||
const maxVat = Math.round(lineTotal * vatRate * 100) / 100
|
||
// 1-öre tolerance covers POS rounding; anything beyond is an upstream bug
|
||
// or a client trying to inflate 2641 debit beyond the statutory ceiling.
|
||
return item.vat_amount <= maxVat + 0.01
|
||
},
|
||
{
|
||
message: 'vat_amount cannot exceed line_total × vat_rate',
|
||
path: ['vat_amount'],
|
||
},
|
||
).superRefine((item, ctx) => {
|
||
validateAccrualPeriod(item, ctx)
|
||
if (item.accrual_period_start || item.accrual_period_end) {
|
||
const lineTotal = item.amount != null
|
||
? item.amount
|
||
: (item.quantity ?? 1) * (item.unit_price ?? 0)
|
||
if (lineTotal <= 0) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['accrual_period_start'],
|
||
message: 'Endast rader med positivt belopp kan periodiseras',
|
||
})
|
||
}
|
||
}
|
||
})
|
||
|
||
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(),
|
||
// Per-invoice öresavrundning toggle (display-only). Omitted → stored as null (off).
|
||
ore_rounding: z.boolean().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(),
|
||
// Dimensions PR7: invoice-level bag applied to every generated journal line;
|
||
// items[].dimensions merge over it per expense line.
|
||
default_dimensions: DimensionsBagSchema.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(),
|
||
// Which BAS account to credit for the payment. Defaults to 1930 to preserve
|
||
// the historical behaviour for MCP / agent callers that don't supply it.
|
||
payment_account: accountNumber.optional(),
|
||
// Optional user-edited journal entry rows. When present they override the
|
||
// default 2440-clearing / cash booking. Server validates balance and posts
|
||
// via createJournalEntry directly. source_type still derives from the
|
||
// routing decision so downstream payment-sync keeps working.
|
||
lines: z.array(z.object({
|
||
account_number: accountNumber,
|
||
debit_amount: nonNegativeAmount.default(0),
|
||
credit_amount: nonNegativeAmount.default(0),
|
||
line_description: z.string().optional(),
|
||
// Dimensions PR7: user-edited payment lines keep their tags (the
|
||
// no-override path re-propagates the invoice's default_dimensions).
|
||
dimensions: DimensionsBagSchema.optional(),
|
||
})).min(2).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(),
|
||
// SIE dimension map {sie_dim_no: object_code}, e.g. {"1":"KS01","6":"P001"}.
|
||
// Single source of truth for the constraints lives in dimension-resolver so
|
||
// the staged pending-operations path validates identically. Wins per key
|
||
// over the cost_center/project aliases.
|
||
dimensions: DimensionsBagSchema.optional(),
|
||
// Deprecated aliases for dimensions['1'] / dimensions['6'], kept forever
|
||
// for API/MCP compatibility.
|
||
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'),
|
||
})
|
||
|
||
// ============================================================
|
||
// Dimension registry schemas (kostnadsställe/projekt)
|
||
// ============================================================
|
||
// dev_docs/dimensions_implementation_plan.md §6. The registry tables
|
||
// (dimensions/dimension_values) shipped in 20260702084500_dimensions_substrate.
|
||
|
||
/**
|
||
* Object code for USER-CREATED dimension values: strict Fortnox format.
|
||
* Deliberately tighter than both the DB CHECK (1..40 chars, no `"{}`') and
|
||
* DimensionsBagSchema (line-level values): legacy free-text codes from the
|
||
* backfill/SIE import must survive on lines, but new registry codes minted
|
||
* through the API stay portable to Fortnox/Visma.
|
||
*/
|
||
const dimensionValueCode = z
|
||
.string()
|
||
.regex(
|
||
/^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/,
|
||
'Koden får bara innehålla bokstäver (A-Ö), siffror, _, + och - (max 20 tecken)',
|
||
)
|
||
|
||
const dimensionValueDates = {
|
||
start_date: isoDate.nullable().optional(),
|
||
end_date: isoDate.nullable().optional(),
|
||
}
|
||
|
||
/** PATCH /api/dimensions/[id]: name is rejected route-side for is_system dims. */
|
||
export const UpdateDimensionSchema = z
|
||
.object({
|
||
name: z.string().min(1).max(80).optional(),
|
||
is_active: z.boolean().optional(),
|
||
sort_order: z.number().int().min(0).optional(),
|
||
})
|
||
.refine((body) => Object.values(body).some((v) => v !== undefined), {
|
||
message: 'Minst ett fält måste anges',
|
||
})
|
||
|
||
/** POST /api/dimensions/[id]/values: code is immutable after creation (v1: no rename). */
|
||
export const CreateDimensionValueSchema = z
|
||
.object({
|
||
code: dimensionValueCode,
|
||
name: z.string().min(1).max(120),
|
||
/** Omitted → true. Lets "create as archived" be a single atomic POST. */
|
||
is_active: z.boolean().optional(),
|
||
...dimensionValueDates,
|
||
})
|
||
.refine(
|
||
(body) => !body.start_date || !body.end_date || body.end_date >= body.start_date,
|
||
{ message: 'Slutdatum får inte vara före startdatum', path: ['end_date'] },
|
||
)
|
||
|
||
/**
|
||
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag: Tier-2 retro-
|
||
* tagging (dimensions plan PR6). The RPC enforces every rule (posted only,
|
||
* open period, lock date, active registry values); this schema only shapes
|
||
* the request. An empty bag {} untags the line.
|
||
*/
|
||
/**
|
||
* POST /api/dimensions — create a custom dimension (dimensions PR10).
|
||
* sie_dim_no omitted → server picks the next free number >= 20 (SIE leaves
|
||
* 20+ unreserved). parent_sie_dim_no declares an #UNDERDIM hierarchy.
|
||
*/
|
||
export const CreateDimensionSchema = z.object({
|
||
name: z.string().trim().min(1).max(60),
|
||
sie_dim_no: z.coerce.number().int().min(1).max(9999).optional(),
|
||
resets_annually: z.boolean().optional(),
|
||
parent_sie_dim_no: z.coerce.number().int().min(1).max(9999).nullable().optional(),
|
||
})
|
||
|
||
const AccountDimensionRuleTypeSchema = z.enum(['required', 'default', 'fixed'])
|
||
|
||
/** GET /api/dimensions/rules query — optional exact-account filter. */
|
||
export const ListDimensionRulesQuerySchema = z.object({
|
||
account_number: accountNumber.optional(),
|
||
})
|
||
|
||
/**
|
||
* POST /api/dimensions/rules — per-account dimension policy (dimensions
|
||
* PR10). 'required' carries no value; 'default'/'fixed' must carry the value
|
||
* to apply. One rule per (account, dimension) — enforced by the DB UNIQUE.
|
||
*/
|
||
export const CreateAccountDimensionRuleSchema = z
|
||
.object({
|
||
account_number: accountNumber,
|
||
dimension_id: uuid,
|
||
rule_type: AccountDimensionRuleTypeSchema,
|
||
value_id: uuid.optional(),
|
||
is_active: z.boolean().optional(),
|
||
})
|
||
.superRefine((rule, ctx) => {
|
||
if (rule.rule_type === 'required' && rule.value_id) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['value_id'],
|
||
message: 'En obligatorisk regel har inget värde — värden hör till Förval/Låst.',
|
||
})
|
||
}
|
||
if (rule.rule_type !== 'required' && !rule.value_id) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['value_id'],
|
||
message: 'Välj vilket värde regeln ska använda.',
|
||
})
|
||
}
|
||
})
|
||
|
||
/** PATCH /api/dimensions/rules/[id] — the value-presence rule re-checks in the route (partial update). */
|
||
export const UpdateAccountDimensionRuleSchema = z.object({
|
||
rule_type: AccountDimensionRuleTypeSchema.optional(),
|
||
value_id: uuid.nullable().optional(),
|
||
is_active: z.boolean().optional(),
|
||
})
|
||
|
||
export const RetagLineDimensionsSchema = z.object({
|
||
// {} passes (no entries to validate) = UNTAG. Intentional divergence from
|
||
// the MCP staged path (RetagLineDimensionsParamsSchema), which rejects an
|
||
// empty bag: a human clearing phantom tags via the dialog/workbench is a
|
||
// deliberate act with a logged reason; an agent bulk-clearing history is
|
||
// not something we allow to be staged. The retag log records {} as the
|
||
// new value either way (#867 review).
|
||
dimensions: DimensionsBagSchema,
|
||
reason: z.string().min(3).max(500),
|
||
})
|
||
|
||
/** PATCH /api/dimensions/[id]/values/[valueId]: no `code` field by design. */
|
||
export const UpdateDimensionValueSchema = z
|
||
.object({
|
||
name: z.string().min(1).max(120).optional(),
|
||
is_active: z.boolean().optional(),
|
||
...dimensionValueDates,
|
||
})
|
||
.refine((body) => Object.values(body).some((v) => v !== undefined), {
|
||
message: 'Minst ett fält måste anges',
|
||
})
|
||
|
||
/**
|
||
* Move a posted verifikation to a different date (and thereby fiscal period)
|
||
* without changing its lines: fixes a booking entered with the wrong
|
||
* date/year. The corrected lines are copied server-side from the original.
|
||
*/
|
||
export const RecordateJournalEntrySchema = z.object({
|
||
new_entry_date: isoDate,
|
||
})
|
||
|
||
// ============================================================
|
||
// Transaction schemas
|
||
// ============================================================
|
||
|
||
/**
|
||
* Manual bank-transaction creation (the "Lägg till transaktion" form).
|
||
*
|
||
* The authoritative server-side boundary for that flow. Historically the form
|
||
* inserted straight into Supabase from the browser with only
|
||
* `z.string().min(1)` on the date, which let a corrupt 6-digit year through and
|
||
* crashed the page on render. The form reuses `isSaneDateString` (via this
|
||
* schema's `saneIsoDate`) so the date rule is single-sourced across layers.
|
||
*/
|
||
export const CreateTransactionSchema = z.object({
|
||
date: saneIsoDate,
|
||
description: z.string().min(1, 'Description is required').max(500),
|
||
amount: z.number().refine((n) => n !== 0, 'Amount must not be zero'),
|
||
currency: CurrencySchema,
|
||
category: TransactionCategorySchema.optional(),
|
||
notes: z.string().max(2000).optional(),
|
||
})
|
||
|
||
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(),
|
||
// Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE). force
|
||
// bypasses it after the user reviews the candidate; the bypass is bound to
|
||
// the specific already-booked candidate (re-detected server-side, so a
|
||
// guessed id can't wave the guard away). The candidate is either a sibling
|
||
// transaction (expected_duplicate_transaction_id) or a ledger-only voucher
|
||
// with no transaction behind it (expected_duplicate_journal_entry_id): both
|
||
// carry a journal_entry_id, so new callers bind on that.
|
||
force: z.boolean().optional(),
|
||
expected_duplicate_transaction_id: uuid.optional(),
|
||
expected_duplicate_journal_entry_id: uuid.optional(),
|
||
})
|
||
.refine((v) => !v.force || !!v.expected_duplicate_transaction_id || !!v.expected_duplicate_journal_entry_id, {
|
||
message: 'expected_duplicate_transaction_id or expected_duplicate_journal_entry_id is required when force=true',
|
||
path: ['expected_duplicate_journal_entry_id'],
|
||
})
|
||
|
||
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'),
|
||
// Booking-time duplicate guard: see CategorizeTransactionSchema.
|
||
force: z.boolean().optional(),
|
||
expected_duplicate_transaction_id: uuid.optional(),
|
||
expected_duplicate_journal_entry_id: uuid.optional(),
|
||
})
|
||
.refine((v) => !v.force || !!v.expected_duplicate_transaction_id || !!v.expected_duplicate_journal_entry_id, {
|
||
message: 'expected_duplicate_transaction_id or expected_duplicate_journal_entry_id is required when force=true',
|
||
path: ['expected_duplicate_journal_entry_id'],
|
||
})
|
||
|
||
/**
|
||
* Edit a bank transaction's title (description). Only the working label:
|
||
* gated server-side to unbooked, unmatched rows. Trimmed; whitespace-only is
|
||
* rejected by min(1). Passing the bank original restores the "not edited" tag.
|
||
*/
|
||
export const UpdateTransactionTitleSchema = z.object({
|
||
description: z.string().trim().min(1, 'Title cannot be empty').max(500),
|
||
})
|
||
|
||
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(),
|
||
})
|
||
|
||
/**
|
||
* Bulk-book selected Underlag (Dokumentinkorgen) against their matched bank
|
||
* transactions. One shared category + VAT treatment is applied to every
|
||
* selected item; each item is booked against its own matched transaction (which
|
||
* carries the SEK amount), so the verifikat are individual, not a
|
||
* samlingsverifikation. Items without a matched transaction, already booked, or
|
||
* already linked to a leverantörsfaktura are skipped server-side.
|
||
*
|
||
* Used both as the UI route body (POST /items/bulk-book) and as the
|
||
* pending-operation params for `bulk_book_inbox_items` (Lena-driven flow).
|
||
*/
|
||
export const BulkBookInboxSchema = z.object({
|
||
item_ids: z.array(uuid).min(1, 'Minst ett underlag krävs').max(200, 'Högst 200 underlag per bokföring'),
|
||
category: TransactionCategorySchema,
|
||
// Optional fields are `.nullish()` (not just `.optional()`) because the
|
||
// `bulk_book_inbox_items` pending operation persists absent optionals as
|
||
// explicit JSON `null` (stagePendingOperation in mcp-server/server.ts). When
|
||
// the executor re-parses those params on approval, a bare `.optional()` would
|
||
// reject the stored `null`. `.transform` normalizes `null → undefined` so the
|
||
// executor and categorizeMatchedTransaction never receive `null`.
|
||
vat_treatment: VatTreatmentSchema.nullish().transform((v) => v ?? undefined),
|
||
// The underlag's actual moms when it differs from rate × belopp (e.g. dricks).
|
||
// Only valid with a rate-based vat_treatment; rejected otherwise downstream.
|
||
vat_amount: z.number().positive().nullish().transform((v) => v ?? undefined),
|
||
notes: z.string().max(2000).nullish().transform((v) => v ?? undefined),
|
||
allow_duplicate: z.boolean().nullish().transform((v) => v ?? undefined),
|
||
})
|
||
export type BulkBookInboxInput = z.infer<typeof BulkBookInboxSchema>
|
||
|
||
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(),
|
||
// Optional user-edited journal entry lines. When present they override
|
||
// the default clearing/cash booking: the route validates balance and
|
||
// posts via createJournalEntry directly. Source_type is still set from
|
||
// the routing decision (invoice_paid vs invoice_cash_payment) so
|
||
// downstream payment-sync continues to work.
|
||
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(),
|
||
// Optional caller-supplied SEK-per-invoice-currency rate for cross-currency
|
||
// settlement. Used when the Riksbanken lookup returns nothing (rate not
|
||
// published for that date): the dialog surfaces an input so the user can
|
||
// type the rate from their bank statement. Ignored when tx.currency ===
|
||
// invoice.currency. The .max() is a sanity ceiling against pasted garbage /
|
||
// scientific-notation input silently corrupting the FX-diff posting and
|
||
// invoice_payments.amount: no supported currency's SEK rate approaches it
|
||
// (USD~10.5, EUR~11.5, GBP~13.5). It is a guard rail, not a precise band;
|
||
// the dialog's live preview (paid_in_invoice_currency + FX gain/loss) is
|
||
// what catches a plausible-but-wrong decimal-shift typo before confirm.
|
||
manual_exchange_rate: z.number().positive().max(100000).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'],
|
||
})
|
||
|
||
/**
|
||
* Link an existing posted verifikat as payment for an invoice. No new
|
||
* journal entry is created: only an invoice_payments row pointing at the
|
||
* supplied journal_entry_id, plus the invoice's paid/remaining are advanced.
|
||
*/
|
||
export const LinkInvoiceToVoucherSchema = z.object({
|
||
journal_entry_id: uuid,
|
||
notes: z.string().max(2000).optional(),
|
||
})
|
||
|
||
/**
|
||
* Supplier-invoice mirror: link an existing posted verifikat as payment for a
|
||
* supplier invoice. No new JE: only a supplier_invoice_payments row pointing
|
||
* at the supplied journal_entry_id, plus the invoice's paid/remaining advance.
|
||
*/
|
||
export const LinkSupplierInvoiceToVoucherSchema = z.object({
|
||
journal_entry_id: uuid,
|
||
notes: z.string().max(2000).optional(),
|
||
})
|
||
|
||
/**
|
||
* Bulk-book N bank transactions on the same date into one combined verifikat
|
||
* (samlingsverifikation per BFL 5 kap 6§). Two flows multiplexed by which
|
||
* field is set:
|
||
*
|
||
* - `existing_journal_entry_id`: link the txs to an already-posted voucher
|
||
* (no new JE created). The voucher's 19xx net must equal the tx sum.
|
||
*
|
||
* - `template_id` + `mode` + `entry_description`: build a new verifikat
|
||
* by applying the booking template to each tx. The route does the ratio
|
||
* expansion (one_line_per_tx OR sum_per_account) and passes the final
|
||
* lines to the RPC.
|
||
*
|
||
* Exactly one of the two paths must be set: enforced by superRefine.
|
||
*/
|
||
export const BulkBookSchema = z
|
||
.object({
|
||
tx_ids: z
|
||
.array(uuid)
|
||
.min(1, 'At least one transaction is required')
|
||
.max(200, 'At most 200 transactions per batch'),
|
||
existing_journal_entry_id: uuid.optional(),
|
||
template_id: uuid.optional(),
|
||
mode: z.enum(['one_line_per_tx', 'sum_per_account']).optional(),
|
||
entry_description: z.string().min(1).max(500).optional(),
|
||
// PR #608: manual lines path. Mutually exclusive with template_id /
|
||
// existing_journal_entry_id. The route passes these straight through
|
||
// to the RPC's p_new_entry.lines.
|
||
manual_lines: z
|
||
.array(
|
||
z.object({
|
||
account_number: accountNumber,
|
||
// Bound at 99,999,999 SEK per line (compliance-swarm V4.5).
|
||
// Real-world max is in the millions; an 8-digit ceiling catches
|
||
// typos (1000000 mistyped as 10000000000) before they hit the
|
||
// RPC, without blocking legitimate large bookings.
|
||
debit_amount: nonNegativeAmount.max(99_999_999, 'Line amount exceeds maximum'),
|
||
credit_amount: nonNegativeAmount.max(99_999_999, 'Line amount exceeds maximum'),
|
||
currency: z.string().min(3).max(3).default('SEK'),
|
||
line_description: z.string().max(200).optional(),
|
||
// Dimensions PR7: per-line bag, wins over default_dimensions.
|
||
dimensions: DimensionsBagSchema.optional(),
|
||
})
|
||
)
|
||
.min(2, 'A verifikat needs at least two lines')
|
||
.max(200)
|
||
.optional(),
|
||
// Dimensions PR7: header-level bag applied to every generated line in
|
||
// BOTH the template and manual paths (per-line bags win per key). The
|
||
// route merges before calling the RPC.
|
||
default_dimensions: DimensionsBagSchema.optional(),
|
||
})
|
||
.superRefine((data, ctx) => {
|
||
const hasExisting = !!data.existing_journal_entry_id
|
||
const hasTemplate = !!data.template_id
|
||
const hasManual = !!data.manual_lines
|
||
const paths = [hasExisting, hasTemplate, hasManual].filter(Boolean).length
|
||
if (paths !== 1) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message:
|
||
'Provide exactly one of: existing_journal_entry_id (link), template_id (template), or manual_lines (manual)',
|
||
path: ['existing_journal_entry_id'],
|
||
})
|
||
return
|
||
}
|
||
if (hasTemplate) {
|
||
if (!data.mode) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: 'mode is required when template_id is set',
|
||
path: ['mode'],
|
||
})
|
||
}
|
||
if (!data.entry_description) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: 'entry_description is required when template_id is set',
|
||
path: ['entry_description'],
|
||
})
|
||
}
|
||
}
|
||
if (hasManual && !data.entry_description) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: 'entry_description is required when manual_lines is set',
|
||
path: ['entry_description'],
|
||
})
|
||
}
|
||
})
|
||
|
||
/**
|
||
* Allocate one bank transaction across N customer OR N supplier invoices.
|
||
* Backed by the match_batch_allocate PL/pgSQL RPC, which builds a single
|
||
* combined verifikat (samlingsverifikation) and inserts N payment rows.
|
||
*/
|
||
export const MatchBatchSchema = z
|
||
.object({
|
||
allocations: z
|
||
.array(
|
||
z.discriminatedUnion('kind', [
|
||
z.object({
|
||
kind: z.literal('customer_invoice'),
|
||
invoice_id: uuid,
|
||
// Strictly positive: zero or negative is rejected at the schema
|
||
// layer (PR #603 review) so the RPC's BATCH_INVALID_AMOUNT path
|
||
// is only reachable from non-HTTP callers.
|
||
amount: z.number().positive('Allocation amount must be greater than 0'),
|
||
}),
|
||
z.object({
|
||
kind: z.literal('supplier_invoice'),
|
||
supplier_invoice_id: uuid,
|
||
amount: z.number().positive('Allocation amount must be greater than 0'),
|
||
}),
|
||
]),
|
||
)
|
||
.min(1, 'At least one allocation is required')
|
||
// Cap at 100 to prevent DoS via unbounded FOR UPDATE locks in the RPC
|
||
// (PR #603 compliance review, OWASP V4.2). Domain-appropriate ceiling:
|
||
// a real samlingsverifikat rarely covers more than a few dozen invoices.
|
||
.max(100, 'At most 100 allocations per batch'),
|
||
})
|
||
.superRefine((data, ctx) => {
|
||
// Reject mixed customer + supplier in a single batch: semantically a
|
||
// single bank transfer settles invoices on one side. The RPC also guards
|
||
// this with BATCH_MIXED_KINDS_UNSUPPORTED, but rejecting at the schema
|
||
// layer gives a cleaner 400 with a per-field path.
|
||
const kinds = new Set(data.allocations.map((a) => a.kind))
|
||
if (kinds.size > 1) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ['allocations'],
|
||
message: 'Allocations cannot mix customer_invoice and supplier_invoice kinds',
|
||
})
|
||
}
|
||
})
|
||
|
||
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,
|
||
// Same purpose as MatchInvoiceSchema.lines: user-edited rows override
|
||
// the default 2440-clearing / cash booking. Route validates balance and
|
||
// posts via createJournalEntry; source_type still derives from routing.
|
||
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(),
|
||
})
|
||
|
||
|
||
// ============================================================
|
||
// Settings schemas
|
||
// ============================================================
|
||
|
||
// Editable invoice email texts (standard invoices only). Nested JSONB:
|
||
// unknown keys inside are stripped (Zod default, consistent with this file).
|
||
// Empty strings pass validation; the template resolver treats whitespace-only
|
||
// as unset, and the UI prunes empties before saving so the stored object
|
||
// stays minimal. Subject is a mail header: CR/LF are stripped at render time
|
||
// regardless.
|
||
const InvoiceEmailTextsLangSchema = z.object({
|
||
subject: z.string().max(200, 'Ämnesraden får vara max 200 tecken').optional(),
|
||
greeting: z.string().max(200, 'Hälsningen får vara max 200 tecken').optional(),
|
||
body: z.string().max(2000, 'Brödtexten får vara max 2000 tecken').optional(),
|
||
signoff: z.string().max(200, 'Avslutningen får vara max 200 tecken').optional(),
|
||
})
|
||
|
||
export const InvoiceEmailTextsSchema = z.object({
|
||
sv: InvoiceEmailTextsLangSchema.optional(),
|
||
en: InvoiceEmailTextsLangSchema.optional(),
|
||
})
|
||
|
||
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()
|
||
.transform(normalizeVatNumber)
|
||
.pipe(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(),
|
||
default_our_reference: z.string().max(200).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(),
|
||
// Per-source-type voucher series map. Keys are journal_entries.source_type
|
||
// values; values are single uppercase letters A-Z. Read by the engine
|
||
// (`createDraftEntry`) when no explicit voucher_series is passed, with a
|
||
// fallback to 'A' for unknown keys.
|
||
default_voucher_series_per_source_type: z
|
||
.record(
|
||
JournalEntrySourceTypeSchema,
|
||
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(),
|
||
// Editable invoice email texts: { sv?: {...}, en?: {...} }; null clears
|
||
// all overrides. Without this entry the generic PUT would silently strip
|
||
// the field (the schema is the de-facto column whitelist).
|
||
invoice_email_texts: InvoiceEmailTextsSchema.nullable().optional(),
|
||
// Invoice branding: colors enforced as #RRGGBB at the DB level too
|
||
// (see migration 20260526120200_invoice_branding.sql). The dedicated
|
||
// /api/settings/invoicing/branding route is the primary path; these
|
||
// entries let the generic PUT /api/settings also accept the same fields.
|
||
invoice_primary_color: z
|
||
.string()
|
||
.regex(/^#[0-9A-Fa-f]{6}$/, 'Ange en giltig hex-färg (#RRGGBB)')
|
||
.optional(),
|
||
invoice_accent_color: z
|
||
.string()
|
||
.regex(/^#[0-9A-Fa-f]{6}$/, 'Ange en giltig hex-färg (#RRGGBB)')
|
||
.optional(),
|
||
invoice_font_family: z.enum(['Helvetica', 'Times-Roman', 'Courier']).optional(),
|
||
invoice_header_text: z.string().max(200).nullable().optional(),
|
||
invoice_footer_text: z.string().max(500).nullable().optional(),
|
||
// Automation
|
||
send_invoice_reminders: z.boolean().optional(),
|
||
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
|
||
reminder_fee_enabled: z.boolean().optional(),
|
||
reminder_fee_amount: z
|
||
.number()
|
||
.min(0, 'Påminnelseavgift kan inte vara negativ')
|
||
.max(60, 'Lagstadgad maxgräns för påminnelseavgift är 60 kr (Lag 1981:739)')
|
||
.optional(),
|
||
reminder_interest_rate_override: z
|
||
.number()
|
||
.min(0, 'Räntesats kan inte vara negativ')
|
||
.max(0.9999, 'Ange räntesatsen som en decimal mindre än 1 (t.ex. 0.115 för 11,5%)')
|
||
.nullable()
|
||
.optional(),
|
||
// AI agent flow
|
||
ai_flow_enabled: z.boolean().optional(),
|
||
// Dimensions (kostnadsställe/projekt): UI-visibility toggle only, never
|
||
// load-bearing for correctness (dev_docs/dimensions_implementation_plan.md §2).
|
||
dimensions_enabled: z.boolean().optional(),
|
||
// Salary payment file
|
||
preferred_payment_format: z.enum(['bg_lb', 'pain001']).optional(),
|
||
// Salary settings (migration 20260703190000). Day of month salaries are
|
||
// paid (1–28 so it exists in every month) and the default bank whose
|
||
// upload instructions the payment-file panel pre-selects.
|
||
salary_pay_day: z.number().int().min(1).max(28).optional(),
|
||
salary_default_bank: z
|
||
.enum(['swedbank', 'seb', 'handelsbanken', 'nordea', 'other'])
|
||
.nullable()
|
||
.optional(),
|
||
}).refine(
|
||
(data) => {
|
||
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
||
if (data.entity_type === 'enskild_firma' && data.fiscal_year_start_month !== undefined) {
|
||
return data.fiscal_year_start_month === 1
|
||
}
|
||
return true
|
||
},
|
||
{
|
||
message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)',
|
||
path: ['fiscal_year_start_month'],
|
||
}
|
||
)
|
||
|
||
// ============================================================
|
||
// Fiscal period schemas
|
||
// ============================================================
|
||
|
||
export const CreateFiscalPeriodSchema = z.object({
|
||
name: z.string().min(1, 'Period name is required'),
|
||
period_start: isoDate,
|
||
period_end: isoDate,
|
||
}).refine(
|
||
(data) => data.period_start < data.period_end,
|
||
{
|
||
message: 'Period start must be before period end',
|
||
path: ['period_end'],
|
||
}
|
||
)
|
||
|
||
// ============================================================
|
||
// Mapping rule schemas
|
||
// ============================================================
|
||
|
||
export const CreateMappingRuleSchema = z.object({
|
||
rule_name: z.string().min(1, 'Rule name is required'),
|
||
rule_type: MappingRuleTypeSchema,
|
||
priority: z.number().int().min(0).optional(),
|
||
mcc_codes: z.array(z.string()).optional(),
|
||
merchant_pattern: z.string().optional(),
|
||
description_pattern: z.string().optional(),
|
||
amount_min: z.number().optional(),
|
||
amount_max: z.number().optional(),
|
||
debit_account: accountNumber,
|
||
credit_account: accountNumber,
|
||
vat_treatment: z.string().optional(),
|
||
risk_level: RiskLevelSchema.optional(),
|
||
default_private: z.boolean().optional(),
|
||
requires_review: z.boolean().optional(),
|
||
confidence_score: z.number().min(0).max(1).optional(),
|
||
})
|
||
|
||
export const EvaluateMappingRulesSchema = z.union([
|
||
z.object({ transaction_id: uuid }),
|
||
z.object({
|
||
description: z.string().optional(),
|
||
amount: z.number(),
|
||
}).passthrough(),
|
||
])
|
||
|
||
// ============================================================
|
||
// Deadline schemas
|
||
// ============================================================
|
||
|
||
export const CreateDeadlineSchema = z.object({
|
||
title: z.string().min(1, 'Title is required'),
|
||
due_date: isoDate,
|
||
due_time: timeString.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(),
|
||
})
|
||
|
||
// Looser account-number shape than the 4-digit primitive on purpose: imported
|
||
// charts can carry non-standard numbers (sub-accounts like '19301'), and those
|
||
// are exactly the rows the prune flow exists to remove.
|
||
export const PruneAccountsSchema = z
|
||
.object({
|
||
dry_run: z.boolean(),
|
||
account_numbers: z.array(z.string().min(1).max(10)).max(2000).optional(),
|
||
})
|
||
.refine((v) => v.dry_run || (v.account_numbers?.length ?? 0) > 0, {
|
||
message: 'account_numbers is required when dry_run is false',
|
||
path: ['account_numbers'],
|
||
})
|
||
|
||
// ============================================================
|
||
// Bank reconciliation schemas
|
||
// ============================================================
|
||
|
||
export const BankLinkSchema = z.object({
|
||
transaction_id: uuid,
|
||
journal_entry_id: uuid,
|
||
// Settlement account being reconciled. The voucher must have a line on this
|
||
// account and the transaction must belong to it. Defaults to '1930' in the
|
||
// route for back-compat.
|
||
account_number: z
|
||
.string()
|
||
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
|
||
.optional(),
|
||
})
|
||
|
||
export const BankUnlinkSchema = z.object({
|
||
transaction_id: uuid,
|
||
})
|
||
|
||
/**
|
||
* Re-tag a mis-typed bank-account opening balance (a manual/import voucher that
|
||
* is really an ingående balans) as source_type='opening_balance' so bank
|
||
* reconciliation excludes it from the period movement. Routed to the
|
||
* mark_entry_as_opening_balance SECURITY DEFINER RPC, which enforces the rest.
|
||
*/
|
||
export const MarkOpeningBalanceSchema = z.object({
|
||
journal_entry_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(),
|
||
// Pairs the user ticked in the dry-run preview. When present on an apply
|
||
// (dry_run false), only these pairs are committed: intersected server-side
|
||
// with a fresh match run, so a stale or fabricated pair is never applied.
|
||
selected_matches: z
|
||
.array(
|
||
z.object({
|
||
transaction_id: uuid,
|
||
journal_entry_id: uuid,
|
||
}),
|
||
)
|
||
.max(500)
|
||
.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),
|
||
})
|
||
|
||
// ============================================================
|
||
// Audit trail schemas
|
||
// ============================================================
|
||
|
||
// `satisfies` keeps every filter value a member of the AuditAction union —
|
||
// adding a bogus value here fails the typecheck.
|
||
const auditActions = [
|
||
'INSERT', 'UPDATE', 'DELETE', 'COMMIT', 'REVERSE', 'CORRECT',
|
||
'LOCK_PERIOD', 'CLOSE_PERIOD', 'DOCUMENT_DELETE_BLOCKED',
|
||
'RETENTION_BLOCK', 'SECURITY_EVENT', 'INTEGRITY_FAILURE',
|
||
] as const satisfies readonly AuditAction[]
|
||
|
||
export const AuditTrailQuerySchema = z.object({
|
||
action: z.enum(auditActions).optional(),
|
||
table_name: z.string().min(1).optional(),
|
||
record_id: z.string().min(1).optional(),
|
||
from_date: isoDate.optional(),
|
||
to_date: isoDate.optional(),
|
||
page: z.coerce.number().int().min(1).default(1),
|
||
page_size: z.coerce.number().int().min(1).max(200).default(50),
|
||
})
|
||
|
||
// ============================================================
|
||
// 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(),
|
||
})
|
||
|
||
const ImportedArticleRowSchema = z.object({
|
||
row_index: z.number().int(),
|
||
name: z.string().min(1),
|
||
name_en: z.string().nullable(),
|
||
article_number: z.string().nullable(),
|
||
type: ArticleTypeSchema,
|
||
unit: z.string(),
|
||
price_excl_vat: nonNegativeAmount,
|
||
vat_rate: vatRatePercent,
|
||
// The execute route re-validates against the chart of accounts (and drops
|
||
// unknown/inactive overrides), so a loose nullable string is enough here.
|
||
revenue_account: z.string().nullable(),
|
||
cost_price: nonNegativeAmount.nullable(),
|
||
ean: z.string().nullable(),
|
||
housework_type: z.string().nullable(),
|
||
notes: z.string().nullable(),
|
||
})
|
||
|
||
export const ArticleImportExecuteSchema = z.object({
|
||
rows: z.array(ImportedArticleRowSchema).min(1, 'At least one row is required'),
|
||
update_duplicates: z.boolean(),
|
||
})
|
||
|
||
// Validates the optional column-mapping override posted to the parse route, so
|
||
// a malformed/hostile blob can't drive the parser with non-numeric or
|
||
// unexpected column indices. Mirrors DetectedArticleColumns.
|
||
const articleColumnIndex = z.number().int().min(0).nullable()
|
||
export const ArticleColumnOverridesSchema = z.object({
|
||
name_col: z.number().int().min(0),
|
||
article_number_col: articleColumnIndex,
|
||
name_en_col: articleColumnIndex,
|
||
type_col: articleColumnIndex,
|
||
unit_col: articleColumnIndex,
|
||
price_col: articleColumnIndex,
|
||
vat_rate_col: articleColumnIndex,
|
||
revenue_account_col: articleColumnIndex,
|
||
cost_price_col: articleColumnIndex,
|
||
ean_col: articleColumnIndex,
|
||
housework_type_col: articleColumnIndex,
|
||
notes_col: articleColumnIndex,
|
||
confidence: z.number(),
|
||
})
|
||
|
||
// ============================================================
|
||
// 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', 'overtime_50', 'overtime_100',
|
||
'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
|
||
'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', 'semesterersattning',
|
||
'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(),
|
||
// Dimensions PR8: bag applied to the employee's P&L cost lines when a
|
||
// salary run is booked. {} clears (the UI always sends the field).
|
||
default_dimensions: DimensionsBagSchema.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(),
|
||
})
|
||
|
||
// One-click variant for the dashboard route: all fields optional — the route
|
||
// resolves defaults server-side (period = month after the latest
|
||
// non-corrected run, payment date from company_settings.salary_pay_day,
|
||
// series from the per-source-type map), so "Starta lönekörning" can POST {}.
|
||
// The v1 REST surface keeps the strict CreateSalaryRunSchema above (it
|
||
// inserts the fields verbatim and must 400 on omissions, not 500).
|
||
export const CreateSalaryRunWithDefaultsSchema = z.object({
|
||
period_year: z.number().int().min(2020).max(2100).optional(),
|
||
period_month: z.number().int().min(1).max(12).optional(),
|
||
payment_date: isoDate.optional(),
|
||
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
|
||
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',
|
||
'unpaid_leave',
|
||
'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(),
|
||
// Optional shift window. Feeds the shift-premium engine: without explicit
|
||
// times, the engine assumes a default 08:00-17:00 day shift. Either both
|
||
// fields are provided or neither.
|
||
start_time: timeString.optional(),
|
||
end_time: timeString.optional(),
|
||
})
|
||
.refine(
|
||
(data) => (data.start_time == null && data.end_time == null) || (data.start_time != null && data.end_time != null),
|
||
{
|
||
message: 'Ange både start- och sluttid eller låt båda vara tomma',
|
||
path: ['start_time'],
|
||
},
|
||
)
|
||
|
||
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(),
|
||
// Optional shift window applied to every date in the batch. Pair both or
|
||
// neither; same fallback behaviour as the single-row endpoint.
|
||
start_time: timeString.optional(),
|
||
end_time: timeString.optional(),
|
||
})
|
||
.refine(
|
||
(data) => (data.start_time == null && data.end_time == null) || (data.start_time != null && data.end_time != null),
|
||
{
|
||
message: 'Ange både start- och sluttid eller låt båda vara tomma',
|
||
path: ['start_time'],
|
||
},
|
||
)
|
||
|
||
// ============================================================
|
||
// 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,
|
||
})
|
||
|
||
export const LinkDocumentSchema = z.object({
|
||
journal_entry_id: uuid,
|
||
journal_entry_line_id: uuid.optional(),
|
||
inbox_item_id: uuid.optional(),
|
||
transaction_id: uuid.optional(),
|
||
})
|
||
|
||
// ============================================================
|
||
// Shift-premium rules (OB-tillägg och övertid)
|
||
// ============================================================
|
||
|
||
export const ShiftPremiumItemTypeSchema = z.enum([
|
||
'overtime_50',
|
||
'overtime_100',
|
||
'ob_weekday_evening',
|
||
'ob_weekend',
|
||
'ob_night',
|
||
'ob_holiday',
|
||
])
|
||
|
||
const dayOfWeekArray = z
|
||
.array(z.number().int().min(1).max(7))
|
||
.min(1, 'Välj minst en veckodag')
|
||
.max(7, 'Högst sju veckodagar tillåtna')
|
||
|
||
export const CreateShiftPremiumRuleSchema = z
|
||
.object({
|
||
name: z.string().min(1).max(120),
|
||
applies_to_all_employees: z.boolean().default(true),
|
||
applies_to_employee_ids: z.array(uuid).default([]),
|
||
day_of_week: dayOfWeekArray,
|
||
start_time: timeString,
|
||
end_time: timeString,
|
||
premium_percent: z.number().min(0).max(500),
|
||
item_type: ShiftPremiumItemTypeSchema,
|
||
priority: z.number().int().min(0).max(1000).default(0),
|
||
is_active: z.boolean().default(true),
|
||
})
|
||
.refine(
|
||
(data) => data.applies_to_all_employees || data.applies_to_employee_ids.length > 0,
|
||
{
|
||
message: 'Välj minst en anställd när regeln inte gäller alla',
|
||
path: ['applies_to_employee_ids'],
|
||
},
|
||
)
|
||
|
||
export const UpdateShiftPremiumRuleSchema = z
|
||
.object({
|
||
name: z.string().min(1).max(120).optional(),
|
||
applies_to_all_employees: z.boolean().optional(),
|
||
applies_to_employee_ids: z.array(uuid).optional(),
|
||
day_of_week: dayOfWeekArray.optional(),
|
||
start_time: timeString.optional(),
|
||
end_time: timeString.optional(),
|
||
premium_percent: z.number().min(0).max(500).optional(),
|
||
item_type: ShiftPremiumItemTypeSchema.optional(),
|
||
priority: z.number().int().min(0).max(1000).optional(),
|
||
is_active: z.boolean().optional(),
|
||
})
|
||
.refine(
|
||
(data) => {
|
||
if (data.applies_to_all_employees === false && data.applies_to_employee_ids !== undefined) {
|
||
return data.applies_to_employee_ids.length > 0
|
||
}
|
||
return true
|
||
},
|
||
{
|
||
message: 'Välj minst en anställd när regeln inte gäller alla',
|
||
path: ['applies_to_employee_ids'],
|
||
},
|
||
)
|
||
|
||
/**
|
||
* Per-employee override on a salary run (advanced mode).
|
||
*
|
||
* Each field is independently nullable. `null` clears a previously-set
|
||
* override; `undefined` leaves it unchanged. `reason` is required whenever
|
||
* any non-null override is being applied: the DB CHECK constraint
|
||
* enforces this at the storage layer too.
|
||
*/
|
||
// Upper bound on per-employee override values. 10 MSEK is well above any
|
||
// plausible single-period gross/tax/avgifter figure for a salary run and
|
||
// catches typos (e.g. an extra zero) before they reach the ledger or AGI.
|
||
const SALARY_OVERRIDE_MAX = 10_000_000
|
||
|
||
export const SalaryEmployeeOverrideSchema = z
|
||
.object({
|
||
// Per-run monthly salary for this employee, editable while the run is a
|
||
// draft. 0 is allowed (an intentional nollkörning). This is NOT a review
|
||
// override: it sets the base the engine uses for this month only and does
|
||
// not require a reason. The route gates this field to `draft` status.
|
||
monthly_salary: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).optional(),
|
||
tax_withheld_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
|
||
avgifter_amount_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
|
||
avgifter_basis_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
|
||
reason: z.string().min(1).max(500).nullable().optional(),
|
||
})
|
||
.refine(
|
||
(data) => {
|
||
const hasOverride =
|
||
(data.tax_withheld_override !== undefined && data.tax_withheld_override !== null) ||
|
||
(data.avgifter_amount_override !== undefined && data.avgifter_amount_override !== null) ||
|
||
(data.avgifter_basis_override !== undefined && data.avgifter_basis_override !== null)
|
||
if (hasOverride && (data.reason === undefined || data.reason === null || data.reason.trim() === '')) {
|
||
return false
|
||
}
|
||
return true
|
||
},
|
||
{
|
||
message: 'Ange en anledning till justeringen (krävs av BFL för manuella skattejusteringar)',
|
||
path: ['reason'],
|
||
},
|
||
)
|
||
|
||
|
||
// ============================================================
|
||
// Dimensions PR6: bulk retro-tagging workbench (appended at end
|
||
// of file by PR6 to avoid conflicts; keep new schemas below).
|
||
// ============================================================
|
||
|
||
/**
|
||
* Query filters for GET /api/dimensions/tagging/lines (the BulkTagWorkbench
|
||
* line browser). All filters optional; `limit` is a hard cap (default 200,
|
||
* max 500): the route fetches limit+1 and reports `total_capped` instead of
|
||
* paginating (dimensions plan §3, v1 scope).
|
||
*/
|
||
export const DimensionTaggingLinesQuerySchema = z.object({
|
||
period_id: uuid.optional(),
|
||
date_from: saneIsoDate.optional(),
|
||
date_to: saneIsoDate.optional(),
|
||
account_from: accountNumber.optional(),
|
||
account_to: accountNumber.optional(),
|
||
/** Free-text ilike filter on journal_entries.description. */
|
||
text: z.string().trim().max(200).optional(),
|
||
/** '1' → only vouchers with at least one untagged line ({} dimensions). */
|
||
only_untagged: z.enum(['0', '1']).optional(),
|
||
/**
|
||
* '1' → include reversal pairs (annulled entries + their stornos). Excluded
|
||
* by default: a pair nets to zero in every dimension bucket when both sides
|
||
* carry the same tag, so retro-tagging it is a no-op, and showing it
|
||
* invites tagging one side only, which skews project P&L.
|
||
*/
|
||
include_annulled: z.enum(['0', '1']).optional(),
|
||
/** Cap counts VOUCHERS since the voucher-level rework. */
|
||
limit: z.coerce.number().int().min(1).max(300).default(150),
|
||
})
|
||
|
||
/**
|
||
* Body for POST /api/dimensions/tagging/apply. One dimensions object applied
|
||
* to every listed line via the retag_line_dimensions RPC (the UI groups
|
||
* selected lines by their computed resulting map and issues one POST per
|
||
* distinct map). `dimensions` reuses THE bag schema so validation cannot
|
||
* drift from the engine/API layers; an empty bag is allowed: replace mode
|
||
* uses it to clear phantom tags. `reason` mirrors the RPC's >= 3 chars CHECK.
|
||
*/
|
||
export const DimensionTaggingApplySchema = z.object({
|
||
line_ids: z.array(uuid).min(1).max(500),
|
||
dimensions: DimensionsBagSchema,
|
||
reason: z.string().trim().min(3).max(500),
|
||
})
|