ea8f6bc88a
The #4 cost side used 'date(COALESCE(invoice_date, created_at)) BETWEEN ...' and 'date(created_at) BETWEEN ...'. The mocked unit tests never execute the SQL, so the Postgres failure (date()/COALESCE(date,timestamp)) slipped through and surfaced as a 500 on the live tax report. Replaced with plain range comparisons (col >= from AND col <= '<to> 23:59:59.999') — valid on both PG and SQLite, inclusive of the whole end day. Same fix applied to ledgerService buildPostings (the Treuhänder export would have 500'd identically).
449 lines
23 KiB
JavaScript
449 lines
23 KiB
JavaScript
/**
|
|
* ledgerService — Accounting Layer A: chart of accounts + VAT codes + a
|
|
* Treuhänder export.
|
|
*
|
|
* picpeak is NOT a double-entry ledger (that's Layer B). This service:
|
|
* 1. CRUD for `ledger_accounts` (Swiss/LI KMU-Kontenrahmen) + `vat_codes`,
|
|
* plus the category→account and tax_treatment→VAT-code mappings.
|
|
* 2. buildPostings(): turns the data we already capture (revenue invoices,
|
|
* incoming supplier invoices, internal expenses) into balanced
|
|
* "Buchungssätze" — accrual-dated, single-row Soll/Haben entries with a
|
|
* VAT code the target software expands.
|
|
* 3. Export formatters (generic / Banana / bexio) so a Treuhänder can import
|
|
* the collective journal.
|
|
*
|
|
* Accrual basis only — payment/bank postings are Layer B (bank reconciliation).
|
|
* Legal/financial output is a GUIDELINE: every surface must point the user at a
|
|
* Treuhänder ([[feedback_legal_financial_examples_only]]).
|
|
*/
|
|
|
|
const { db, withRetry } = require('../database/db');
|
|
const { getAppSetting } = require('../utils/appSettings');
|
|
const { buildCustomerLabel } = require('./taxReportService')._internal;
|
|
const { ensureInt } = require('../utils/numericHelpers');
|
|
|
|
const ACCOUNT_TYPES = ['asset', 'liability', 'equity', 'revenue', 'expense'];
|
|
const VAT_DIRECTIONS = ['output', 'input'];
|
|
|
|
// Statuses we book. Mirrors taxReportService: cancelled originals are excluded
|
|
// (the storno reissue, a negative-total row, carries the reversal).
|
|
const REVENUE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery'];
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────
|
|
function rateKey(rate) {
|
|
// Normalise 8.10 → '8.1', 0 → '0' so it matches the seeded output-VAT map.
|
|
const n = Number(rate);
|
|
if (!Number.isFinite(n)) return '0';
|
|
return String(Number(n.toFixed(2)));
|
|
}
|
|
|
|
/**
|
|
* Resolve all the config the posting engine needs in one shot: account
|
|
* lookup maps + VAT-code lookup + the default-account / VAT-mapping settings.
|
|
*/
|
|
async function getConfig() {
|
|
const [accounts, vatCodes] = await Promise.all([
|
|
db('ledger_accounts').select('id', 'number', 'name', 'type', 'active'),
|
|
db('vat_codes').select('id', 'code', 'name', 'rate', 'direction', 'account_id', 'active'),
|
|
]);
|
|
const accountByNumber = new Map(accounts.map((a) => [a.number, a]));
|
|
const accountById = new Map(accounts.map((a) => [a.id, a]));
|
|
|
|
const [
|
|
debitoren, kreditoren, defaultRevenue, defaultExpense, mileage, perDiem, rebilled,
|
|
vatMap, outputVatMap,
|
|
] = await Promise.all([
|
|
getAppSetting('ledger_account_debitoren', '1100'),
|
|
getAppSetting('ledger_account_kreditoren', '2000'),
|
|
getAppSetting('ledger_account_default_revenue', '3400'),
|
|
getAppSetting('ledger_account_default_expense', '6700'),
|
|
getAppSetting('ledger_account_mileage', '6200'),
|
|
getAppSetting('ledger_account_per_diem', '6640'),
|
|
getAppSetting('ledger_account_rebilled_revenue', '3940'),
|
|
getAppSetting('ledger_vat_map', {}),
|
|
getAppSetting('ledger_output_vat_map', {}),
|
|
]);
|
|
|
|
return {
|
|
accounts, vatCodes, accountByNumber, accountById,
|
|
settings: {
|
|
debitoren, kreditoren, defaultRevenue, defaultExpense, mileage, perDiem, rebilled,
|
|
},
|
|
vatMap: vatMap || {},
|
|
outputVatMap: outputVatMap || {},
|
|
};
|
|
}
|
|
|
|
// ── CRUD: accounts ───────────────────────────────────────────────────
|
|
async function listAccounts() {
|
|
return db('ledger_accounts').orderBy('number', 'asc').select('*');
|
|
}
|
|
|
|
async function createAccount({ number, name, type }) {
|
|
if (!number || !name) throw httpError(400, 'number and name are required', 'VALIDATION');
|
|
if (!ACCOUNT_TYPES.includes(type)) throw httpError(400, 'invalid account type', 'VALIDATION');
|
|
const exists = await db('ledger_accounts').where({ number }).first();
|
|
if (exists) throw httpError(409, 'an account with this number already exists', 'DUPLICATE');
|
|
const [row] = await db('ledger_accounts')
|
|
.insert({ number, name, type, is_seed: false, active: true })
|
|
.returning('*');
|
|
return row || db('ledger_accounts').where({ number }).first();
|
|
}
|
|
|
|
async function updateAccount(id, { number, name, type, active }) {
|
|
const patch = { updated_at: new Date() };
|
|
if (number !== undefined) patch.number = number;
|
|
if (name !== undefined) patch.name = name;
|
|
if (type !== undefined) {
|
|
if (!ACCOUNT_TYPES.includes(type)) throw httpError(400, 'invalid account type', 'VALIDATION');
|
|
patch.type = type;
|
|
}
|
|
if (active !== undefined) patch.active = !!active;
|
|
if (patch.number) {
|
|
const clash = await db('ledger_accounts').where({ number: patch.number }).whereNot({ id }).first();
|
|
if (clash) throw httpError(409, 'an account with this number already exists', 'DUPLICATE');
|
|
}
|
|
await db('ledger_accounts').where({ id }).update(patch);
|
|
return db('ledger_accounts').where({ id }).first();
|
|
}
|
|
|
|
/** Hard-delete only when nothing references the account; otherwise tell the
|
|
* caller to deactivate instead (keeps mappings + exports stable). */
|
|
async function deleteAccount(id) {
|
|
const acct = await db('ledger_accounts').where({ id }).first();
|
|
if (!acct) throw httpError(404, 'account not found', 'NOT_FOUND');
|
|
const refs = await accountReferences(acct);
|
|
if (refs.length) throw httpError(409, `account is in use (${refs.join(', ')}) — deactivate it instead`, 'IN_USE');
|
|
await db('ledger_accounts').where({ id }).del();
|
|
return { deleted: true };
|
|
}
|
|
|
|
async function accountReferences(acct) {
|
|
const refs = [];
|
|
const vat = await db('vat_codes').where({ account_id: acct.id }).first();
|
|
if (vat) refs.push('VAT code');
|
|
if (await db.schema.hasColumn('expense_categories', 'ledger_account_id')) {
|
|
const cat = await db('expense_categories').where({ ledger_account_id: acct.id }).first();
|
|
if (cat) refs.push('expense category');
|
|
}
|
|
// Default-account settings reference accounts by NUMBER.
|
|
const settingKeys = ['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
|
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
|
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue'];
|
|
const settingRows = await db('app_settings').whereIn('setting_key', settingKeys).select('setting_value');
|
|
if (settingRows.some((r) => safeParse(r.setting_value) === acct.number)) refs.push('default-account setting');
|
|
return refs;
|
|
}
|
|
|
|
// ── CRUD: VAT codes ──────────────────────────────────────────────────
|
|
async function listVatCodes() {
|
|
return db('vat_codes').orderBy('display_order', 'asc').select('*');
|
|
}
|
|
|
|
async function createVatCode({ code, name, rate, direction, accountId }) {
|
|
if (!code || !name) throw httpError(400, 'code and name are required', 'VALIDATION');
|
|
if (!VAT_DIRECTIONS.includes(direction)) throw httpError(400, 'invalid direction', 'VALIDATION');
|
|
const exists = await db('vat_codes').where({ code }).first();
|
|
if (exists) throw httpError(409, 'a VAT code with this code already exists', 'DUPLICATE');
|
|
const [row] = await db('vat_codes')
|
|
.insert({ code, name, rate: Number(rate) || 0, direction, account_id: accountId || null, is_seed: false, active: true })
|
|
.returning('*');
|
|
return row || db('vat_codes').where({ code }).first();
|
|
}
|
|
|
|
async function updateVatCode(id, { code, name, rate, direction, accountId, active }) {
|
|
const patch = { updated_at: new Date() };
|
|
if (code !== undefined) patch.code = code;
|
|
if (name !== undefined) patch.name = name;
|
|
if (rate !== undefined) patch.rate = Number(rate) || 0;
|
|
if (direction !== undefined) {
|
|
if (!VAT_DIRECTIONS.includes(direction)) throw httpError(400, 'invalid direction', 'VALIDATION');
|
|
patch.direction = direction;
|
|
}
|
|
if (accountId !== undefined) patch.account_id = accountId || null;
|
|
if (active !== undefined) patch.active = !!active;
|
|
if (patch.code) {
|
|
const clash = await db('vat_codes').where({ code: patch.code }).whereNot({ id }).first();
|
|
if (clash) throw httpError(409, 'a VAT code with this code already exists', 'DUPLICATE');
|
|
}
|
|
await db('vat_codes').where({ id }).update(patch);
|
|
return db('vat_codes').where({ id }).first();
|
|
}
|
|
|
|
async function deleteVatCode(id) {
|
|
const vat = await db('vat_codes').where({ id }).first();
|
|
if (!vat) throw httpError(404, 'VAT code not found', 'NOT_FOUND');
|
|
// Referenced by the tax_treatment / output-rate maps?
|
|
const [vatMap, outputVatMap] = await Promise.all([
|
|
getAppSetting('ledger_vat_map', {}), getAppSetting('ledger_output_vat_map', {}),
|
|
]);
|
|
const used = Object.values(vatMap || {}).includes(vat.code) || Object.values(outputVatMap || {}).includes(vat.code);
|
|
if (used) throw httpError(409, 'VAT code is referenced by a mapping — change the mapping first', 'IN_USE');
|
|
await db('vat_codes').where({ id }).del();
|
|
return { deleted: true };
|
|
}
|
|
|
|
// ── mappings (categories + settings) ─────────────────────────────────
|
|
async function getMappings() {
|
|
const hasCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
|
const categories = await db('expense_categories')
|
|
.orderBy('display_order', 'asc')
|
|
.select('id', 'name', 'color', hasCol ? 'ledger_account_id' : db.raw('NULL as ledger_account_id'));
|
|
const settingKeys = ['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
|
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
|
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
|
'ledger_vat_map', 'ledger_output_vat_map'];
|
|
const rows = await db('app_settings').whereIn('setting_key', settingKeys).select('setting_key', 'setting_value');
|
|
const settings = {};
|
|
for (const r of rows) settings[r.setting_key] = safeParse(r.setting_value);
|
|
return { categories, settings };
|
|
}
|
|
|
|
async function setCategoryAccount(categoryId, ledgerAccountId) {
|
|
if (!(await db.schema.hasColumn('expense_categories', 'ledger_account_id'))) {
|
|
throw httpError(409, 'category→account mapping column missing', 'SCHEMA');
|
|
}
|
|
await db('expense_categories').where({ id: categoryId }).update({ ledger_account_id: ledgerAccountId || null });
|
|
return db('expense_categories').where({ id: categoryId }).first();
|
|
}
|
|
|
|
/** Update the ledger_* app_settings (default accounts + VAT maps). Only
|
|
* whitelisted keys; values stored JSON-stringified (matching the store). */
|
|
async function updateSettings(patch) {
|
|
const allowed = new Set(['ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank',
|
|
'ledger_account_cash', 'ledger_account_default_revenue', 'ledger_account_default_expense',
|
|
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
|
'ledger_vat_map', 'ledger_output_vat_map']);
|
|
const updated = [];
|
|
for (const [key, value] of Object.entries(patch || {})) {
|
|
if (!allowed.has(key)) continue;
|
|
const existing = await db('app_settings').where({ setting_key: key }).first();
|
|
if (existing) {
|
|
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
|
|
} else {
|
|
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'accounting' });
|
|
}
|
|
updated.push(key);
|
|
}
|
|
return { updated };
|
|
}
|
|
|
|
// ── posting engine ───────────────────────────────────────────────────
|
|
/**
|
|
* Build the accrual collective journal for [from, to] in `cur`.
|
|
*
|
|
* Returns { postings, currency, period }. Each posting is a single
|
|
* Buchungssatz:
|
|
* { date, docNumber, description, debitAccount, debitName, creditAccount,
|
|
* creditName, grossMinor, netMinor, vatMinor, vatCode, vatRate, source,
|
|
* eventName }
|
|
* Amount is GROSS (the VAT code lets the target software expand net+VAT).
|
|
* `netMinor`/`vatMinor` are included for tooling that imports net amounts.
|
|
*/
|
|
async function buildPostings({ from, to, currency } = {}) {
|
|
if (!from || !to) throw httpError(400, '`from` and `to` are required (YYYY-MM-DD)', 'VALIDATION');
|
|
if (!currency) throw httpError(400, '`currency` is required', 'VALIDATION');
|
|
const cur = String(currency).toUpperCase();
|
|
// Inclusive end-of-day bound; plain range comparison (no SQL date()) so it's
|
|
// valid on both Postgres and SQLite.
|
|
const toEnd = `${to} 23:59:59.999`;
|
|
|
|
return withRetry(async () => {
|
|
const cfg = await getConfig();
|
|
const nameOf = (number) => cfg.accountByNumber.get(number)?.name || '';
|
|
const postings = [];
|
|
|
|
// 1) Revenue invoices → Dr Debitoren / Cr Ertrag (gross, output VAT code).
|
|
const invoices = await db('invoices')
|
|
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
|
|
.leftJoin('events', 'invoices.event_id', 'events.id')
|
|
.whereBetween('invoices.issue_date', [from, to])
|
|
.where('invoices.currency', cur)
|
|
.whereIn('invoices.status', REVENUE_STATUSES)
|
|
.orderBy('invoices.issue_date', 'asc')
|
|
.select(
|
|
'invoices.id', 'invoices.invoice_number', 'invoices.issue_date', 'invoices.vat_rate',
|
|
'invoices.net_amount_minor', 'invoices.vat_amount_minor', 'invoices.total_amount_minor',
|
|
'customer_accounts.company_name as customer_company_name',
|
|
'customer_accounts.first_name as customer_first_name',
|
|
'customer_accounts.last_name as customer_last_name',
|
|
'customer_accounts.display_name as customer_display_name',
|
|
'customer_accounts.email as customer_email',
|
|
db.raw('COALESCE(invoices.event_name, events.event_name) AS event_name'),
|
|
);
|
|
for (const inv of invoices) {
|
|
const revAcct = cfg.settings.defaultRevenue;
|
|
const vatCode = cfg.outputVatMap[rateKey(inv.vat_rate)] || '';
|
|
const label = buildCustomerLabel(inv);
|
|
postings.push({
|
|
date: inv.issue_date,
|
|
docNumber: inv.invoice_number || '',
|
|
description: [inv.invoice_number, label].filter(Boolean).join(' · '),
|
|
debitAccount: cfg.settings.debitoren, debitName: nameOf(cfg.settings.debitoren),
|
|
creditAccount: revAcct, creditName: nameOf(revAcct),
|
|
grossMinor: ensureInt(inv.total_amount_minor),
|
|
netMinor: ensureInt(inv.net_amount_minor),
|
|
vatMinor: ensureInt(inv.vat_amount_minor),
|
|
vatCode, vatRate: Number(inv.vat_rate) || 0,
|
|
source: 'revenue', eventName: inv.event_name || '',
|
|
});
|
|
}
|
|
|
|
// 2) Incoming supplier invoices → Dr Aufwand / Cr Kreditoren (input VAT).
|
|
if (await db.schema.hasTable('inbound_documents')) {
|
|
const hasCatCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
|
const inbound = await db('inbound_documents')
|
|
.leftJoin('events', 'inbound_documents.event_id', 'events.id')
|
|
.modify((q) => {
|
|
if (hasCatCol) q.leftJoin('expense_categories', 'inbound_documents.category_id', 'expense_categories.id');
|
|
})
|
|
.whereRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) >= ? AND COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) <= ?', [from, toEnd])
|
|
.where('inbound_documents.currency', cur)
|
|
.whereNotIn('inbound_documents.status', ['declined', 'duplicate'])
|
|
.orderByRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) asc')
|
|
.select(
|
|
'inbound_documents.id', 'inbound_documents.invoice_number', 'inbound_documents.invoice_date',
|
|
'inbound_documents.created_at', 'inbound_documents.supplier_name', 'inbound_documents.tax_treatment',
|
|
'inbound_documents.net_amount_minor', 'inbound_documents.vat_amount_minor', 'inbound_documents.total_amount_minor',
|
|
'inbound_documents.event_id',
|
|
hasCatCol ? 'expense_categories.ledger_account_id as cat_account_id' : db.raw('NULL as cat_account_id'),
|
|
'events.event_name as event_name',
|
|
);
|
|
for (const d of inbound) {
|
|
const acctNumber = cfg.accountById.get(d.cat_account_id)?.number || cfg.settings.defaultExpense;
|
|
const vatCode = cfg.vatMap[d.tax_treatment || 'domestic'] || '';
|
|
const gross = ensureInt(d.total_amount_minor) || (ensureInt(d.net_amount_minor) + ensureInt(d.vat_amount_minor));
|
|
postings.push({
|
|
date: d.invoice_date || d.created_at,
|
|
docNumber: d.invoice_number || '',
|
|
description: [d.supplier_name, d.invoice_number].filter(Boolean).join(' · '),
|
|
debitAccount: acctNumber, debitName: nameOf(acctNumber),
|
|
creditAccount: cfg.settings.kreditoren, creditName: nameOf(cfg.settings.kreditoren),
|
|
grossMinor: gross,
|
|
netMinor: ensureInt(d.net_amount_minor) || (gross - ensureInt(d.vat_amount_minor)),
|
|
vatMinor: ensureInt(d.vat_amount_minor),
|
|
vatCode, vatRate: 0,
|
|
source: 'incoming', eventName: d.event_id ? (d.event_name || '') : '',
|
|
});
|
|
}
|
|
}
|
|
|
|
// 3) Internal expenses → Dr Aufwand / Cr Kreditoren (input VAT).
|
|
if (await db.schema.hasTable('expenses')) {
|
|
const hasCatCol = await db.schema.hasColumn('expense_categories', 'ledger_account_id');
|
|
const expenses = await db('expenses')
|
|
.leftJoin('events', 'expenses.event_id', 'events.id')
|
|
.modify((q) => {
|
|
if (hasCatCol) q.leftJoin('expense_categories', 'expenses.category_id', 'expense_categories.id');
|
|
})
|
|
.whereRaw('expenses.created_at >= ? AND expenses.created_at <= ?', [from, toEnd])
|
|
.whereNot('expenses.status', 'declined')
|
|
.whereNotIn('expenses.disposition', ['duplikat', 'abgelehnt'])
|
|
.modify((q) => { if (cur !== 'CHF') q.where('expenses.original_currency', cur); })
|
|
.orderBy('expenses.created_at', 'asc')
|
|
.select(
|
|
'expenses.id', 'expenses.created_at', 'expenses.kind', 'expenses.supplier_name', 'expenses.description',
|
|
'expenses.tax_treatment', 'expenses.event_id',
|
|
'expenses.original_amount_minor', 'expenses.chf_amount_minor',
|
|
'expenses.net_amount_minor', 'expenses.vat_amount_minor', 'expenses.gross_amount_minor',
|
|
hasCatCol ? 'expense_categories.ledger_account_id as cat_account_id' : db.raw('NULL as cat_account_id'),
|
|
'events.event_name as event_name',
|
|
);
|
|
const isChf = cur === 'CHF';
|
|
for (const e of expenses) {
|
|
// Account: category mapping → kind default (mileage/per-diem) → default expense.
|
|
let acctNumber = cfg.accountById.get(e.cat_account_id)?.number;
|
|
if (!acctNumber && e.kind === 'mileage') acctNumber = cfg.settings.mileage;
|
|
if (!acctNumber && e.kind === 'per_diem') acctNumber = cfg.settings.perDiem;
|
|
if (!acctNumber) acctNumber = cfg.settings.defaultExpense;
|
|
const vatCode = cfg.vatMap[e.tax_treatment || 'domestic'] || '';
|
|
const base = isChf ? ensureInt(e.chf_amount_minor) : ensureInt(e.original_amount_minor);
|
|
const gross = ensureInt(e.gross_amount_minor) || ((ensureInt(e.net_amount_minor) || ensureInt(e.vat_amount_minor)) ? ensureInt(e.net_amount_minor) + ensureInt(e.vat_amount_minor) : base);
|
|
postings.push({
|
|
date: e.created_at,
|
|
docNumber: `EXP-${e.id}`,
|
|
description: e.description || e.supplier_name || `Expense #${e.id}`,
|
|
debitAccount: acctNumber, debitName: nameOf(acctNumber),
|
|
creditAccount: cfg.settings.kreditoren, creditName: nameOf(cfg.settings.kreditoren),
|
|
grossMinor: gross,
|
|
netMinor: ensureInt(e.net_amount_minor) || (gross - ensureInt(e.vat_amount_minor)),
|
|
vatMinor: ensureInt(e.vat_amount_minor),
|
|
vatCode, vatRate: 0,
|
|
source: 'expense', eventName: e.event_id ? (e.event_name || '') : '',
|
|
});
|
|
}
|
|
}
|
|
|
|
postings.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
|
|
return { postings, currency: cur, period: { from, to } };
|
|
});
|
|
}
|
|
|
|
// ── export formatters ────────────────────────────────────────────────
|
|
function csvEscape(cell) {
|
|
const s = cell === null || cell === undefined ? '' : String(cell);
|
|
return `"${s.replace(/"/g, '""')}"`;
|
|
}
|
|
function minorToDecimal(m) { return ((Number(m) || 0) / 100).toFixed(2); }
|
|
function dateOnly(d) { return String(d || '').slice(0, 10); }
|
|
|
|
const EXPORT_FORMATS = ['generic', 'banana', 'bexio'];
|
|
|
|
/**
|
|
* Render the collective journal in the requested format. Returns
|
|
* { content, filename, contentType }. All are single-row Soll/Haben
|
|
* ("two-account") layouts with a VAT-code column — the universal Swiss
|
|
* import shape Banana + bexio both accept.
|
|
*/
|
|
async function exportPostings({ from, to, currency, format = 'generic' } = {}) {
|
|
const fmt = EXPORT_FORMATS.includes(format) ? format : 'generic';
|
|
const { postings, currency: cur, period } = await buildPostings({ from, to, currency });
|
|
const eol = '\r\n';
|
|
let headers; let rowOf;
|
|
|
|
if (fmt === 'banana') {
|
|
// Banana "Conti doppia" import: Date, Doc, Description, AccountDebit,
|
|
// AccountCredit, Amount, VatCode. Amount = gross; VatCode expands VAT.
|
|
headers = ['Date', 'Doc', 'Description', 'AccountDebit', 'AccountCredit', 'Amount', 'VatCode'];
|
|
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode];
|
|
} else if (fmt === 'bexio') {
|
|
// bexio manual-entry import.
|
|
headers = ['date', 'reference_nr', 'description', 'debit_account', 'credit_account', 'amount', 'tax_code', 'currency'];
|
|
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.debitAccount, p.creditAccount, minorToDecimal(p.grossMinor), p.vatCode, cur];
|
|
} else {
|
|
// Generic — every column a human or any tool could want.
|
|
headers = ['Date', 'DocNumber', 'Description', 'Source', 'Event',
|
|
'DebitAccount', 'DebitAccountName', 'CreditAccount', 'CreditAccountName',
|
|
'VatCode', 'Currency', 'GrossAmount', 'NetAmount', 'VatAmount'];
|
|
rowOf = (p) => [dateOnly(p.date), p.docNumber, p.description, p.source, p.eventName,
|
|
p.debitAccount, p.debitName, p.creditAccount, p.creditName,
|
|
p.vatCode, cur, minorToDecimal(p.grossMinor), minorToDecimal(p.netMinor), minorToDecimal(p.vatMinor)];
|
|
}
|
|
|
|
const lines = [headers.map(csvEscape).join(',')];
|
|
for (const p of postings) lines.push(rowOf(p).map(csvEscape).join(','));
|
|
const content = lines.join(eol) + eol;
|
|
const filename = `journal_${period.from}_to_${period.to}_${cur}_${fmt}.csv`;
|
|
return { content, filename, contentType: 'text/csv; charset=utf-8', count: postings.length };
|
|
}
|
|
|
|
// ── small util ───────────────────────────────────────────────────────
|
|
function httpError(status, message, code) {
|
|
const err = new Error(message);
|
|
err.status = status; err.statusCode = status; err.code = code;
|
|
return err;
|
|
}
|
|
function safeParse(v) {
|
|
if (v == null) return null;
|
|
try { return JSON.parse(v); } catch (_) { return v; }
|
|
}
|
|
|
|
module.exports = {
|
|
ACCOUNT_TYPES, VAT_DIRECTIONS, EXPORT_FORMATS,
|
|
listAccounts, createAccount, updateAccount, deleteAccount,
|
|
listVatCodes, createVatCode, updateVatCode, deleteVatCode,
|
|
getMappings, setCategoryAccount, updateSettings,
|
|
getConfig, buildPostings, exportPostings,
|
|
_internal: { rateKey, csvEscape, minorToDecimal },
|
|
};
|