Updated GL System , Updated user manual

This commit is contained in:
2026-06-08 10:55:42 -05:00
parent 63d767c6b0
commit 2a254489a3
6 changed files with 964 additions and 10 deletions

View File

@@ -416,6 +416,28 @@ function initialize() {
UNIQUE(entity_id, code)
);
CREATE TABLE IF NOT EXISTS gl_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER REFERENCES entities(id),
book_code TEXT NOT NULL,
entry_date TEXT NOT NULL,
fiscal_year INTEGER,
account TEXT NOT NULL,
account_type TEXT,
description TEXT,
reference TEXT,
debit REAL NOT NULL DEFAULT 0,
credit REAL NOT NULL DEFAULT 0,
asset_id INTEGER REFERENCES assets(id) ON DELETE SET NULL,
external_id TEXT,
source TEXT NOT NULL DEFAULT 'manual',
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_gl_entries_scope ON gl_entries (entity_id, book_code, fiscal_year);
CREATE INDEX IF NOT EXISTS idx_gl_entries_extid ON gl_entries (entity_id, book_code, external_id);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,

View File

@@ -1,9 +1,15 @@
const express = require('express');
const multer = require('multer');
const { requireCapability } = require('../middleware/auth');
const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook } = require('../services/books');
const { exportReport } = require('../services/reportExport');
const { rowsFromImport, extensionForUpload } = require('../services/importExport');
const glEntries = require('../services/glEntries');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
const ledgerReader = requireCapability('finance.view');
const ledgerEditor = requireCapability('finance.manage');
router.get('/books', (req, res) => {
res.json(listBooks(req.companyId));
@@ -43,4 +49,73 @@ router.post('/books/:code/ledger/export', async (req, res) => {
return res.send(output.body);
});
// ---- GL journal entries (stored, editable; full audit) ----------------------
function glFilters(query, code) {
return { book: code, year: query.year ? Number(query.year) : null, account: query.account || null, search: query.search || null };
}
router.get('/books/:code/gl-entries', ledgerReader, (req, res) => {
res.json(glEntries.listEntries(glFilters(req.query, req.params.code), req.companyId));
});
router.post('/books/:code/gl-entries', ledgerEditor, (req, res, next) => {
try {
res.status(201).json({ entry: glEntries.createEntry(req.params.code, req.body, req.user.id, req.companyId) });
} catch (error) {
next(error);
}
});
router.put('/books/:code/gl-entries/:id', ledgerEditor, (req, res, next) => {
try {
const entry = glEntries.updateEntry(req.params.id, req.body, req.user.id, req.companyId);
if (!entry) return res.status(404).json({ error: 'GL entry not found' });
return res.json({ entry });
} catch (error) {
return next(error);
}
});
router.delete('/books/:code/gl-entries/:id', ledgerEditor, (req, res) => {
if (!glEntries.deleteEntry(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'GL entry not found' });
return res.status(204).end();
});
// Per-entry change timeline (from the audit log).
router.get('/books/:code/gl-entries/:id/history', ledgerReader, (req, res) => {
const history = glEntries.entryHistory(req.params.id, req.companyId);
if (!history) return res.status(404).json({ error: 'GL entry not found' });
return res.json({ history });
});
// Dry-run: parse the uploaded CSV/Excel and classify each row (new/update/duplicate/error).
router.post('/books/:code/gl-entries/import/preview', ledgerEditor, upload.single('file'), async (req, res, next) => {
try {
if (!req.file) return res.status(400).json({ error: 'File is required' });
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
res.json(glEntries.previewImport(rows, req.params.code, req.companyId));
} catch (error) {
return next(error);
}
});
// Commit the classified rows from the preview using the chosen conflict strategy.
router.post('/books/:code/gl-entries/import/commit', ledgerEditor, (req, res, next) => {
try {
res.json(glEntries.commitImport(req.body.rows || [], req.body.strategy, req.params.code, req.user.id, req.companyId));
} catch (error) {
return next(error);
}
});
router.get('/books/:code/gl-entries/export', ledgerReader, async (req, res) => {
const report = glEntries.entriesReport(glFilters(req.query, req.params.code), req.companyId);
const format = ['csv', 'xlsx'].includes(String(req.query.format).toLowerCase()) ? String(req.query.format).toLowerCase() : 'csv';
const output = await exportReport(report, format);
res.setHeader('Content-Type', output.contentType);
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${req.params.code}-gl-entries.${format}"`);
return res.send(output.body);
});
module.exports = router;

View File

@@ -0,0 +1,286 @@
const { all, audit, now, one, parseJson, run, tx } = require('../db');
// Stored, editable general-ledger journal entries per book/company. Independent of the computed
// depreciation rollup (bookLedger) — this is a manual/imported journal layer. Every change is logged
// through audit() with full before/after JSON for the Activity & Audit Trail.
function round2(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}
function num(value) {
if (value === '' || value === null || value === undefined) return 0;
const n = Number(String(value).replace(/[$,]/g, ''));
return Number.isFinite(n) ? n : 0;
}
function strOrNull(value) {
const s = value === null || value === undefined ? '' : String(value).trim();
return s || null;
}
function yearOf(dateStr) {
return dateStr ? Number(String(dateStr).slice(0, 4)) || null : null;
}
function entryFromRow(row) {
return { ...row };
}
// ---- Read -------------------------------------------------------------------
function listEntries(filters = {}, companyId) {
const entries = all(
`SELECT g.*, a.asset_id AS asset_code
FROM gl_entries g
LEFT JOIN assets a ON a.id = g.asset_id
WHERE g.entity_id = ? AND g.book_code = ?
AND (? IS NULL OR g.fiscal_year = ?)
AND (? IS NULL OR g.account = ?)
AND (? IS NULL OR (g.account LIKE '%' || ? || '%' OR g.description LIKE '%' || ? || '%' OR g.reference LIKE '%' || ? || '%'))
ORDER BY g.entry_date DESC, g.id DESC
LIMIT 2000`,
[
companyId, filters.book,
filters.year || null, filters.year || null,
filters.account || null, filters.account || null,
filters.search || null, filters.search || null, filters.search || null, filters.search || null
]
).map(entryFromRow);
const debit = round2(entries.reduce((s, e) => s + Number(e.debit || 0), 0));
const credit = round2(entries.reduce((s, e) => s + Number(e.credit || 0), 0));
return { entries, summary: { count: entries.length, debit, credit, balance: round2(debit - credit) } };
}
function getEntry(id, companyId) {
return one('SELECT * FROM gl_entries WHERE id = ? AND entity_id = ?', [id, companyId]) || null;
}
// ---- Normalize / validate ---------------------------------------------------
// Tolerant field picker: matches a parsed row's keys case/space/underscore-insensitively.
function pick(raw, ...aliases) {
const map = {};
for (const key of Object.keys(raw || {})) map[key.toLowerCase().replace(/[\s_]+/g, '')] = raw[key];
for (const alias of aliases) {
const value = map[alias.toLowerCase().replace(/[\s_]+/g, '')];
if (value !== undefined && value !== null && value !== '') return value;
}
return undefined;
}
// Map a raw import row (tolerant headers) to the normalized GL entry shape.
function normalizeRow(raw) {
const entry_date = String(pick(raw, 'entry_date', 'date', 'posting_date', 'posted') || '').slice(0, 10);
return {
entry_date,
fiscal_year: yearOf(entry_date),
account: String(pick(raw, 'account', 'gl_account', 'account_number', 'acct') || '').trim(),
account_type: strOrNull(pick(raw, 'account_type', 'type', 'account_name')),
description: strOrNull(pick(raw, 'description', 'memo', 'desc', 'narrative')),
reference: strOrNull(pick(raw, 'reference', 'ref', 'document', 'doc')),
debit: round2(num(pick(raw, 'debit', 'dr'))),
credit: round2(num(pick(raw, 'credit', 'cr'))),
external_id: strOrNull(pick(raw, 'external_id', 'id', 'gl_id', 'entry_id'))
};
}
function entryError(entry) {
if (!entry.account) return 'Missing account';
if (!entry.entry_date) return 'Missing entry date';
if (round2(entry.debit) === 0 && round2(entry.credit) === 0) return 'Debit or credit is required';
return null;
}
// Validate + coerce a normalized entry coming from the CRUD API.
function validateEntry(body) {
const entry = {
entry_date: String(body.entry_date || '').slice(0, 10),
account: String(body.account || '').trim(),
account_type: strOrNull(body.account_type),
description: strOrNull(body.description),
reference: strOrNull(body.reference),
debit: round2(num(body.debit)),
credit: round2(num(body.credit)),
external_id: strOrNull(body.external_id),
asset_id: body.asset_id ? Number(body.asset_id) : null
};
entry.fiscal_year = yearOf(entry.entry_date);
const err = entryError(entry);
if (err) throw Object.assign(new Error(err), { status: 400 });
return entry;
}
// ---- Write (CRUD) -----------------------------------------------------------
function insertEntry(entry, book, userId, companyId, source) {
const ts = now();
const result = run(
`INSERT INTO gl_entries (entity_id, book_code, entry_date, fiscal_year, account, account_type, description, reference, debit, credit, asset_id, external_id, source, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
companyId, book, entry.entry_date, entry.fiscal_year, entry.account, entry.account_type,
entry.description, entry.reference, entry.debit, entry.credit, entry.asset_id || null,
entry.external_id, source || 'manual', userId, ts, ts
]
);
const created = getEntry(result.lastInsertRowid, companyId);
audit(userId, 'create', 'gl_entry', result.lastInsertRowid, null, created);
return created;
}
function createEntry(book, body, userId, companyId) {
return insertEntry(validateEntry(body), book, userId, companyId, 'manual');
}
function updateEntry(id, body, userId, companyId) {
const before = getEntry(id, companyId);
if (!before) return null;
const entry = validateEntry({ ...before, ...body });
run(
`UPDATE gl_entries SET entry_date = ?, fiscal_year = ?, account = ?, account_type = ?, description = ?,
reference = ?, debit = ?, credit = ?, asset_id = ?, external_id = ?, updated_at = ? WHERE id = ?`,
[
entry.entry_date, entry.fiscal_year, entry.account, entry.account_type, entry.description,
entry.reference, entry.debit, entry.credit, entry.asset_id || null, entry.external_id, now(), id
]
);
const after = getEntry(id, companyId);
audit(userId, 'update', 'gl_entry', id, before, after);
return after;
}
function deleteEntry(id, userId, companyId) {
const before = getEntry(id, companyId);
if (!before) return false;
run('DELETE FROM gl_entries WHERE id = ?', [id]);
audit(userId, 'delete', 'gl_entry', id, before, null);
return true;
}
// ---- Import (preview + commit) ----------------------------------------------
// Find an existing entry an import row should reconcile with: by external_id first, else the natural
// key (date + account + debit + credit + reference) within the same company + book.
function matchExisting(entry, companyId, book) {
if (entry.external_id) {
const byId = one('SELECT * FROM gl_entries WHERE entity_id = ? AND book_code = ? AND external_id = ?', [companyId, book, entry.external_id]);
if (byId) return byId;
}
return one(
`SELECT * FROM gl_entries
WHERE entity_id = ? AND book_code = ? AND entry_date = ? AND account = ?
AND ROUND(debit, 2) = ? AND ROUND(credit, 2) = ? AND IFNULL(reference, '') = IFNULL(?, '')`,
[companyId, book, entry.entry_date, entry.account, entry.debit, entry.credit, entry.reference]
);
}
function differs(existing, entry) {
return existing.entry_date !== entry.entry_date
|| existing.account !== entry.account
|| (existing.account_type || '') !== (entry.account_type || '')
|| (existing.description || '') !== (entry.description || '')
|| (existing.reference || '') !== (entry.reference || '')
|| round2(existing.debit) !== entry.debit
|| round2(existing.credit) !== entry.credit;
}
// Classify each raw import row as new / update / duplicate / error (no DB writes).
function previewImport(rawRows, book, companyId) {
const summary = { new: 0, update: 0, duplicate: 0, error: 0 };
const rows = (rawRows || []).map((raw, index) => {
const entry = normalizeRow(raw);
const err = entryError(entry);
if (err) { summary.error += 1; return { index, status: 'error', entry, error: err }; }
const existing = matchExisting(entry, companyId, book);
if (!existing) { summary.new += 1; return { index, status: 'new', entry, existing_id: null }; }
if (differs(existing, entry)) { summary.update += 1; return { index, status: 'update', entry, existing_id: existing.id }; }
summary.duplicate += 1;
return { index, status: 'duplicate', entry, existing_id: existing.id };
});
return { summary, rows };
}
// Apply classified rows by strategy. Re-validates company/book server-side.
// skip — insert only 'new'; conflicts skipped
// merge — insert 'new', update 'update'; identical duplicates skipped
// insert — insert every non-error row as a brand-new entry (ignore matches)
function commitImport(rows, strategy, book, userId, companyId) {
const result = { inserted: 0, updated: 0, skipped: 0, errors: 0 };
const mode = ['skip', 'merge', 'insert'].includes(strategy) ? strategy : 'merge';
tx(() => {
for (const row of rows || []) {
const entry = normalizeRow(row.entry || {});
if (entryError(entry)) { result.errors += 1; continue; }
const status = row.status;
if (mode === 'insert') { insertEntry(entry, book, userId, companyId, 'import'); result.inserted += 1; continue; }
if (status === 'new') { insertEntry(entry, book, userId, companyId, 'import'); result.inserted += 1; continue; }
if (mode === 'merge' && status === 'update') {
const existing = row.existing_id ? getEntry(row.existing_id, companyId) : matchExisting(entry, companyId, book);
if (existing) { updateEntry(existing.id, entry, userId, companyId); result.updated += 1; }
else { insertEntry(entry, book, userId, companyId, 'import'); result.inserted += 1; }
continue;
}
result.skipped += 1; // duplicates, or conflicts under 'skip'
}
});
audit(userId, 'import', 'gl_entries', book, null, { strategy: mode, ...result });
return result;
}
// ---- Export -----------------------------------------------------------------
function entriesReport(filters, companyId) {
const { entries, summary } = listEntries(filters, companyId);
return {
title: `${filters.book} general ledger entries${filters.year ? `${filters.year}` : ''}`,
generatedAt: now(),
columns: [
{ key: 'entry_date', label: 'Date', format: 'text' },
{ key: 'account', label: 'Account', format: 'text' },
{ key: 'account_type', label: 'Account type', format: 'text' },
{ key: 'description', label: 'Description', format: 'text' },
{ key: 'reference', label: 'Reference', format: 'text' },
{ key: 'debit', label: 'Debit', format: 'currency' },
{ key: 'credit', label: 'Credit', format: 'currency' },
{ key: 'external_id', label: 'External ID', format: 'text' },
{ key: 'source', label: 'Source', format: 'text' }
],
rows: entries,
totals: { debit: summary.debit, credit: summary.credit },
meta: { note: `${summary.count} entr${summary.count === 1 ? 'y' : 'ies'} · debit ${summary.debit} · credit ${summary.credit} · balance ${summary.balance}` }
};
}
// Full change timeline for one entry, drawn from the audit log (create/update events, and any
// import-driven changes — all share entity_type 'gl_entry'). Returns null if the entry isn't in
// this company (so it can't be probed cross-company). Newest first.
function entryHistory(id, companyId) {
if (!getEntry(id, companyId)) return null;
return all(
`SELECT al.id, al.action, al.before_json, al.after_json, al.created_at, u.name AS actor_name
FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id
WHERE al.entity_type = 'gl_entry' AND al.entity_id = ?
ORDER BY al.id DESC`,
[String(id)]
).map((row) => ({
id: row.id,
action: row.action,
actor_name: row.actor_name,
created_at: row.created_at,
before: parseJson(row.before_json, null),
after: parseJson(row.after_json, null)
}));
}
module.exports = {
commitImport,
createEntry,
deleteEntry,
entriesReport,
entryHistory,
getEntry,
listEntries,
previewImport,
updateEntry
};

View File

@@ -1784,6 +1784,61 @@ async function main() {
})).assignment;
if (crossEmpAssign.employee_id) throw new Error('A company A assignment accepted a company B employee');
// ---- GL journal entries: CRUD + audit + import (conflict/merge) + export ----
const glEntry = (await request('/api/books/GAAP/gl-entries', {
method: 'POST', headers: companyHeaders(companyA),
body: JSON.stringify({ entry_date: '2026-03-01', account: '1500', account_type: 'Asset', description: 'Equipment', reference: 'INV-1', debit: 1000, credit: 0, external_id: `GL-${suffix}-100` })
})).entry;
if (!glEntry || glEntry.entity_id !== companyA) throw new Error('GL entry was not created/scoped');
const glListed = (await request('/api/books/GAAP/gl-entries?year=2026', { headers: companyHeaders(companyA) }));
if (!glListed.entries.some((e) => e.id === glEntry.id) || glListed.summary.debit < 1000) throw new Error('GL entry did not list with totals');
// Update it (full before/after must be audited).
await request(`/api/books/GAAP/gl-entries/${glEntry.id}`, {
method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ debit: 1200, description: 'Equipment (revised)' })
});
const glAudit = await request('/api/audit-logs?entity_type=gl_entry', { headers: auth });
if (glAudit.total < 2) throw new Error('GL changes were not audited');
const updateLog = glAudit.rows.find((r) => r.action === 'update' && r.entity_id === String(glEntry.id));
if (!updateLog || !updateLog.before_json || !updateLog.after_json) throw new Error('GL update did not capture full before/after');
// Import preview: one row matches the existing entry's external id (update), one is brand new.
const csv = `Date,Account,Account Type,Description,Reference,Debit,Credit,External Id\n` +
`2026-03-01,1500,Asset,Equipment changed,INV-1,2000,0,GL-${suffix}-100\n` +
`2026-03-05,6400,Expense,Depreciation,INV-2,0,250,GL-${suffix}-200\n`;
const previewForm = new FormData();
previewForm.append('file', new Blob([csv], { type: 'text/csv' }), 'gl.csv');
const glPreview = await (await fetch(`${baseUrl}/api/books/GAAP/gl-entries/import/preview`, { method: 'POST', headers: companyHeaders(companyA), body: previewForm })).json();
if (glPreview.summary.new !== 1 || glPreview.summary.update !== 1) {
throw new Error(`Import preview misclassified rows: ${JSON.stringify(glPreview.summary)}`);
}
// Commit with merge: insert the new row, update the matched one.
const glCommit = await request('/api/books/GAAP/gl-entries/import/commit', {
method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ rows: glPreview.rows, strategy: 'merge' })
});
if (glCommit.inserted !== 1 || glCommit.updated !== 1) throw new Error(`Import commit applied wrong counts: ${JSON.stringify(glCommit)}`);
// Export CSV + Excel.
const glCsv = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/export?year=2026&format=csv`, { headers: companyHeaders(companyA) });
if (!(glCsv.headers.get('content-type') || '').includes('text/csv')) throw new Error('GL CSV export was not text/csv');
if (!(await glCsv.text()).startsWith('Date,Account')) throw new Error('GL CSV header was malformed');
const glXlsx = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/export?year=2026&format=xlsx`, { headers: companyHeaders(companyA) });
if (!(glXlsx.headers.get('content-type') || '').includes('spreadsheet')) throw new Error('GL Excel export had the wrong content-type');
// Per-entry change history (create + update events with before/after).
const glHistory = (await request(`/api/books/GAAP/gl-entries/${glEntry.id}/history`, { headers: companyHeaders(companyA) })).history;
if (glHistory.length < 2 || !glHistory.some((h) => h.action === 'update' && h.before && h.after)) {
throw new Error('GL entry history did not return the create + update timeline');
}
// History is company-scoped (company B can't read company A's entry history).
const crossHistory = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/${glEntry.id}/history`, { headers: companyHeaders(companyB) });
if (crossHistory.status !== 404) throw new Error('Cross-company GL history should 404');
// Isolation: company B's GAAP book sees none of company A's entries.
const glB = (await request('/api/books/GAAP/gl-entries', { headers: companyHeaders(companyB) })).entries;
if (glB.some((e) => e.id === glEntry.id)) throw new Error('Company B sees company A GL entries');
console.log('Smoke test passed');
}