/** * ═══════════════════════════════════════════════════════════════════════════ * AAMOS Ledger — Input Validation Module * ═══════════════════════════════════════════════════════════════════════════ * * Använder Joi för robust schema-validering av alla inkommande requests. */ import Joi from 'joi'; // ── Reusable validators ───────────────────────────────────────────────────── const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export const schemas = { // ── Account schemas ─────────────────────────────────────────────────────── createAccount: Joi.object({ account_number: Joi.string().max(20).required() .pattern(/^\d+$/) .messages({ 'string.pattern.base': 'account_number måste vara numeriskt' }), name: Joi.string().min(1).max(200).required(), account_type: Joi.string().valid('asset', 'liability', 'equity', 'revenue', 'expense').required(), normal_balance: Joi.string().valid('debit', 'credit').required(), coa_standard: Joi.string().valid('BAS', 'IFRS', 'USGAAP', 'custom').default('BAS'), parent_account: Joi.string().max(20).allow(null), vat_code: Joi.string().max(20).allow(null), metadata: Joi.object().default({}), }), listAccounts: Joi.object({ standard: Joi.string().valid('BAS', 'IFRS', 'USGAAP', 'custom').default('BAS'), }), // ── Journal entry schemas ───────────────────────────────────────────────── createJournalEntry: Joi.object({ entry_date: Joi.date().iso().required() .messages({ 'date.format': 'entry_date måste vara ISO-8601 (YYYY-MM-DD)' }), description: Joi.string().min(1).max(1000).required(), reference: Joi.string().max(200).allow(null, ''), source_type: Joi.string().valid('manual', 'import_sie', 'import_csv', 'bank', 'system', 'agent').default('manual'), source_id: Joi.string().max(200).allow(null, ''), period: Joi.string().pattern(/^\d{4}-\d{2}$/).allow(null) .messages({ 'string.pattern.base': 'period måste vara YYYY-MM' }), fiscal_year: Joi.number().integer().min(2000).max(2100).allow(null), metadata: Joi.object().default({}), lines: Joi.array().items( Joi.object({ account_number: Joi.string().max(20).required(), account_name: Joi.string().max(200).allow(null, ''), debit: Joi.number().positive().allow(null), credit: Joi.number().positive().allow(null), currency: Joi.string().length(3).uppercase().default('SEK'), amount_base: Joi.number().positive().allow(null), vat_code: Joi.string().max(20).allow(null, ''), vat_amount: Joi.number().min(0).allow(null), cost_center: Joi.string().max(50).allow(null, ''), project_code: Joi.string().max(50).allow(null, ''), description: Joi.string().max(500).allow(null, ''), metadata: Joi.object().default({}), }).custom((value, helpers) => { // Antingen debit ELLER credit, inte båda/ingen const hasDebit = value.debit != null; const hasCredit = value.credit != null; if (hasDebit === hasCredit) { return helpers.error('journal.line.debit_credit'); } return value; }, 'debit_credit_check') .messages({ 'journal.line.debit_credit': 'Varje journalrad måste ha antingen debit ELLER credit' }) ).min(2).required() .messages({ 'array.min': 'Minst 2 journalrader krävs för dubbel bokföring' }), }).custom((value, helpers) => { // Beräkna och validera att debet = kredit const totalDebit = value.lines.reduce((s, l) => s + (l.debit || 0), 0); const totalCredit = value.lines.reduce((s, l) => s + (l.credit || 0), 0); if (Math.abs(totalDebit - totalCredit) > 0.01) { return helpers.error('journal.balance', { totalDebit, totalCredit }); } return value; }, 'balance_check') .messages({ 'journal.balance': 'Dubbelbokföring bruten: debet {{#totalDebit}} ≠ kredit {{#totalCredit}}', }), listJournalEntries: Joi.object({ period: Joi.string().pattern(/^\d{4}-\d{2}$/).allow(null, ''), fiscal_year: Joi.number().integer().min(2000).max(2100).allow(null, ''), status: Joi.string().valid('draft', 'posted', 'voided').allow(null, ''), limit: Joi.number().integer().min(1).max(1000).default(50), offset: Joi.number().integer().min(0).default(0), }), postJournalEntry: Joi.object({ id: Joi.string().pattern(UUID_PATTERN).required() .messages({ 'string.pattern.base': 'id måste vara ett giltigt UUID' }), }), // ── Trial balance schemas ───────────────────────────────────────────────── trialBalance: Joi.object({ period: Joi.string().pattern(/^\d{4}-\d{2}$/).allow(null, ''), fiscal_year: Joi.number().integer().min(2000).max(2100).required(), }), // ── Period schemas ──────────────────────────────────────────────────────── listPeriods: Joi.object({ fiscal_year: Joi.number().integer().min(2000).max(2100).allow(null, ''), }), closePeriod: Joi.object({ period: Joi.string().pattern(/^\d{4}-\d{2}$/).required() .messages({ 'string.pattern.base': 'period måste vara YYYY-MM' }), fiscal_year: Joi.number().integer().min(2000).max(2100).allow(null), }), }; // ── Custom error messages ─────────────────────────────────────────────────── export const validationMessages = { 'journal.line.debit_credit': 'Varje journalrad måste ha antingen debit ELLER credit', 'journal.balance': 'Dubbelbokföring bruten: debet {{#totalDebit}} ≠ kredit {{#totalCredit}}', }; // ── Middleware factory ────────────────────────────────────────────────────── export function validate(schema) { return (req, res, next) => { // Samla data från body, query, och params const data = { ...req.body, ...req.query, ...req.params, }; const { error, value } = schema.validate(data, { abortEarly: false, stripUnknown: true, allowUnknown: true, }); if (error) { const details = error.details.map(d => ({ field: d.path.join('.'), message: d.message, })); return res.status(400).json({ ok: false, error: 'Valideringsfel', details, }); } // Spara validerad data tillbaka till request req.validated = value; next(); }; } // ── Sanitizer helpers ─────────────────────────────────────────────────────── export function sanitizeString(str, maxLength = 1000) { if (typeof str !== 'string') return str; return str .replace(/[<>]/g, '') .trim() .slice(0, maxLength); } export function sanitizeMetadata(obj) { if (typeof obj !== 'object' || obj === null) return {}; // Ta bort potentiellt farliga nycklar const dangerousKeys = ['__proto__', 'constructor', 'prototype']; const cleaned = {}; for (const [key, value] of Object.entries(obj)) { if (dangerousKeys.includes(key)) continue; if (typeof value === 'string') { cleaned[key] = sanitizeString(value, 5000); } else if (typeof value === 'object' && value !== null) { cleaned[key] = sanitizeMetadata(value); } else { cleaned[key] = value; } } return cleaned; } export default { schemas, validate, sanitizeString, sanitizeMetadata };