From 2a254489a3544f9a4ae8f8794e5d51a6078e16e6 Mon Sep 17 00:00:00 2001
From: mpuckett
Date: Mon, 8 Jun 2026 10:55:42 -0500
Subject: [PATCH] Updated GL System , Updated user manual
---
server/db.js | 22 +++
server/routes/books.js | 75 ++++++++
server/services/glEntries.js | 286 +++++++++++++++++++++++++++++
server/smoke-test.js | 55 ++++++
src/components/UserManual.vue | 207 ++++++++++++++++++++-
src/views/BooksView.vue | 329 +++++++++++++++++++++++++++++++++-
6 files changed, 964 insertions(+), 10 deletions(-)
create mode 100644 server/services/glEntries.js
diff --git a/server/db.js b/server/db.js
index d41d4ad..e5f7448 100644
--- a/server/db.js
+++ b/server/db.js
@@ -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,
diff --git a/server/routes/books.js b/server/routes/books.js
index 7fdd5fe..d736e44 100644
--- a/server/routes/books.js
+++ b/server/routes/books.js
@@ -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;
diff --git a/server/services/glEntries.js b/server/services/glEntries.js
new file mode 100644
index 0000000..3aa6c1e
--- /dev/null
+++ b/server/services/glEntries.js
@@ -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
+};
diff --git a/server/smoke-test.js b/server/smoke-test.js
index b41fbd4..642b378 100644
--- a/server/smoke-test.js
+++ b/server/smoke-test.js
@@ -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');
}
diff --git a/src/components/UserManual.vue b/src/components/UserManual.vue
index 0d279f2..4033527 100644
--- a/src/components/UserManual.vue
+++ b/src/components/UserManual.vue
@@ -266,8 +266,34 @@
Select a book to view its GL ledger in debit/credit format — asset cost, accumulated depreciation, and the
period’s depreciation expense rolled to GL accounts, with per-asset detail and a summary. You can export the ledger.
+ This rollup is computed automatically from depreciation; the Journal entries panel below it is a separate,
+ editable layer (the two do not double-count).
+ GL journal entries — add, edit, import & track changes
+
+ Below the computed ledger, the Journal entries panel holds stored, editable GL postings for the selected
+ book and year. Use it to record manual journal lines or to load entries from another system.
+
+
+ - Add / edit / delete. Click Add entry (or the pencil/trash on a row) to capture a
+ date, GL account, account type, description, reference, debit and/or credit, and an
+ optional external ID. The panel shows running debit, credit, and balance totals.
+ - Import (CSV or Excel/XLS). Click Import and choose a
.csv,
+ .xlsx, or .xls file. Column headers are matched flexibly (Date, Account, Debit, Credit,
+ Reference, Description, External Id, …).
+ - Conflict / merge resolution. The import first shows a preview that classifies every
+ row as New, Update (matches an existing entry but differs), Duplicate (identical), or
+ Error, with counts. Rows are matched to existing entries by external ID first, then by a
+ natural key (date + account + debit + credit + reference). You then pick a strategy — Skip conflicts,
+ Merge (update existing + insert new), or Insert as new — and confirm. Set an external ID
+ on your entries for the most reliable re-import matching.
+ - Export. The Export menu downloads the current entries as CSV or Excel (XLSX).
+ - Change history. Every add, edit, delete, and import is fully logged. Click the history
+ (clock) icon on a row to see that entry’s timeline — who changed it, when, and a field-level before→after diff. The same
+ events are searchable system-wide in Admin → Activity & Audit Trail.
+
+
Depreciation methods & critical fields
Each book’s depreciation is computed from the depreciation-critical fields: property type, placed-in-service date,
@@ -523,6 +549,36 @@
Completed forms — with signature, ratings, notes, and photos — are viewable from both the asset’s PM tab and the
Completed PM forms list on the Maintenance screen.
+
+
Dynamic scheduling — usage meters & the lifespan wear curve
+
+ Beyond a fixed calendar interval, a schedule’s next-due date can adjust to real-world use. When usage and
+ age signals are enabled, the next service is due at the earliest of three triggers — the
+ calendar interval, a projected usage threshold, or an age-compressed date — and the asset’s PM list shows a
+ small badge (📅 date, ⏱ usage, or 📉 age) indicating which one is driving the date.
+
+
+ -
+ Usage meters. Add one or more meters to a PM plan (e.g. Operating Hours, kWh,
+ gallons, cycles) with a “due every” threshold. Record the current reading when completing a service, or
+ anytime via Log reading on the asset’s PM tab. DepreCore infers the usage rate
+ from successive readings and pulls the due-date in when a machine is running hard; crossing a threshold
+ raises a usage alert (e.g. “Operating Hours 512/500”).
+
+ -
+ Lifespan wear curve. Turn on “Tighten the interval as the asset ages” on a plan to shorten
+ the service interval as an asset approaches its estimated life (from the asset’s useful-life months). New
+ equipment is serviced on the base cadence; near end-of-life it is serviced more often, along a curve you
+ tune in PM settings.
+
+
+
+ Both signals are opt-in: an administrator enables “Usage-based scheduling” and the “Lifespan
+ wear curve” (with its end-of-life factor and aggressiveness) under Admin → Application Configuration →
+ Preventative maintenance defaults. A nightly job re-projects due-dates so usage/age schedules stay
+ current even when an asset isn’t serviced for a while; admins can also run it on demand with Run
+ now.
+
@@ -561,6 +617,7 @@
Use the type selector and filter box to find records; create a contact with New contact.
Capture name/organization, international-friendly address and phone, and email.
Type-specific fields: employee ID/department/manager/start date; contractor tax ID or business license; vendor and service-provider star rating and credit term.
+ Location map — once an address is entered, the contact card can show an embedded Google map of the location, with an Open in Google Maps link. For a new contact the map appears on a Show map click; when you open an existing contact with an address it’s shown automatically.
Keep a per-contact notes log for running history.
How contacts connect to the rest of the app
@@ -605,6 +662,7 @@
- Email digest — when email is enabled and a mail server is configured, new alerts are emailed to the recipient list. You can also trigger Email digest now from the Alerts screen.
- Webhook — each new alert can be posted to an external URL as a JSON object, optionally signed for authenticity. Useful for chat tools or custom automation.
+ - Microsoft Teams — post a digest card of new alerts to a Teams channel or chat (see Microsoft Teams integration).
- ServiceNow incident — raise a ticket from any alert (see below).
@@ -655,21 +713,65 @@
+
+
+ Microsoft Teams integration
+
+ DepreCore can post newly raised alerts to a Microsoft Teams channel or chat. One digest card is sent per
+ alert cycle, alongside (or instead of) email, webhook, and ServiceNow delivery. Configure it in
+ Administration → Integrations → Microsoft Teams.
+
+ Connecting
+
+ - In Teams, create an incoming webhook for the target channel — a Workflows “post to a channel when a
+ webhook request is received” flow (recommended), or a legacy Incoming Webhook connector.
+ - Paste the webhook URL into DepreCore and pick the matching card format: Adaptive Card (Workflows)
+ for the modern flow, or MessageCard (legacy connector).
+ - Choose a minimum severity to post (e.g. Warning and above) so the channel only gets what matters, turn on
+ Enable Teams delivery, and click Send test card to confirm it lands in the channel.
+
+
+ Cards are colour-coded by severity and summarize the pending alerts (title, detail, due date, asset). Each alert is delivered
+ once across all channels. Note: this “Teams” (Microsoft Teams chat) is unrelated to Admin → Users &
+ Teams, which groups your DepreCore users.
+
+
+
Administration
- Admin (admin role) is where the application is configured. It is split into three sub-screens in the
- navigation rail: Users & Teams, Application Configuration (application settings, asset classes, depreciation zones, Section 179 limits, asset ID templates, asset categories,
- asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and Integrations (email, webhook, ServiceNow, Workday). Each is described below.
+ Admin (admin role) is where the application is configured. It is split into sub-screens in the
+ navigation rail: Users & Teams, Password & Security,
+ Activity & Audit Trail, Application Configuration (companies, application
+ settings, asset classes, depreciation zones, Section 179 limits, asset ID templates, asset categories,
+ asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and
+ Integrations (email, webhook, Microsoft Teams, ServiceNow, Workday). Each is described below.
Users & roles
- Create and edit users — name, email, team, role, status, and password.
- The list paginates and is filterable. A user’s role controls what they can see and do.
+ - Company access — assign which companies a user may see and switch between (administrators
+ always see every company). See Companies.
+ - You can require a password change at next sign-in, and a locked account (after too many
+ failed sign-ins) shows an Unlock action — see
+ Password & security.
+ Companies
+ Add, edit, and dispose/restore companies, each with its own books, GL, and data. Covered under
+ Companies.
+
+ Password & security
+ Password complexity/expiry/history rules and account lockout. Covered under
+ Password & security.
+
+ Activity & audit trail
+ A searchable, exportable log of every change made in the system. Covered under
+ Password & security.
+
Teams
Group users into teams. Select a team to edit or delete it (a team in use by members can’t be deleted until they’re reassigned).
@@ -747,11 +849,20 @@
payload is shown for whoever builds the receiving side.
+ Microsoft Teams
+ Post alert digests to a Teams channel — webhook URL, card format, and minimum severity. Covered under Microsoft Teams integration.
+
ServiceNow integration
Connection, incident push, and CMDB pull settings — covered in detail under ServiceNow integration.
Maintenance (PM) defaults
- Set the default maintenance frequency for newly attached plans and how many days ahead PM-due alerts should appear.
+
+ Set the default maintenance frequency for newly attached plans and how many days ahead PM-due alerts should appear.
+ This is also where you enable dynamic scheduling: usage-based scheduling (meters) and the
+ lifespan wear curve (with its end-of-life interval factor and curve aggressiveness), plus the
+ nightly recompute job (on/off, hour of day, and a Run now button). See
+ Preventative maintenance for how these signals drive due-dates.
+
Workday connector
@@ -799,6 +910,83 @@
+
+
+ Companies (multi-company)
+
+ DepreCore can manage multiple companies, each with its own assets, books and general ledger, PM plans,
+ alerts, reports, reference data (categories/locations/departments), contacts, templates, saved reports, parts, and
+ employee directory. You work in one active company at a time.
+
+ Switching companies
+
+ When you have access to more than one company, a company switcher appears in the top bar. Choosing a
+ company instantly re-scopes every screen — dashboard, assets, books, maintenance, alerts, reports, and so on — to that
+ company. Your selection is remembered between visits.
+
+ Managing companies
+ Administrators manage companies in Admin → Application Configuration → Companies:
+
+ - Add / edit a company — name, legal name, base currency, fiscal year-end month, and separate
+ Federal, State, and (optional) Local tax IDs. A new company is
+ created with the standard book set and the standard reference data so its ledger works immediately.
+ - Dispose (archive) a company to retire it — all of its assets, books, GL, and history are
+ preserved, but it’s hidden from the switcher and can’t be selected for new work. The last active company can’t be
+ disposed. Disposed companies can be restored at any time.
+
+ Who can see which company
+
+ Each user is granted access to one or more companies (set in the user’s edit dialog — see
+ Administration); the switcher only lists the companies they may use, and a
+ user can’t reach another company’s data. Administrators see every company.
+
+
+ A few things are deliberately shared across all companies: the tax-rule-set catalog (each company still
+ selects which set each of its books uses), IRS reference data (depreciation zones and §179 limits), the integrations
+ (email/Teams/webhook/ServiceNow/Workday), and the users/roles and audit/security layer.
+
+
+
+
+
+ Password & security · audit trail
+
+ Password & account security policy
+
+ Administrators set the rules for locally managed passwords in Admin → Password & Security. They are
+ enforced whenever a password is set or changed and at sign-in:
+
+
+ - Complexity — minimum length and required character classes (uppercase, lowercase, number, symbol),
+ and a rule that a password may not contain the user’s name or email.
+ - Rotation & reuse — expire passwords after N days, block reuse of the last N passwords, and an
+ optional minimum password age.
+ - Account lockout — lock an account for a set number of minutes after too many failed sign-ins. A locked
+ user is refused until the window passes or an administrator clicks Unlock on the user.
+
+
+ The live rule preview shows users exactly what’s required. Users change their own password from the account menu
+ (Change password); when a change is required (expired, or set by an admin) they’re prompted at sign-in.
+
+
+ Activity & Audit Trail
+
+ Admin → Activity & Audit Trail is a complete, searchable record of user activity — sign-ins and
+ lockouts, password and permission changes, GL and data edits, imports, and every create/update/delete across the app.
+
+
+ - Filter by user, action, entity type, date range, or
+ free-text search, with pagination.
+ - Each entry can expand to show the full before → after detail of what changed.
+ - Export CSV for external review or retention.
+
+
+ Because every change is captured here with full before/after detail, the audit trail is the system-of-record for change
+ tracking — for example, each GL journal-entry edit is logged here and also surfaced inline via the entry’s
+ history icon (see Books, depreciation & tax).
+
+
+
@@ -878,16 +1066,19 @@ export default {
{ id: 'assets', title: 'Assets — creating & managing', icon: 'mdi-archive-search-outline', keywords: 'asset register create edit import export mass edit workbench files notes tasks warranty' },
{ id: 'lifecycle', title: 'Asset lifecycle', icon: 'mdi-cash-remove', keywords: 'dispose disposal sale retire gain loss lease amortization impairment adjustment write down' },
{ id: 'barcodes', title: 'Barcodes & scanning', icon: 'mdi-barcode-scan', keywords: 'barcode label print scan camera tag' },
- { id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports' },
+ { id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports journal entry entries import export csv excel conflict merge history zone section 179' },
{ id: 'taxrules', title: 'Tax rule sets', icon: 'mdi-scale-balance', keywords: 'tax rules jurisdiction method limits activate version' },
{ id: 'templates', title: 'Templates', icon: 'mdi-form-select', keywords: 'template defaults custom fields data entry' },
- { id: 'pm', title: 'Preventative maintenance', icon: 'mdi-wrench-clock', keywords: 'pm maintenance plan steps complete close signature ratings guidance photos schedule' },
+ { id: 'pm', title: 'Preventative maintenance', icon: 'mdi-wrench-clock', keywords: 'pm maintenance plan steps complete close signature ratings guidance photos schedule meter usage reading hours lifespan wear curve dynamic nightly recompute' },
{ id: 'parts', title: 'Parts inventory', icon: 'mdi-package-variant-closed', keywords: 'parts stock aisle bin reserve reorder supplier inventory' },
{ id: 'contacts', title: 'Contacts & CRM', icon: 'mdi-card-account-details-outline', keywords: 'contacts crm vendor employee contractor work location supplier notes' },
{ id: 'assignments', title: 'Assignments (custody)', icon: 'mdi-account-switch-outline', keywords: 'assignment custody employee release traceability' },
- { id: 'alerts', title: 'Alerts & reminders', icon: 'mdi-bell-alert-outline', keywords: 'alerts reminders severity scan acknowledge dismiss email digest webhook servicenow' },
+ { id: 'alerts', title: 'Alerts & reminders', icon: 'mdi-bell-alert-outline', keywords: 'alerts reminders severity scan acknowledge dismiss email digest webhook teams microsoft servicenow' },
{ id: 'servicenow', title: 'ServiceNow integration', icon: 'mdi-cloud-sync-outline', keywords: 'servicenow incident ticket cmdb import sync connection' },
- { id: 'admin', title: 'Administration', icon: 'mdi-cog-outline', keywords: 'admin users roles teams settings smtp email webhook workday configuration' },
+ { id: 'teams', title: 'Microsoft Teams', icon: 'mdi-microsoft-teams', keywords: 'teams microsoft chat channel webhook adaptive card messagecard alert digest notification workflow connector' },
+ { id: 'admin', title: 'Administration', icon: 'mdi-cog-outline', keywords: 'admin users roles teams settings smtp email webhook workday configuration company companies password security lockout audit activity trail' },
+ { id: 'companies', title: 'Companies (multi-company)', icon: 'mdi-domain', keywords: 'company companies multi tenant switch active dispose archive restore tax id federal state local entity access' },
+ { id: 'security', title: 'Password & security · audit', icon: 'mdi-shield-key-outline', keywords: 'password policy complexity expiry lockout reuse history audit trail activity log change tracking export forced change' },
{ id: 'roles', title: 'Roles & permissions', icon: 'mdi-shield-account-outline', keywords: 'roles permissions admin finance operations viewer access' }
]
};
diff --git a/src/views/BooksView.vue b/src/views/BooksView.vue
index fbfcd7c..fe1dd14 100644
--- a/src/views/BooksView.vue
+++ b/src/views/BooksView.vue
@@ -149,6 +149,159 @@
Select a book to view its general ledger.
+
+
+
+
+
+
Entries{{ entrySummary.count }}
+
Debit{{ currency(entrySummary.debit) }}
+
Credit{{ currency(entrySummary.credit) }}
+
Balance{{ currency(entrySummary.balance) }}
+
+
+
+ {{ row.debit ? currency(row.debit) : '' }}
+ {{ row.credit ? currency(row.credit) : '' }}
+ {{ row.source }}
+
+
+
+
+
+
+ {{ entryError }}
+ {{ message }}
+
+
+
+
+
+ {{ entryForm.id ? 'Edit GL entry' : 'New GL entry' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ entryDialogError }}
+
+
+
+ Cancel
+ Save entry
+
+
+
+
+
+
+
+ Import preview — {{ ledgerBook }}
+
+
+
New{{ importPreview.summary.new }}
+
Update{{ importPreview.summary.update }}
+
Duplicate{{ importPreview.summary.duplicate }}
+
Errors{{ importPreview.summary.error }}
+
+ Conflict strategy
+
+
+
+
+
+
+
+ | Status | Date | Account | Debit | Credit | Reference |
+
+
+ | {{ r.status }} · {{ r.error }} |
+ {{ r.entry.entry_date }} |
+ {{ r.entry.account }} |
+ {{ r.entry.debit ? currency(r.entry.debit) : '' }} |
+ {{ r.entry.credit ? currency(r.entry.credit) : '' }} |
+ {{ r.entry.reference }} |
+
+
+
+
+
+
+
+ Cancel
+ Import
+
+
+
+
+
+
+
+ Change history
+
+ Loading…
+ No recorded changes.
+
+
+ {{ historyLabel(ev.action) }}
+ {{ ev.actor_name || 'System' }}
+
+ {{ shortDate(ev.created_at) }}
+
+
+
+ | {{ c.label }} |
+ {{ c.from }} |
+ → |
+ {{ c.to }} |
+
+
+
No field changes.
+
+
+
+
+ Close
+
+
+
+
New book
@@ -178,7 +331,7 @@