185 lines
8.2 KiB
JavaScript
185 lines
8.2 KiB
JavaScript
|
|
/**
|
||
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
* 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 };
|