Files
boc/aamos-ledger/validation.mjs
T

185 lines
8.2 KiB
JavaScript
Raw Normal View History

/**
* ═══════════════════════════════════════════════════════════════════════════
* 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 };