Added Wizard for Month/Year End Close
This commit is contained in:
@@ -16,6 +16,7 @@ const assetRoutes = require('./routes/assets');
|
||||
const assignmentRoutes = require('./routes/assignments');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const bookRoutes = require('./routes/books');
|
||||
const closeRoutes = require('./routes/close');
|
||||
const contactRoutes = require('./routes/contacts');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const dataPortabilityRoutes = require('./routes/dataPortability');
|
||||
@@ -72,6 +73,7 @@ function createApp() {
|
||||
app.use('/api', assetRoutes);
|
||||
app.use('/api', alertRoutes);
|
||||
app.use('/api', bookRoutes);
|
||||
app.use('/api', closeRoutes);
|
||||
app.use('/api', contactRoutes);
|
||||
app.use('/api', disposalRoutes);
|
||||
app.use('/api', leaseRoutes);
|
||||
|
||||
66
server/db.js
66
server/db.js
@@ -460,9 +460,38 @@ function initialize() {
|
||||
expense_account TEXT,
|
||||
pm_expense_account TEXT,
|
||||
pm_clearing_account TEXT,
|
||||
cwip_account TEXT,
|
||||
gain_account TEXT,
|
||||
loss_account TEXT,
|
||||
proceeds_account TEXT,
|
||||
retained_earnings_account TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS close_periods (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
book_code TEXT NOT NULL,
|
||||
period_type TEXT NOT NULL DEFAULT 'month',
|
||||
fiscal_year INTEGER NOT NULL,
|
||||
period_month INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
steps_json TEXT,
|
||||
reconciliation_json TEXT,
|
||||
totals_json TEXT,
|
||||
notes TEXT,
|
||||
started_by INTEGER REFERENCES users(id),
|
||||
started_at TEXT,
|
||||
closed_by INTEGER REFERENCES users(id),
|
||||
closed_at TEXT,
|
||||
reopened_by INTEGER REFERENCES users(id),
|
||||
reopened_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(entity_id, book_code, period_type, fiscal_year, period_month)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_close_periods_scope ON close_periods (entity_id, book_code, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
@@ -847,6 +876,13 @@ function migrate() {
|
||||
rebuildReferenceValuesPerCompany();
|
||||
rebuildAssetTemplatesPerCompany();
|
||||
rebuildPartsPerCompany();
|
||||
// Period close: extra GL posting accounts + per-company capitalization threshold.
|
||||
ensureColumn('gl_account_defaults', 'cwip_account', 'TEXT');
|
||||
ensureColumn('gl_account_defaults', 'gain_account', 'TEXT');
|
||||
ensureColumn('gl_account_defaults', 'loss_account', 'TEXT');
|
||||
ensureColumn('gl_account_defaults', 'proceeds_account', 'TEXT');
|
||||
ensureColumn('gl_account_defaults', 'retained_earnings_account', 'TEXT');
|
||||
ensureColumn('entities', 'capitalization_threshold', 'REAL NOT NULL DEFAULT 0');
|
||||
backfillCompanyScope();
|
||||
}
|
||||
|
||||
@@ -1072,14 +1108,21 @@ function seedReferenceValuesForCompany(entityId) {
|
||||
// The defaults are the fallback accounts the depreciation/PM ledgers post to when an asset doesn't
|
||||
// carry its own. Called during initial seed and when a new company is created.
|
||||
function seedGlAccountsForCompany(entityId) {
|
||||
if (!entityId || one('SELECT id FROM gl_accounts WHERE entity_id = ? LIMIT 1', [entityId])) return;
|
||||
if (!entityId) return;
|
||||
const ts = now();
|
||||
// Seed the starter chart only for a brand-new company, so deliberate later deletions aren't undone.
|
||||
if (!one('SELECT id FROM gl_accounts WHERE entity_id = ? LIMIT 1', [entityId])) {
|
||||
const accounts = [
|
||||
['1010', 'Cash / Proceeds Clearing', 'asset', 'debit', 'Disposal proceeds clearing'],
|
||||
['1500', 'Fixed Assets', 'asset', 'debit', 'Asset cost / basis'],
|
||||
['1590', 'Accumulated Depreciation', 'contra_asset', 'credit', 'Accumulated depreciation (contra-asset)'],
|
||||
['1800', 'Construction in Progress', 'asset', 'debit', 'Capital work-in-progress (CWIP)'],
|
||||
['2000', 'Maintenance Clearing', 'liability', 'credit', 'PM/maintenance clearing'],
|
||||
['3900', 'Retained Earnings', 'equity', 'credit', 'Year-end depreciation roll-forward'],
|
||||
['6400', 'Depreciation Expense', 'expense', 'debit', 'Periodic depreciation expense'],
|
||||
['6450', 'Maintenance Expense', 'expense', 'debit', 'Preventative-maintenance spend']
|
||||
['6450', 'Maintenance Expense', 'expense', 'debit', 'Preventative-maintenance spend'],
|
||||
['7200', 'Gain on Disposal', 'revenue', 'credit', 'Gain on asset disposal'],
|
||||
['7300', 'Loss on Disposal', 'expense', 'debit', 'Loss on asset disposal']
|
||||
];
|
||||
for (const [code, name, type, normal, description] of accounts) {
|
||||
run(
|
||||
@@ -1088,11 +1131,26 @@ function seedGlAccountsForCompany(entityId) {
|
||||
[entityId, code, name, type, normal, description, ts, ts]
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ensure the per-company posting defaults exist; backfill the close-account columns for companies
|
||||
// seeded before the close feature (the ledgers post to these account codes regardless of chart rows).
|
||||
run(
|
||||
`INSERT OR IGNORE INTO gl_account_defaults (entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account, updated_at)
|
||||
VALUES (?, '1500', '1590', '6400', '6450', '2000', ?)`,
|
||||
`INSERT OR IGNORE INTO gl_account_defaults
|
||||
(entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account,
|
||||
cwip_account, gain_account, loss_account, proceeds_account, retained_earnings_account, updated_at)
|
||||
VALUES (?, '1500', '1590', '6400', '6450', '2000', '1800', '7200', '7300', '1010', '3900', ?)`,
|
||||
[entityId, ts]
|
||||
);
|
||||
run(
|
||||
`UPDATE gl_account_defaults SET
|
||||
cwip_account = COALESCE(cwip_account, '1800'),
|
||||
gain_account = COALESCE(gain_account, '7200'),
|
||||
loss_account = COALESCE(loss_account, '7300'),
|
||||
proceeds_account = COALESCE(proceeds_account, '1010'),
|
||||
retained_earnings_account = COALESCE(retained_earnings_account, '3900')
|
||||
WHERE entity_id = ?`,
|
||||
[entityId]
|
||||
);
|
||||
}
|
||||
|
||||
function seed() {
|
||||
|
||||
79
server/routes/close.js
Normal file
79
server/routes/close.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const close = require('../services/closePeriods');
|
||||
|
||||
const router = express.Router();
|
||||
const reader = requireCapability('finance.view');
|
||||
const closer = requireCapability('finance.close');
|
||||
|
||||
// Period close (month-end / year-end). All scoped to the active company (req.companyId).
|
||||
|
||||
router.get('/close-periods', reader, (req, res) => {
|
||||
res.json({ periods: close.listPeriods(req.companyId, req.query.book || null) });
|
||||
});
|
||||
|
||||
// Step definitions for the wizard (month vs year).
|
||||
router.get('/close-periods/step-defs', reader, (req, res) => {
|
||||
res.json({ month: close.stepDefs('month'), year: close.stepDefs('year') });
|
||||
});
|
||||
|
||||
router.post('/close-periods/start', closer, (req, res, next) => {
|
||||
try {
|
||||
const period = close.getOrStart(req.companyId, req.body.book, req.body.period_type || 'month', req.body.fiscal_year, req.body.period_month, req.user.id);
|
||||
res.status(201).json({ period, steps: close.stepDefs(period.period_type) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/close-periods/:id', reader, (req, res) => {
|
||||
const period = close.getPeriod(req.params.id, req.companyId);
|
||||
if (!period) return res.status(404).json({ error: 'Close period not found' });
|
||||
return res.json({ period, steps: close.stepDefs(period.period_type), data: close.stepData(period, req.companyId) });
|
||||
});
|
||||
|
||||
function withPeriod(handler) {
|
||||
return (req, res, next) => {
|
||||
const period = close.getPeriod(req.params.id, req.companyId);
|
||||
if (!period) return res.status(404).json({ error: 'Close period not found' });
|
||||
try {
|
||||
return handler(period, req, res);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/close-periods/:id/steps/:key', closer, withPeriod((period, req, res) => {
|
||||
res.json({ period: close.recordStep(period.id, req.params.key, req.body || {}, req.user.id, req.companyId) });
|
||||
}));
|
||||
|
||||
router.post('/close-periods/:id/post-depreciation', closer, withPeriod((period, req, res) => {
|
||||
const summary = close.postDepreciation(period, req.user.id, req.companyId);
|
||||
res.json({ summary, period: close.getPeriod(period.id, req.companyId) });
|
||||
}));
|
||||
|
||||
router.post('/close-periods/:id/post-disposals', closer, withPeriod((period, req, res) => {
|
||||
res.json({ result: close.postDisposalEntries(period, req.user.id, req.companyId) });
|
||||
}));
|
||||
|
||||
router.post('/close-periods/:id/reconcile', closer, withPeriod((period, req, res) => {
|
||||
const reconciliation = close.reconcile(period, req.companyId);
|
||||
res.json({ reconciliation, period: close.getPeriod(period.id, req.companyId) });
|
||||
}));
|
||||
|
||||
router.post('/close-periods/:id/wip/:assetId/capitalize', closer, withPeriod((period, req, res) => {
|
||||
const asset = close.capitalizeWip(Number(req.params.assetId), req.body || {}, req.user.id, req.companyId);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
return res.json({ asset });
|
||||
}));
|
||||
|
||||
router.post('/close-periods/:id/finalize', closer, withPeriod((period, req, res) => {
|
||||
res.json({ period: close.finalize(period, req.user.id, req.companyId, req.body || {}) });
|
||||
}));
|
||||
|
||||
router.post('/close-periods/:id/reopen', closer, withPeriod((period, req, res) => {
|
||||
res.json({ period: close.reopen(period.id, req.user.id, req.companyId) });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -20,6 +20,7 @@ const CAPABILITIES = [
|
||||
// Finance
|
||||
{ key: 'finance.view', label: 'View reports, books & tax rules', group: 'Finance' },
|
||||
{ key: 'finance.manage', label: 'Manage books, tax rules, disposals & leases', group: 'Finance' },
|
||||
{ key: 'finance.close', label: 'Run month-end / year-end close & reopen periods', group: 'Finance' },
|
||||
{ key: 'reports.save', label: 'Save & delete reports', group: 'Finance' },
|
||||
// Configuration
|
||||
{ key: 'config.manage', label: 'Manage templates, categories & ID templates', group: 'Configuration' },
|
||||
@@ -41,7 +42,7 @@ const WILDCARD = '*';
|
||||
const FINANCE_CAPS = [
|
||||
'assets.view', 'assets.edit', 'assets.delete', 'assets.bulk', 'assets.assign',
|
||||
'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage',
|
||||
'contacts.manage', 'finance.view', 'finance.manage', 'reports.save', 'config.manage'
|
||||
'contacts.manage', 'finance.view', 'finance.manage', 'finance.close', 'reports.save', 'config.manage'
|
||||
];
|
||||
|
||||
const OPERATIONS_CAPS = [
|
||||
|
||||
@@ -270,6 +270,8 @@ function createAsset(body, userId, companyId) {
|
||||
const created = now();
|
||||
const input = normalizeAssetInput(body);
|
||||
if (companyId) input.entity_id = companyId; // new assets belong to the active company
|
||||
// A new capitalization can't land in a closed period (gated by the primary book's locks).
|
||||
require('./closePeriods').assertSubledgerOpen(input.entity_id, input.in_service_date || input.acquired_date);
|
||||
const result = tx(() => {
|
||||
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
|
||||
const insert = run(
|
||||
@@ -293,6 +295,14 @@ function updateAsset(id, body, userId, companyId) {
|
||||
|
||||
const input = normalizeAssetInput({ ...before, ...body });
|
||||
if (companyId) input.entity_id = companyId; // assets stay within their company in phase 1
|
||||
// Block changes to the financial dates/cost that would fall in a closed period (either the old or the
|
||||
// new value), so a locked period's subledger can't be altered. Other field edits are unaffected.
|
||||
const financialChanged = ['in_service_date', 'acquired_date', 'acquisition_cost'].some((f) => String(before[f] ?? '') !== String(input[f] ?? ''));
|
||||
if (financialChanged) {
|
||||
const guard = require('./closePeriods');
|
||||
guard.assertSubledgerOpen(input.entity_id, before.in_service_date || before.acquired_date);
|
||||
guard.assertSubledgerOpen(input.entity_id, input.in_service_date || input.acquired_date);
|
||||
}
|
||||
// When depreciation-critical fields change, the caller chooses when the change applies.
|
||||
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
|
||||
// 'going_forward' keeps depreciation already recorded (the stored prior depreciation).
|
||||
|
||||
533
server/services/closePeriods.js
Normal file
533
server/services/closePeriods.js
Normal file
@@ -0,0 +1,533 @@
|
||||
const { all, audit, now, one, parseJson, run } = require('../db');
|
||||
|
||||
// Month-end / year-end close for the fixed-asset subledger, scoped per company + book. A close walks a
|
||||
// checklist (additions, disposals, WIP capitalization, depreciation, impairments, reconciliation,
|
||||
// reports, finalize), POSTS the real journal entries it generates into the stored GL layer (tagged
|
||||
// `close:<id>` so they're idempotent and reversible), reconciles the register to the GL, and locks the
|
||||
// period against backdated changes. Every action is audited. Top-level deps are db only; the GL/report
|
||||
// services are lazy-required to avoid the cycle created by the lock guard below.
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const TOLERANCE = 0.01;
|
||||
|
||||
function round2(value) {
|
||||
return Math.round((Number(value) || 0) * 100) / 100;
|
||||
}
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ---- Period shape & windows -------------------------------------------------
|
||||
|
||||
function fromRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
steps: parseJson(row.steps_json, {}),
|
||||
reconciliation: parseJson(row.reconciliation_json, null),
|
||||
totals: parseJson(row.totals_json, null),
|
||||
locked: row.status === 'closed'
|
||||
};
|
||||
}
|
||||
|
||||
// Date window the period covers. Month = the calendar month; year = the calendar fiscal year (the
|
||||
// depreciation engine is calendar-year based, so year closes use Jan–Dec of fiscal_year).
|
||||
function periodWindow(period) {
|
||||
if (period.period_type === 'month') {
|
||||
const m = period.period_month;
|
||||
return {
|
||||
start: `${period.fiscal_year}-${pad(m)}-01`,
|
||||
end: new Date(Date.UTC(period.fiscal_year, m, 0)).toISOString().slice(0, 10),
|
||||
label: `${MONTHS[m - 1]} ${period.fiscal_year}`
|
||||
};
|
||||
}
|
||||
return { start: `${period.fiscal_year}-01-01`, end: `${period.fiscal_year}-12-31`, label: `FY ${period.fiscal_year}` };
|
||||
}
|
||||
|
||||
function source(period) {
|
||||
return `close:${period.id}`;
|
||||
}
|
||||
|
||||
// ---- Step definitions -------------------------------------------------------
|
||||
|
||||
function stepDefs(periodType) {
|
||||
const month = [
|
||||
{ key: 'additions', title: 'Record additions', description: 'Verify capital purchases in the period meet the capitalization threshold and are recorded.', required: false },
|
||||
{ key: 'disposals', title: 'Record disposals', description: 'Remove assets sold, scrapped, or retired in the period; recognize gain/loss.', required: false },
|
||||
{ key: 'wip', title: 'Capitalize WIP', description: 'Move completed Construction-in-progress assets into service.', required: false },
|
||||
{ key: 'depreciation', title: 'Process & post depreciation', description: 'Compute and post the period depreciation expense, allocated by department.', required: true },
|
||||
{ key: 'impairments', title: 'Check for impairments', description: 'Assess assets for impairment and record any loss.', required: false },
|
||||
{ key: 'reconcile', title: 'Reconcile subledger to GL', description: 'Match the Fixed Asset Register to the GL control accounts; clear variances.', required: true },
|
||||
{ key: 'reports', title: 'Generate reports', description: 'Run the Fixed Asset Register, Depreciation Summary, and Additions/Disposals reports.', required: false }
|
||||
];
|
||||
if (periodType === 'month') return month;
|
||||
return [
|
||||
{ key: 'final_month_close', title: 'Final month-end close', description: 'Confirm the final month period is closed and the final depreciation posted.', required: true },
|
||||
...month,
|
||||
{ key: 'physical_inventory', title: 'Physical inventory', description: 'Physically verify high-value / mobile assets still exist and are in use.', required: true },
|
||||
{ key: 'tax_books', title: 'Update tax books', description: 'Recompute tax depreciation (MACRS / §179) and review book-vs-tax differences.', required: false }
|
||||
// roll_forward is performed automatically during finalize for a year close.
|
||||
];
|
||||
}
|
||||
|
||||
function requiredSteps(periodType) {
|
||||
return stepDefs(periodType).filter((s) => s.required).map((s) => s.key);
|
||||
}
|
||||
|
||||
// ---- Lifecycle --------------------------------------------------------------
|
||||
|
||||
function listPeriods(companyId, bookCode) {
|
||||
const rows = all(
|
||||
`SELECT * FROM close_periods WHERE entity_id = ? AND (? IS NULL OR book_code = ?)
|
||||
ORDER BY fiscal_year DESC, period_month DESC, id DESC`,
|
||||
[companyId, bookCode || null, bookCode || null]
|
||||
);
|
||||
return rows.map(fromRow);
|
||||
}
|
||||
|
||||
function getPeriod(id, companyId) {
|
||||
return fromRow(one('SELECT * FROM close_periods WHERE id = ? AND entity_id = ?', [id, companyId]));
|
||||
}
|
||||
|
||||
function getOrStart(companyId, bookCode, periodType, fiscalYear, periodMonth, userId) {
|
||||
const month = periodType === 'month' ? Number(periodMonth) : null;
|
||||
if (periodType === 'month' && (!month || month < 1 || month > 12)) {
|
||||
throw Object.assign(new Error('A valid month (1-12) is required for a month close'), { status: 400 });
|
||||
}
|
||||
const existing = one(
|
||||
`SELECT * FROM close_periods WHERE entity_id = ? AND book_code = ? AND period_type = ? AND fiscal_year = ? AND IFNULL(period_month, 0) = ?`,
|
||||
[companyId, bookCode, periodType, Number(fiscalYear), month || 0]
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.status === 'open') {
|
||||
run('UPDATE close_periods SET status = ?, started_by = ?, started_at = ?, updated_at = ? WHERE id = ?', ['in_progress', userId, now(), now(), existing.id]);
|
||||
}
|
||||
return getPeriod(existing.id, companyId);
|
||||
}
|
||||
const ts = now();
|
||||
const result = run(
|
||||
`INSERT INTO close_periods (entity_id, book_code, period_type, fiscal_year, period_month, status, steps_json, started_by, started_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'in_progress', '{}', ?, ?, ?, ?)`,
|
||||
[companyId, bookCode, periodType, Number(fiscalYear), month, userId, ts, ts, ts]
|
||||
);
|
||||
audit(userId, 'start', 'close_period', result.lastInsertRowid, null, { book: bookCode, periodType, fiscalYear, periodMonth: month });
|
||||
return getPeriod(result.lastInsertRowid, companyId);
|
||||
}
|
||||
|
||||
function saveSteps(period, userId) {
|
||||
run('UPDATE close_periods SET steps_json = ?, updated_at = ? WHERE id = ?', [JSON.stringify(period.steps), now(), period.id]);
|
||||
}
|
||||
|
||||
function setStepDone(period, key, userId, notes) {
|
||||
period.steps = period.steps || {};
|
||||
period.steps[key] = { done: true, by: userId, at: now(), notes: notes || null };
|
||||
}
|
||||
|
||||
function recordStep(periodId, key, payload, userId, companyId) {
|
||||
const period = getPeriod(periodId, companyId);
|
||||
if (!period) return null;
|
||||
if (period.locked) throw Object.assign(new Error('The period is closed. Reopen it to make changes.'), { status: 409 });
|
||||
period.steps = period.steps || {};
|
||||
period.steps[key] = { done: payload.done !== false, by: userId, at: now(), notes: payload.notes || null, acknowledged: Boolean(payload.acknowledged) };
|
||||
saveSteps(period, userId);
|
||||
audit(userId, 'step', 'close_period', period.id, null, { step: key, ...period.steps[key] });
|
||||
return getPeriod(periodId, companyId);
|
||||
}
|
||||
|
||||
// ---- Step data (live review for the wizard) ---------------------------------
|
||||
|
||||
function assetRowsForBook(bookCode, companyId, status) {
|
||||
const { computeYear } = require('./reportEngine');
|
||||
const { ruleSetForBook } = require('./ruleSets');
|
||||
const { assetFromRow } = require('./assets');
|
||||
const rules = ruleSetForBook(bookCode);
|
||||
const rows = all(
|
||||
`SELECT a.*, b.cost AS book_cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
|
||||
b.section_179_amount, b.bonus_percent, b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
|
||||
FROM assets a JOIN asset_books b ON b.asset_id = a.id
|
||||
WHERE b.book_type = ? AND b.active = 1 AND a.entity_id = ? AND (? IS NULL OR a.status = ?)
|
||||
ORDER BY a.asset_id`,
|
||||
[bookCode, companyId, status || null, status || null]
|
||||
);
|
||||
return { rules, computeYear, rows: rows.map((row) => ({ asset: assetFromRow(row), book: bookObject(row) })) };
|
||||
}
|
||||
|
||||
function bookObject(row) {
|
||||
return {
|
||||
book_type: row.book_type,
|
||||
cost: row.book_cost,
|
||||
depreciation_method: row.depreciation_method,
|
||||
convention: row.convention,
|
||||
useful_life_months: row.book_life,
|
||||
section_179_amount: row.section_179_amount,
|
||||
bonus_percent: row.bonus_percent,
|
||||
business_use_percent: row.book_bup,
|
||||
investment_use_percent: row.book_ivp,
|
||||
manual_depreciation: row.manual_depreciation
|
||||
};
|
||||
}
|
||||
|
||||
function stepData(period, companyId) {
|
||||
const win = periodWindow(period);
|
||||
const company = one('SELECT capitalization_threshold FROM entities WHERE id = ?', [companyId]);
|
||||
const threshold = Number(company?.capitalization_threshold || 0);
|
||||
|
||||
const additions = all(
|
||||
`SELECT id, asset_id, description, category, department, acquisition_cost, in_service_date, acquired_date, status
|
||||
FROM assets WHERE entity_id = ? AND status != 'cip'
|
||||
AND ((in_service_date BETWEEN ? AND ?) OR (acquired_date BETWEEN ? AND ?))
|
||||
ORDER BY COALESCE(in_service_date, acquired_date)`,
|
||||
[companyId, win.start, win.end, win.start, win.end]
|
||||
).map((a) => ({ ...a, below_threshold: threshold > 0 && Number(a.acquisition_cost) < threshold }));
|
||||
|
||||
const disposals = all(
|
||||
`SELECT d.id, d.asset_id AS asset_pk, a.asset_id, a.description, d.disposal_date, d.method,
|
||||
d.disposed_cost, d.accumulated_depreciation, d.net_book_value, d.disposal_price, d.disposal_expense, d.gain_loss
|
||||
FROM disposals d JOIN assets a ON a.id = d.asset_id
|
||||
WHERE a.entity_id = ? AND d.book_type = ? AND d.disposal_date BETWEEN ? AND ?
|
||||
ORDER BY d.disposal_date`,
|
||||
[companyId, period.book_code, win.start, win.end]
|
||||
);
|
||||
|
||||
const wip = all(
|
||||
`SELECT id, asset_id, description, category, department, acquisition_cost, acquired_date
|
||||
FROM assets WHERE entity_id = ? AND status = 'cip' ORDER BY acquired_date`,
|
||||
[companyId]
|
||||
);
|
||||
|
||||
// Depreciation preview for the period (annual / 12 for a month close), grouped by department.
|
||||
const { rules, computeYear, rows } = assetRowsForBook(period.book_code, companyId, 'in_service');
|
||||
const fraction = period.period_type === 'month' ? 1 / 12 : 1;
|
||||
const byDept = new Map();
|
||||
const stillDepreciating = [];
|
||||
let periodDep = 0;
|
||||
for (const { asset, book } of rows) {
|
||||
const c = computeYear(asset, book, rules, period.fiscal_year);
|
||||
const dep = round2(c.depreciation * fraction);
|
||||
const dept = asset.department || 'Unassigned';
|
||||
if (dep > 0) {
|
||||
byDept.set(dept, round2((byDept.get(dept) || 0) + dep));
|
||||
periodDep += dep;
|
||||
}
|
||||
// Fully depreciated (NBV at/under salvage) yet a method still produces depreciation → flag for review.
|
||||
if (c.nbv_end <= Number(asset.salvage_value || 0) + TOLERANCE && dep > 0) {
|
||||
stillDepreciating.push({ asset_id: asset.asset_id, description: asset.description, depreciation: dep });
|
||||
}
|
||||
}
|
||||
const depreciation = {
|
||||
period: win.label,
|
||||
total: round2(periodDep),
|
||||
by_department: [...byDept.entries()].map(([department, amount]) => ({ department, amount })),
|
||||
fully_depreciated_still_running: stillDepreciating
|
||||
};
|
||||
|
||||
// Impairment candidates: simple heuristic — assets flagged poor condition that aren't disposed.
|
||||
const impairments = all(
|
||||
`SELECT id, asset_id, description, category, condition, acquisition_cost
|
||||
FROM assets WHERE entity_id = ? AND status = 'in_service' AND condition IN ('poor', 'salvage', 'damaged')
|
||||
ORDER BY asset_id`,
|
||||
[companyId]
|
||||
);
|
||||
|
||||
return {
|
||||
window: win,
|
||||
threshold,
|
||||
additions,
|
||||
disposals,
|
||||
wip,
|
||||
depreciation,
|
||||
impairments,
|
||||
reconciliation: period.reconciliation
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Posting (idempotent; tagged source = close:<id>) -----------------------
|
||||
|
||||
function clearPostings(period, like) {
|
||||
run('DELETE FROM gl_entries WHERE entity_id = ? AND source = ? AND external_id LIKE ?', [period.entity_id, source(period), like]);
|
||||
}
|
||||
|
||||
function postDepreciation(period, userId, companyId) {
|
||||
if (period.locked) throw Object.assign(new Error('The period is closed. Reopen it to repost.'), { status: 409 });
|
||||
const glEntries = require('./glEntries');
|
||||
const { getDefaults } = require('./glAccounts');
|
||||
const defaults = getDefaults(companyId);
|
||||
const win = periodWindow(period);
|
||||
const src = source(period);
|
||||
clearPostings(period, `${src}:dep:%`); // idempotent re-post
|
||||
|
||||
const { rules, computeYear, rows } = assetRowsForBook(period.book_code, companyId, 'in_service');
|
||||
const fraction = period.period_type === 'month' ? 1 / 12 : 1;
|
||||
const expenseLines = new Map(); // `${account}||${dept}` -> amount
|
||||
const accumLines = new Map(); // account -> amount
|
||||
for (const { asset, book } of rows) {
|
||||
const c = computeYear(asset, book, rules, period.fiscal_year);
|
||||
const dep = round2(c.depreciation * fraction);
|
||||
if (dep <= 0) continue; // fully depreciated or none — nothing to post
|
||||
const expAcct = asset.gl_expense_account || defaults.expense_account;
|
||||
const accAcct = asset.gl_accumulated_account || defaults.accumulated_account;
|
||||
const dept = asset.department || 'Unassigned';
|
||||
const ek = `${expAcct}||${dept}`;
|
||||
expenseLines.set(ek, round2((expenseLines.get(ek) || 0) + dep));
|
||||
accumLines.set(accAcct, round2((accumLines.get(accAcct) || 0) + dep));
|
||||
}
|
||||
|
||||
let debit = 0;
|
||||
let credit = 0;
|
||||
for (const [key, amount] of expenseLines) {
|
||||
if (amount <= 0) continue;
|
||||
const [account, dept] = key.split('||');
|
||||
glEntries.insertEntry(buildLine({
|
||||
win, fiscal_year: period.fiscal_year, account, account_type: 'Depreciation expense',
|
||||
description: `Depreciation — ${win.label}${dept !== 'Unassigned' ? ` / ${dept}` : ''}`,
|
||||
reference: `CLOSE-${period.id}`, debit: amount, credit: 0, external_id: `${src}:dep:exp:${account}:${dept}`
|
||||
}), period.book_code, userId, companyId, src);
|
||||
debit = round2(debit + amount);
|
||||
}
|
||||
for (const [account, amount] of accumLines) {
|
||||
if (amount <= 0) continue;
|
||||
glEntries.insertEntry(buildLine({
|
||||
win, fiscal_year: period.fiscal_year, account, account_type: 'Accumulated depreciation',
|
||||
description: `Depreciation — ${win.label}`, reference: `CLOSE-${period.id}`,
|
||||
debit: 0, credit: amount, external_id: `${src}:dep:acc:${account}`
|
||||
}), period.book_code, userId, companyId, src);
|
||||
credit = round2(credit + amount);
|
||||
}
|
||||
|
||||
const fresh = getPeriod(period.id, companyId);
|
||||
setStepDone(fresh, 'depreciation', userId, `Posted ${debit} depreciation for ${win.label}`);
|
||||
saveSteps(fresh, userId);
|
||||
const summary = { period: win.label, debit, credit, expense_lines: expenseLines.size, accumulated_lines: accumLines.size };
|
||||
audit(userId, 'post_depreciation', 'close_period', period.id, null, summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Post the GL entries for disposals recorded in the period (Dr accum + proceeds + loss / Cr cost + gain).
|
||||
function postDisposalEntries(period, userId, companyId) {
|
||||
if (period.locked) throw Object.assign(new Error('The period is closed. Reopen it to repost.'), { status: 409 });
|
||||
const glEntries = require('./glEntries');
|
||||
const { getDefaults } = require('./glAccounts');
|
||||
const d = getDefaults(companyId);
|
||||
const win = periodWindow(period);
|
||||
const src = source(period);
|
||||
clearPostings(period, `${src}:disp:%`);
|
||||
const disposals = all(
|
||||
`SELECT dz.*, a.acquisition_cost, a.gl_asset_account, a.gl_accumulated_account
|
||||
FROM disposals dz JOIN assets a ON a.id = dz.asset_id
|
||||
WHERE a.entity_id = ? AND dz.book_type = ? AND dz.disposal_date BETWEEN ? AND ?`,
|
||||
[companyId, period.book_code, win.start, win.end]
|
||||
);
|
||||
let count = 0;
|
||||
for (const dz of disposals) {
|
||||
const assetAcct = dz.gl_asset_account || d.asset_account;
|
||||
const accumAcct = dz.gl_accumulated_account || d.accumulated_account;
|
||||
const cost = round2(dz.disposed_cost);
|
||||
const accum = round2(dz.accumulated_depreciation);
|
||||
const realized = round2(Number(dz.disposal_price || 0) - Number(dz.disposal_expense || 0));
|
||||
const gainLoss = round2(dz.gain_loss);
|
||||
const lines = [
|
||||
{ account: accumAcct, account_type: 'Accumulated depreciation', debit: accum, credit: 0, tag: 'accum' },
|
||||
{ account: d.proceeds_account, account_type: 'Proceeds', debit: realized, credit: 0, tag: 'cash' },
|
||||
{ account: assetAcct, account_type: 'Asset cost', debit: 0, credit: cost, tag: 'cost' }
|
||||
];
|
||||
if (gainLoss < 0) lines.push({ account: d.loss_account, account_type: 'Loss on disposal', debit: round2(-gainLoss), credit: 0, tag: 'loss' });
|
||||
if (gainLoss > 0) lines.push({ account: d.gain_account, account_type: 'Gain on disposal', debit: 0, credit: gainLoss, tag: 'gain' });
|
||||
for (const l of lines) {
|
||||
if (!l.debit && !l.credit) continue;
|
||||
glEntries.insertEntry(buildLine({
|
||||
win, fiscal_year: period.fiscal_year, account: l.account, account_type: l.account_type,
|
||||
description: `Disposal of asset ${dz.asset_id} — ${win.label}`, reference: `CLOSE-${period.id}`,
|
||||
debit: l.debit, credit: l.credit, asset_id: dz.asset_id, external_id: `${src}:disp:${dz.id}:${l.tag}`
|
||||
}), period.book_code, userId, companyId, src);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
audit(userId, 'post_disposals', 'close_period', period.id, null, { count, period: win.label });
|
||||
return { posted: count };
|
||||
}
|
||||
|
||||
function buildLine(o) {
|
||||
return {
|
||||
entry_date: o.win.end,
|
||||
fiscal_year: o.fiscal_year,
|
||||
account: o.account,
|
||||
account_type: o.account_type,
|
||||
description: o.description,
|
||||
reference: o.reference,
|
||||
debit: round2(o.debit),
|
||||
credit: round2(o.credit),
|
||||
asset_id: o.asset_id || null,
|
||||
external_id: o.external_id
|
||||
};
|
||||
}
|
||||
|
||||
// Capitalize a Construction-in-progress asset: place it in service and post Dr asset / Cr CWIP.
|
||||
function capitalizeWip(assetId, body, userId, companyId) {
|
||||
const glEntries = require('./glEntries');
|
||||
const { getDefaults } = require('./glAccounts');
|
||||
const asset = one('SELECT * FROM assets WHERE id = ? AND entity_id = ?', [assetId, companyId]);
|
||||
if (!asset) return null;
|
||||
if (asset.status !== 'cip') throw Object.assign(new Error('Only Construction-in-progress assets can be capitalized'), { status: 400 });
|
||||
const inService = String(body.in_service_date || now().slice(0, 10)).slice(0, 10);
|
||||
const before = { status: asset.status, in_service_date: asset.in_service_date };
|
||||
run('UPDATE assets SET status = ?, in_service_date = ?, updated_at = ? WHERE id = ?', ['in_service', inService, now(), assetId]);
|
||||
const d = getDefaults(companyId);
|
||||
const assetAcct = asset.gl_asset_account || d.asset_account;
|
||||
const cost = round2(asset.acquisition_cost);
|
||||
const primary = primaryBookCode(companyId) || 'GAAP';
|
||||
const fy = Number(inService.slice(0, 4));
|
||||
if (cost > 0) {
|
||||
glEntries.insertEntry(buildLine({ win: { end: inService }, fiscal_year: fy, account: assetAcct, account_type: 'Asset cost', description: `Capitalize WIP — ${asset.asset_id}`, reference: `WIP-${assetId}`, debit: cost, credit: 0, asset_id: assetId, external_id: `wip:${assetId}:asset` }), primary, userId, companyId, `wip:${assetId}`);
|
||||
glEntries.insertEntry(buildLine({ win: { end: inService }, fiscal_year: fy, account: d.cwip_account, account_type: 'Construction in progress', description: `Capitalize WIP — ${asset.asset_id}`, reference: `WIP-${assetId}`, debit: 0, credit: cost, asset_id: assetId, external_id: `wip:${assetId}:cwip` }), primary, userId, companyId, `wip:${assetId}`);
|
||||
}
|
||||
audit(userId, 'capitalize_wip', 'asset', assetId, before, { status: 'in_service', in_service_date: inService, cost });
|
||||
return one('SELECT id, asset_id, status, in_service_date FROM assets WHERE id = ?', [assetId]);
|
||||
}
|
||||
|
||||
// ---- Reconciliation ---------------------------------------------------------
|
||||
|
||||
// Compare the Fixed Asset Register (subledger) to the GL control accounts (posted entries), per account.
|
||||
function reconcile(period, companyId) {
|
||||
const { bookLedger } = require('./books');
|
||||
const { getDefaults } = require('./glAccounts');
|
||||
const d = getDefaults(companyId);
|
||||
const win = periodWindow(period);
|
||||
const ledger = bookLedger(period.book_code, period.fiscal_year, companyId);
|
||||
const sub = ledger
|
||||
? { cost: round2(ledger.summary.cost), accumulated: round2(ledger.summary.accumulated), nbv: round2(ledger.summary.nbv) }
|
||||
: { cost: 0, accumulated: 0, nbv: 0 };
|
||||
|
||||
// GL posted balances for the control accounts through the period end.
|
||||
const balance = (account, sign) => {
|
||||
if (!account) return 0;
|
||||
const row = one(
|
||||
`SELECT IFNULL(SUM(debit), 0) AS d, IFNULL(SUM(credit), 0) AS c FROM gl_entries
|
||||
WHERE entity_id = ? AND book_code = ? AND account = ? AND entry_date <= ?`,
|
||||
[companyId, period.book_code, account, win.end]
|
||||
);
|
||||
return round2(sign * (row.d - row.c));
|
||||
};
|
||||
const glCost = balance(d.asset_account, 1); // debit-normal
|
||||
const glAccum = balance(d.accumulated_account, -1); // credit-normal
|
||||
const glNbv = round2(glCost - glAccum);
|
||||
|
||||
const lines = [
|
||||
{ account: 'Asset cost', subledger: sub.cost, gl: glCost, variance: round2(sub.cost - glCost) },
|
||||
{ account: 'Accumulated depreciation', subledger: sub.accumulated, gl: glAccum, variance: round2(sub.accumulated - glAccum) },
|
||||
{ account: 'Net book value', subledger: sub.nbv, gl: glNbv, variance: round2(sub.nbv - glNbv) }
|
||||
];
|
||||
const maxVariance = Math.max(...lines.map((l) => Math.abs(l.variance)));
|
||||
const result = { window: win, subledger: sub, gl: { cost: glCost, accumulated: glAccum, nbv: glNbv }, lines, balanced: maxVariance < TOLERANCE, max_variance: round2(maxVariance), at: now() };
|
||||
run('UPDATE close_periods SET reconciliation_json = ?, updated_at = ? WHERE id = ?', [JSON.stringify(result), now(), period.id]);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Finalize / roll-forward / reopen ---------------------------------------
|
||||
|
||||
function rollForward(period, userId, companyId) {
|
||||
const glEntries = require('./glEntries');
|
||||
const { getDefaults } = require('./glAccounts');
|
||||
const d = getDefaults(companyId);
|
||||
const win = periodWindow(period);
|
||||
const src = source(period);
|
||||
clearPostings(period, `${src}:rollfwd:%`);
|
||||
// Clear the year's posted depreciation expense into Retained Earnings.
|
||||
const expense = one(
|
||||
`SELECT IFNULL(SUM(debit), 0) AS d FROM gl_entries
|
||||
WHERE entity_id = ? AND book_code = ? AND account_type = 'Depreciation expense' AND fiscal_year = ?`,
|
||||
[companyId, period.book_code, period.fiscal_year]
|
||||
);
|
||||
const amount = round2(expense.d);
|
||||
if (amount > 0) {
|
||||
glEntries.insertEntry(buildLine({ win, fiscal_year: period.fiscal_year, account: d.retained_earnings_account, account_type: 'Retained earnings', description: `Year-end roll-forward — FY ${period.fiscal_year}`, reference: `ROLLFWD-${period.id}`, debit: amount, credit: 0, external_id: `${src}:rollfwd:re` }), period.book_code, userId, companyId, src);
|
||||
glEntries.insertEntry(buildLine({ win, fiscal_year: period.fiscal_year, account: d.expense_account, account_type: 'Depreciation expense', description: `Close depreciation expense — FY ${period.fiscal_year}`, reference: `ROLLFWD-${period.id}`, debit: 0, credit: amount, external_id: `${src}:rollfwd:exp` }), period.book_code, userId, companyId, src);
|
||||
}
|
||||
audit(userId, 'roll_forward', 'close_period', period.id, null, { period: win.label, amount });
|
||||
return { amount };
|
||||
}
|
||||
|
||||
function finalize(period, userId, companyId, options = {}) {
|
||||
if (period.locked) throw Object.assign(new Error('The period is already closed'), { status: 409 });
|
||||
// Gate: every required step must be marked done.
|
||||
const missing = requiredSteps(period.period_type).filter((key) => !period.steps?.[key]?.done);
|
||||
if (missing.length) {
|
||||
throw Object.assign(new Error(`Complete these steps before finalizing: ${missing.join(', ')}`), { status: 400, missing });
|
||||
}
|
||||
// Gate: reconciliation must have been run, and either balanced or explicitly acknowledged.
|
||||
const recon = period.reconciliation;
|
||||
if (!recon) throw Object.assign(new Error('Run the reconciliation before finalizing'), { status: 400 });
|
||||
if (!recon.balanced && !period.steps?.reconcile?.acknowledged && !options.acknowledge_variance) {
|
||||
throw Object.assign(new Error('The subledger does not reconcile to the GL. Clear the variance or acknowledge it to finalize.'), { status: 400, variance: recon.max_variance });
|
||||
}
|
||||
if (period.period_type === 'year') rollForward(period, userId, companyId);
|
||||
|
||||
const { bookLedger } = require('./books');
|
||||
const ledger = bookLedger(period.book_code, period.fiscal_year, companyId);
|
||||
const totals = ledger ? { cost: round2(ledger.summary.cost), accumulated: round2(ledger.summary.accumulated), nbv: round2(ledger.summary.nbv), assets: ledger.summary.assets } : null;
|
||||
run(
|
||||
'UPDATE close_periods SET status = ?, totals_json = ?, closed_by = ?, closed_at = ?, updated_at = ? WHERE id = ?',
|
||||
['closed', JSON.stringify(totals), userId, now(), now(), period.id]
|
||||
);
|
||||
audit(userId, 'finalize', 'close_period', period.id, null, { period: periodWindow(period).label, totals, acknowledged_variance: !recon.balanced });
|
||||
return getPeriod(period.id, companyId);
|
||||
}
|
||||
|
||||
function reopen(periodId, userId, companyId) {
|
||||
const period = getPeriod(periodId, companyId);
|
||||
if (!period) return null;
|
||||
if (period.status !== 'closed') throw Object.assign(new Error('Only a closed period can be reopened'), { status: 400 });
|
||||
// Reverse the close's postings so a re-finalize re-posts cleanly.
|
||||
const removed = run('DELETE FROM gl_entries WHERE entity_id = ? AND source = ?', [companyId, source(period)]);
|
||||
run('UPDATE close_periods SET status = ?, reopened_by = ?, reopened_at = ?, closed_by = NULL, closed_at = NULL, updated_at = ? WHERE id = ?', ['in_progress', userId, now(), now(), period.id]);
|
||||
audit(userId, 'reopen', 'close_period', period.id, { status: 'closed' }, { status: 'in_progress', reversed_entries: removed.changes });
|
||||
return getPeriod(periodId, companyId);
|
||||
}
|
||||
|
||||
// ---- Lock guard (exported; called by financial-write services) --------------
|
||||
|
||||
function primaryBookCode(companyId) {
|
||||
const row = one('SELECT code FROM books WHERE entity_id = ? AND is_primary = 1 LIMIT 1', [companyId]);
|
||||
return row ? row.code : null;
|
||||
}
|
||||
|
||||
// Throws if `date` falls inside a CLOSED period for (companyId, bookCode). No-op when nothing is closed,
|
||||
// so existing flows are unaffected until a period is actually locked.
|
||||
function assertPeriodOpen(companyId, bookCode, date) {
|
||||
if (!companyId || !bookCode || !date) return;
|
||||
const day = String(date).slice(0, 10);
|
||||
const closed = all(
|
||||
"SELECT * FROM close_periods WHERE entity_id = ? AND book_code = ? AND status = 'closed'",
|
||||
[companyId, bookCode]
|
||||
);
|
||||
for (const row of closed) {
|
||||
const win = periodWindow(row);
|
||||
if (day >= win.start && day <= win.end) {
|
||||
throw Object.assign(new Error(`The accounting period (${win.label}, ${bookCode}) is closed. Reopen it to make changes.`), { status: 409 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience guard for asset-level (subledger) writes: gated by the PRIMARY book's locked periods.
|
||||
function assertSubledgerOpen(companyId, date) {
|
||||
const primary = primaryBookCode(companyId);
|
||||
if (primary) assertPeriodOpen(companyId, primary, date);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertPeriodOpen,
|
||||
assertSubledgerOpen,
|
||||
capitalizeWip,
|
||||
finalize,
|
||||
getOrStart,
|
||||
getPeriod,
|
||||
listPeriods,
|
||||
periodWindow,
|
||||
postDepreciation,
|
||||
postDisposalEntries,
|
||||
primaryBookCode,
|
||||
reconcile,
|
||||
recordStep,
|
||||
reopen,
|
||||
stepData,
|
||||
stepDefs
|
||||
};
|
||||
@@ -5,6 +5,13 @@ const { hydrateAsset } = require('./assets');
|
||||
|
||||
const DISPOSAL_METHODS = ['sale', 'exchange', 'involuntary_conversion', 'like_kind', 'abandonment'];
|
||||
|
||||
// Reject a subledger write whose effective date falls in a closed period (primary book). No-op until a
|
||||
// period is actually locked. Lazy-required to avoid the closePeriods↔services cycle.
|
||||
function guardSubledger(assetId, date) {
|
||||
const row = one('SELECT entity_id FROM assets WHERE id = ?', [assetId]);
|
||||
if (row && date) require('./closePeriods').assertSubledgerOpen(row.entity_id, date);
|
||||
}
|
||||
|
||||
function yearOf(value) {
|
||||
if (!value) return new Date().getFullYear();
|
||||
return new Date(`${value}T00:00:00`).getFullYear();
|
||||
@@ -76,6 +83,7 @@ function recordDisposal(assetId, input, userId) {
|
||||
const asset = hydrateAsset(assetId);
|
||||
if (!asset) return null;
|
||||
const result = computeDisposal(asset, input);
|
||||
guardSubledger(assetId, result.disposal_date);
|
||||
const timestamp = now();
|
||||
|
||||
return tx(() => {
|
||||
@@ -121,6 +129,7 @@ function recordDisposal(assetId, input, userId) {
|
||||
function reverseDisposal(assetId, disposalId, userId) {
|
||||
const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]);
|
||||
if (!disposal) return null;
|
||||
guardSubledger(assetId, disposal.disposal_date);
|
||||
const timestamp = now();
|
||||
|
||||
return tx(() => {
|
||||
@@ -149,6 +158,7 @@ function recordAdjustment(assetId, input, userId) {
|
||||
const asset = one('SELECT id FROM assets WHERE id = ?', [assetId]);
|
||||
if (!asset) return null;
|
||||
const timestamp = now();
|
||||
guardSubledger(assetId, input.adjustment_date || timestamp.slice(0, 10));
|
||||
const result = run(
|
||||
`INSERT INTO asset_adjustments (asset_id, adjustment_date, type, amount, book_type, reason, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
|
||||
@@ -7,7 +7,13 @@ const { all, audit, now, one, run } = require('../db');
|
||||
const TYPES = ['asset', 'contra_asset', 'liability', 'equity', 'revenue', 'expense'];
|
||||
const BALANCES = ['debit', 'credit'];
|
||||
// Used as a last-resort fallback if a company somehow has no defaults row (seed normally creates one).
|
||||
const FALLBACK_DEFAULTS = { asset_account: '1500', accumulated_account: '1590', expense_account: '6400', pm_expense_account: '6450', pm_clearing_account: '2000' };
|
||||
const FALLBACK_DEFAULTS = {
|
||||
asset_account: '1500', accumulated_account: '1590', expense_account: '6400',
|
||||
pm_expense_account: '6450', pm_clearing_account: '2000',
|
||||
cwip_account: '1800', gain_account: '7200', loss_account: '7300',
|
||||
proceeds_account: '1010', retained_earnings_account: '3900'
|
||||
};
|
||||
const DEFAULT_KEYS = Object.keys(FALLBACK_DEFAULTS);
|
||||
|
||||
function fromRow(row) {
|
||||
if (!row) return null;
|
||||
@@ -85,17 +91,22 @@ function getDefaults(companyId) {
|
||||
|
||||
function saveDefaults(body, userId, companyId) {
|
||||
const before = getDefaults(companyId);
|
||||
const keys = ['asset_account', 'accumulated_account', 'expense_account', 'pm_expense_account', 'pm_clearing_account'];
|
||||
const next = {};
|
||||
for (const key of keys) next[key] = body[key] !== undefined ? (String(body[key] || '').trim() || null) : (before[key] || null);
|
||||
for (const key of DEFAULT_KEYS) next[key] = body[key] !== undefined ? (String(body[key] || '').trim() || null) : (before[key] || null);
|
||||
run(
|
||||
`INSERT INTO gl_account_defaults (entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO gl_account_defaults
|
||||
(entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account,
|
||||
cwip_account, gain_account, loss_account, proceeds_account, retained_earnings_account, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(entity_id) DO UPDATE SET
|
||||
asset_account = excluded.asset_account, accumulated_account = excluded.accumulated_account,
|
||||
expense_account = excluded.expense_account, pm_expense_account = excluded.pm_expense_account,
|
||||
pm_clearing_account = excluded.pm_clearing_account, updated_at = excluded.updated_at`,
|
||||
[companyId, next.asset_account, next.accumulated_account, next.expense_account, next.pm_expense_account, next.pm_clearing_account, now()]
|
||||
pm_clearing_account = excluded.pm_clearing_account, cwip_account = excluded.cwip_account,
|
||||
gain_account = excluded.gain_account, loss_account = excluded.loss_account,
|
||||
proceeds_account = excluded.proceeds_account, retained_earnings_account = excluded.retained_earnings_account,
|
||||
updated_at = excluded.updated_at`,
|
||||
[companyId, next.asset_account, next.accumulated_account, next.expense_account, next.pm_expense_account, next.pm_clearing_account,
|
||||
next.cwip_account, next.gain_account, next.loss_account, next.proceeds_account, next.retained_earnings_account, now()]
|
||||
);
|
||||
const after = getDefaults(companyId);
|
||||
audit(userId, 'update', 'gl_account_defaults', companyId, before, after);
|
||||
|
||||
@@ -130,13 +130,18 @@ function insertEntry(entry, book, userId, companyId, source) {
|
||||
}
|
||||
|
||||
function createEntry(book, body, userId, companyId) {
|
||||
return insertEntry(validateEntry(body), book, userId, companyId, 'manual');
|
||||
const entry = validateEntry(body);
|
||||
require('./closePeriods').assertPeriodOpen(companyId, book, entry.entry_date);
|
||||
return insertEntry(entry, book, userId, companyId, 'manual');
|
||||
}
|
||||
|
||||
function updateEntry(id, body, userId, companyId) {
|
||||
const before = getEntry(id, companyId);
|
||||
if (!before) return null;
|
||||
const entry = validateEntry({ ...before, ...body });
|
||||
// Block edits dated in (or moving into) a closed period for this entry's book.
|
||||
require('./closePeriods').assertPeriodOpen(companyId, before.book_code, before.entry_date);
|
||||
require('./closePeriods').assertPeriodOpen(companyId, before.book_code, entry.entry_date);
|
||||
run(
|
||||
`UPDATE gl_entries SET entry_date = ?, fiscal_year = ?, account = ?, account_type = ?, description = ?,
|
||||
reference = ?, debit = ?, credit = ?, asset_id = ?, external_id = ?, updated_at = ? WHERE id = ?`,
|
||||
@@ -153,6 +158,7 @@ function updateEntry(id, body, userId, companyId) {
|
||||
function deleteEntry(id, userId, companyId) {
|
||||
const before = getEntry(id, companyId);
|
||||
if (!before) return false;
|
||||
require('./closePeriods').assertPeriodOpen(companyId, before.book_code, before.entry_date);
|
||||
run('DELETE FROM gl_entries WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'gl_entry', id, before, null);
|
||||
return true;
|
||||
@@ -280,6 +286,7 @@ module.exports = {
|
||||
entriesReport,
|
||||
entryHistory,
|
||||
getEntry,
|
||||
insertEntry,
|
||||
listEntries,
|
||||
previewImport,
|
||||
updateEntry
|
||||
|
||||
@@ -1881,6 +1881,91 @@ async function main() {
|
||||
const logForbidden = await fetch(`${baseUrl}/api/app-logs`, { headers: limitedAuth });
|
||||
if (logForbidden.status !== 403) throw new Error('Application logs should require the admin.logs capability');
|
||||
|
||||
// ---- Period close (month-end / year-end) -----------------------------------
|
||||
// Run the whole close in company A explicitly (bare `auth` resolves to the admin's first company,
|
||||
// which isn't deterministically company A). Seed a depreciating GAAP asset so depreciation is non-zero.
|
||||
const aHeaders = companyHeaders(companyA);
|
||||
await request('/api/assets', {
|
||||
method: 'POST', headers: aHeaders,
|
||||
body: JSON.stringify({
|
||||
asset_id: `CLZ-${suffix}`, description: 'Close-test machine', category: 'Computer Equipment',
|
||||
acquisition_cost: 12000, acquired_date: '2026-01-10', in_service_date: '2026-01-10', useful_life_months: 60,
|
||||
books: [{ book_type: 'GAAP', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, cost: 12000 }]
|
||||
})
|
||||
});
|
||||
|
||||
const startClose = await request('/api/close-periods/start', {
|
||||
method: 'POST', headers: aHeaders,
|
||||
body: JSON.stringify({ book: 'GAAP', period_type: 'month', fiscal_year: 2026, period_month: 3 })
|
||||
});
|
||||
const closeId = startClose.period.id;
|
||||
if (startClose.period.status !== 'in_progress') throw new Error('Close period did not start');
|
||||
|
||||
const postedSource = `close:${closeId}`;
|
||||
const closeEntries = async () => (await request('/api/books/GAAP/gl-entries?year=2026', { headers: aHeaders })).entries.filter((e) => e.source === postedSource);
|
||||
|
||||
// Post depreciation → balanced JE (Dr expense = Cr accumulated), tagged close:<id>.
|
||||
const dep = (await request(`/api/close-periods/${closeId}/post-depreciation`, { method: 'POST', headers: aHeaders })).summary;
|
||||
if (!(dep.debit > 0) || dep.debit !== dep.credit) throw new Error(`Depreciation JE not balanced: ${JSON.stringify(dep)}`);
|
||||
const firstPost = await closeEntries();
|
||||
if (!firstPost.length) throw new Error('Close did not post GL entries');
|
||||
|
||||
// Idempotent re-post: same entry count, not doubled.
|
||||
await request(`/api/close-periods/${closeId}/post-depreciation`, { method: 'POST', headers: aHeaders });
|
||||
if ((await closeEntries()).length !== firstPost.length) throw new Error('Depreciation re-post was not idempotent');
|
||||
|
||||
// Reconcile, then mark the reconcile step done (acknowledging any opening variance).
|
||||
const recon = (await request(`/api/close-periods/${closeId}/reconcile`, { method: 'POST', headers: aHeaders })).reconciliation;
|
||||
if (!recon.lines || recon.lines.length !== 3) throw new Error('Reconciliation result malformed');
|
||||
await request(`/api/close-periods/${closeId}/steps/reconcile`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ done: true, acknowledged: true, notes: 'opening balance' }) });
|
||||
|
||||
// Finalize → period closed.
|
||||
const finalized = (await request(`/api/close-periods/${closeId}/finalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ acknowledge_variance: true }) })).period;
|
||||
if (finalized.status !== 'closed') throw new Error('Finalize did not close the period');
|
||||
|
||||
// Lock: a backdated GL entry into the closed month is rejected (409).
|
||||
const lockedPost = await fetch(`${baseUrl}/api/books/GAAP/gl-entries`, {
|
||||
method: 'POST', headers: { ...aHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entry_date: '2026-03-15', account: '6400', debit: 10, credit: 0 })
|
||||
});
|
||||
if (lockedPost.status !== 409) throw new Error(`Backdated GL post into a closed period should be 409, got ${lockedPost.status}`);
|
||||
|
||||
// Reopen → close JEs reversed, status in_progress, and the backdated write now succeeds.
|
||||
const reopened = (await request(`/api/close-periods/${closeId}/reopen`, { method: 'POST', headers: aHeaders })).period;
|
||||
if (reopened.status !== 'in_progress') throw new Error('Reopen did not reset status');
|
||||
if ((await closeEntries()).length !== 0) throw new Error('Reopen did not reverse the close postings');
|
||||
await request('/api/books/GAAP/gl-entries', { method: 'POST', headers: aHeaders, body: JSON.stringify({ entry_date: '2026-03-15', account: '6400', debit: 10, credit: 0 }) });
|
||||
|
||||
// Every close action is audited.
|
||||
const closeAudit = await request('/api/audit-logs?entity_type=close_period', { headers: auth });
|
||||
for (const action of ['start', 'post_depreciation', 'finalize', 'reopen']) {
|
||||
if (!closeAudit.rows.some((r) => r.action === action)) throw new Error(`Close action not audited: ${action}`);
|
||||
}
|
||||
|
||||
// Isolation: company B cannot read company A's close period.
|
||||
const crossClose = await fetch(`${baseUrl}/api/close-periods/${closeId}`, { headers: companyHeaders(companyB) });
|
||||
if (crossClose.status !== 404) throw new Error('Cross-company close period should 404');
|
||||
|
||||
// Capitalize WIP: a Construction-in-progress asset moves into service + posts a JE.
|
||||
const cip = (await request('/api/assets', {
|
||||
method: 'POST', headers: aHeaders,
|
||||
body: JSON.stringify({ asset_id: `WIP-${suffix}`, description: 'Build in progress', category: 'Computer Equipment', acquisition_cost: 5000, status: 'cip', acquired_date: '2026-02-01' })
|
||||
})).asset;
|
||||
const capped = await request(`/api/close-periods/${closeId}/wip/${cip.id}/capitalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ in_service_date: '2026-06-01' }) });
|
||||
if (capped.asset.status !== 'in_service') throw new Error('WIP capitalization did not place the asset in service');
|
||||
|
||||
// Year close: finalize runs the roll-forward to Retained Earnings.
|
||||
const yearClose = (await request('/api/close-periods/start', { method: 'POST', headers: aHeaders, body: JSON.stringify({ book: 'GAAP', period_type: 'year', fiscal_year: 2026 }) })).period;
|
||||
await request(`/api/close-periods/${yearClose.id}/post-depreciation`, { method: 'POST', headers: aHeaders });
|
||||
await request(`/api/close-periods/${yearClose.id}/reconcile`, { method: 'POST', headers: aHeaders });
|
||||
for (const key of ['final_month_close', 'reconcile', 'physical_inventory']) {
|
||||
await request(`/api/close-periods/${yearClose.id}/steps/${key}`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ done: true, acknowledged: true }) });
|
||||
}
|
||||
await request(`/api/close-periods/${yearClose.id}/finalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ acknowledge_variance: true }) });
|
||||
const reEntries = (await request('/api/books/GAAP/gl-entries?year=2026', { headers: aHeaders })).entries
|
||||
.filter((e) => e.source === `close:${yearClose.id}` && e.account_type === 'Retained earnings');
|
||||
if (!reEntries.length) throw new Error('Year-end roll-forward did not post a Retained Earnings entry');
|
||||
|
||||
console.log('Smoke test passed');
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,10 @@
|
||||
v-if="activeView === 'gl-journals'"
|
||||
:token="token"
|
||||
/>
|
||||
<CloseView
|
||||
v-if="activeView === 'close'"
|
||||
:token="token"
|
||||
/>
|
||||
<TemplatesView
|
||||
v-if="activeView === 'templates'"
|
||||
:category-items="categoryItems"
|
||||
@@ -313,6 +317,7 @@ import AlertsView from './views/AlertsView.vue';
|
||||
import AssignmentsView from './views/AssignmentsView.vue';
|
||||
import BooksView from './views/BooksView.vue';
|
||||
import GlJournalsView from './views/GlJournalsView.vue';
|
||||
import CloseView from './views/CloseView.vue';
|
||||
import ContactsView from './views/ContactsView.vue';
|
||||
import AssetsView from './views/AssetsView.vue';
|
||||
import DashboardView from './views/DashboardView.vue';
|
||||
@@ -351,6 +356,7 @@ export default {
|
||||
AssetsView,
|
||||
BooksView,
|
||||
GlJournalsView,
|
||||
CloseView,
|
||||
ChangePasswordCard,
|
||||
ContactsView,
|
||||
DashboardView,
|
||||
@@ -534,6 +540,7 @@ export default {
|
||||
reports: 'Depreciation schedules, monthly expense, and book values',
|
||||
books: 'Configure books, assign tax rule sets, and set entry defaults',
|
||||
'gl-journals': 'General ledger rollup and editable journal entries per book',
|
||||
close: 'Guided month-end / year-end close: post, reconcile, and lock the period',
|
||||
templates: 'JSON-driven entry defaults and custom fields',
|
||||
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
|
||||
admin: 'Users, configuration, and integrations',
|
||||
@@ -558,6 +565,7 @@ export default {
|
||||
reports: 'Reports',
|
||||
books: 'Books',
|
||||
'gl-journals': 'GL and Journals',
|
||||
close: 'Period Close',
|
||||
templates: 'Templates',
|
||||
'tax-rules': 'Tax rules',
|
||||
admin: 'Configuration',
|
||||
@@ -625,6 +633,7 @@ export default {
|
||||
reports: ['finance.view'],
|
||||
books: ['finance.manage'],
|
||||
'gl-journals': ['finance.view', 'finance.manage'],
|
||||
close: ['finance.close', 'finance.manage'],
|
||||
'tax-rules': ['finance.manage'],
|
||||
'admin-users': ['admin.users'],
|
||||
'admin-security': ['admin.security'],
|
||||
|
||||
39
src/components/CloseTable.vue
Normal file
39
src/components/CloseTable.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="asset-table">
|
||||
<thead>
|
||||
<tr><th v-for="col in columns" :key="col.key">{{ col.label }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in rows" :key="i">
|
||||
<td v-for="col in columns" :key="col.key" :class="{ 'text-right': col.format === 'currency' }">
|
||||
<slot :name="`cell-${col.key}`" :row="row">{{ display(row[col.key], col.format) }}</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length && empty">
|
||||
<td :colspan="columns.length" class="quiet">{{ empty }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { currency } from '../utils/format';
|
||||
|
||||
// Compact, read-only table for the close wizard's step panels. Columns are { key, label, format }; a
|
||||
// `cell-<key>` slot overrides rendering for a column. Lighter than DataTable (no search/pagination).
|
||||
export default {
|
||||
props: {
|
||||
rows: { type: Array, default: () => [] },
|
||||
columns: { type: Array, required: true },
|
||||
empty: { type: String, default: '' }
|
||||
},
|
||||
methods: {
|
||||
display(value, format) {
|
||||
if (value === null || value === undefined || value === '') return format === 'currency' ? currency(0) : '';
|
||||
return format === 'currency' ? currency(value) : value;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -374,6 +374,126 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- PERIOD CLOSE -->
|
||||
<section v-show="isShown('close')" class="manual-section">
|
||||
<h2>Period close — month-end & year-end</h2>
|
||||
<p>
|
||||
<strong>Financial → Period Close</strong> is a guided wizard that walks you through closing an accounting
|
||||
period for the fixed-asset subledger. It reviews the period's activity, <strong>posts the journal entries the
|
||||
close generates into the general ledger</strong>, reconciles the Fixed Asset Register to the GL, and then
|
||||
<strong>locks the period</strong> so it can't be changed after the fact. Every action is recorded in
|
||||
<a href="#" @click.prevent="select('admin')">Activity & Audit Trail</a>.
|
||||
</p>
|
||||
|
||||
<h3>Scope: which book, which period</h3>
|
||||
<ul>
|
||||
<li>A close runs against <strong>one book</strong> — pick it at the top of the screen; it defaults to your
|
||||
<strong>primary book</strong> (the one that feeds the financial GL). Run a separate close for each book you
|
||||
maintain (for example, close GAAP for the financial GL; tax books are reviewed at year-end).</li>
|
||||
<li>Choose <strong>Month</strong> (a fiscal year + month) or <strong>Year</strong> (a full fiscal year), then
|
||||
<strong>Start / Resume</strong>. A close in progress is saved, so you can leave and come back to it. The
|
||||
<strong>Recent closes</strong> list shows every period with its status and a 🔒 lock badge.</li>
|
||||
</ul>
|
||||
|
||||
<h3>The month-end checklist</h3>
|
||||
<p>Each step shows the live data for the period and lets you act, then mark it done:</p>
|
||||
<ol>
|
||||
<li><strong>Record additions</strong> — review capital purchases placed in service in the period. Any asset
|
||||
below your <strong>capitalization threshold</strong> is flagged so you can confirm it belongs on the register.</li>
|
||||
<li><strong>Record disposals</strong> — review assets sold, scrapped, or retired in the period (cost,
|
||||
accumulated depreciation, gain/loss). You can <strong>post the disposal journal entries</strong> here.</li>
|
||||
<li><strong>Capitalize WIP</strong> — review Construction-in-progress assets and <strong>capitalize</strong>
|
||||
completed ones, which moves them into service and begins depreciation.</li>
|
||||
<li><strong>Process & post depreciation</strong> — compute the period's depreciation and <strong>post it to
|
||||
the GL</strong>, allocated by department / cost center. Fully-depreciated assets that would still compute
|
||||
depreciation are flagged for review.</li>
|
||||
<li><strong>Check for impairments</strong> — review flagged assets and record any impairment loss (from the
|
||||
asset's Adjustments tab).</li>
|
||||
<li><strong>Reconcile subledger to GL</strong> — compare the register to the GL control accounts and clear or
|
||||
acknowledge any variance (see below). <em>Required to finalize.</em></li>
|
||||
<li><strong>Generate reports</strong> — run and file the Fixed Asset Register, Depreciation Summary, and
|
||||
Additions / Disposals reports for your records.</li>
|
||||
<li><strong>Finalize & lock</strong> — freeze the period.</li>
|
||||
</ol>
|
||||
|
||||
<h3>What gets posted to the GL</h3>
|
||||
<p>
|
||||
The close writes <strong>real, balanced journal entries</strong> into the stored GL layer (visible under
|
||||
<a href="#" @click.prevent="select('books')">Books → GL and Journals</a>). The accounts come from your
|
||||
<strong>GL posting defaults</strong> (per-asset overrides win where set):
|
||||
</p>
|
||||
<table class="manual-table">
|
||||
<thead><tr><th>Event</th><th>Debit</th><th>Credit</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Period depreciation (by department)</td><td>Depreciation Expense</td><td>Accumulated Depreciation</td></tr>
|
||||
<tr><td>Capitalize WIP</td><td>Fixed Asset cost</td><td>Construction-in-progress (CWIP)</td></tr>
|
||||
<tr><td>Disposal</td><td>Accum. depreciation + Proceeds (+ Loss)</td><td>Asset cost (+ Gain)</td></tr>
|
||||
<tr><td>Year-end roll-forward</td><td>Retained Earnings</td><td>Depreciation Expense</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||
Every close posting is <strong>tagged to its period</strong>, which makes posting <strong>idempotent</strong>:
|
||||
re-running a step (for example, re-posting depreciation after a correction) replaces the prior entries rather
|
||||
than duplicating them. Manage the accounts these entries use in
|
||||
<a href="#" @click.prevent="select('books')">Books → GL and Journals → Chart of accounts</a> (the
|
||||
<strong>posting defaults</strong>: asset cost, accumulated depreciation, depreciation expense, CWIP, gain/loss,
|
||||
proceeds clearing, and retained earnings).
|
||||
</v-alert>
|
||||
|
||||
<h3>Reconciling the subledger to the GL</h3>
|
||||
<p>
|
||||
The <strong>Reconcile</strong> step compares the Fixed Asset Register (the subledger) to the GL
|
||||
control-account balances — <strong>cost</strong>, <strong>accumulated depreciation</strong>, and
|
||||
<strong>net book value</strong> — and shows the variance per account. A green <em>Balanced</em> badge means
|
||||
they agree; otherwise the variance is shown so you can clear it (post an adjusting entry) or
|
||||
<strong>acknowledge</strong> it and finalize anyway.
|
||||
</p>
|
||||
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||
When you <em>first</em> adopt closes, the GL won't yet hold the asset's full history, so the register
|
||||
(life-to-date) and the GL (only what's been posted) will differ — that opening variance is expected. Post an
|
||||
opening-balance entry to clear it, or acknowledge it. Once you run closes forward period over period, the two
|
||||
stay in step.
|
||||
</v-alert>
|
||||
|
||||
<h3>Finalizing & the period lock</h3>
|
||||
<p>
|
||||
<strong>Finalize & lock</strong> becomes available once the required steps are done and the reconciliation
|
||||
has been run. Finalizing snapshots the period's totals and sets the period to <strong>Closed</strong>. A
|
||||
closed period is <strong>locked</strong>:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Any change whose effective date falls inside the closed period is <strong>rejected</strong> — including
|
||||
<strong>GL journal entries</strong> (for that book), and <strong>disposals, impairments, and asset
|
||||
financial-date / cost edits</strong> (gated by the <strong>primary</strong> book's locked periods, since
|
||||
that book is the subledger that feeds the GL).</li>
|
||||
<li>Edits to non-financial fields (location, custodian, notes, and so on) are <strong>not</strong> blocked.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Year-end close</h3>
|
||||
<p>The year-end checklist includes everything above, plus:</p>
|
||||
<ul>
|
||||
<li><strong>Final month-end close</strong> — confirm the final month is closed and its depreciation posted.</li>
|
||||
<li><strong>Physical inventory</strong> — confirm you've verified high-value / mobile assets still exist.</li>
|
||||
<li><strong>Update tax books</strong> — recompute tax depreciation (MACRS / §179) and review book-vs-tax
|
||||
differences.</li>
|
||||
<li><strong>Roll-forward</strong> — finalizing a year close automatically posts the <strong>roll-forward</strong>
|
||||
that clears the year's depreciation expense into <strong>Retained Earnings</strong> and locks the year.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Reopening a closed period</h3>
|
||||
<p>
|
||||
If you must correct a closed period, use <strong>Reopen</strong> (requires the close permission). Reopening
|
||||
<strong>reverses all of that close's posted entries</strong>, returns the period to <em>in progress</em>,
|
||||
and unlocks it so corrections can be made — then you re-run and finalize again. The reopen, the reversed
|
||||
entries, and the re-close are all audited.
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="comfortable" class="manual-callout">
|
||||
Running the close needs the <strong>“Run month-end / year-end close & reopen periods”</strong> capability
|
||||
(included in the Finance role). Because closing posts to the GL and locks the books, keep this permission to
|
||||
the people responsible for the close.
|
||||
</v-alert>
|
||||
</section>
|
||||
|
||||
<!-- TAX RULES -->
|
||||
<section v-show="isShown('taxrules')" class="manual-section">
|
||||
<h2>Tax rule sets</h2>
|
||||
@@ -1067,6 +1187,7 @@ export default {
|
||||
{ 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 journal entry entries import export csv excel conflict merge history zone section 179' },
|
||||
{ id: 'close', title: 'Period close (month & year-end)', icon: 'mdi-calendar-lock', keywords: 'close month end year end period lock reconcile subledger gl roll forward retained earnings depreciation post reopen finalize wip capitalize disposal impairment audit' },
|
||||
{ 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 meter usage reading hours lifespan wear curve dynamic nightly recompute' },
|
||||
|
||||
@@ -161,6 +161,7 @@ export const navItems = [
|
||||
children: [
|
||||
{ key: 'books', label: 'Books', icon: 'mdi-book-open-variant' },
|
||||
{ key: 'gl-journals', label: 'GL and Journals', icon: 'mdi-book-account-outline' },
|
||||
{ key: 'close', label: 'Period Close', icon: 'mdi-calendar-lock' },
|
||||
{ key: 'tax-rules', label: 'Tax rules', icon: 'mdi-scale-balance' }
|
||||
]
|
||||
},
|
||||
|
||||
383
src/views/CloseView.vue
Normal file
383
src/views/CloseView.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<section class="content-grid">
|
||||
<!-- Launcher -->
|
||||
<v-card class="span-12 panel-pad">
|
||||
<div class="toolbar-row mb-1">
|
||||
<h2 class="section-title">Period Close</h2>
|
||||
</div>
|
||||
<p class="quiet text-body-2 mb-3">
|
||||
Guided month-end / year-end close for the fixed-asset subledger. Posts the period's journal entries to the GL,
|
||||
reconciles the register, and locks the period against backdated changes. Every action is recorded in
|
||||
Admin → Activity & Audit Trail.
|
||||
</p>
|
||||
<div class="toolbar-row" style="flex-wrap:wrap;gap:12px">
|
||||
<v-select v-model="form.book" :items="bookItems" label="Book" density="compact" hide-details style="max-width:200px" />
|
||||
<v-select v-model="form.period_type" :items="periodTypeItems" item-title="title" item-value="value" label="Period" density="compact" hide-details style="max-width:140px" />
|
||||
<v-text-field v-model.number="form.fiscal_year" type="number" label="Fiscal year" density="compact" hide-details style="max-width:130px" />
|
||||
<v-select v-if="form.period_type === 'month'" v-model="form.period_month" :items="monthItems" item-title="title" item-value="value" label="Month" density="compact" hide-details style="max-width:160px" />
|
||||
<v-btn color="primary" prepend-icon="mdi-play" :loading="starting" @click="startClose">Start / Resume</v-btn>
|
||||
</div>
|
||||
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- Active close: the wizard -->
|
||||
<v-card v-if="period" class="span-8 panel-pad">
|
||||
<div class="toolbar-row mb-3">
|
||||
<div>
|
||||
<h2 class="section-title">
|
||||
{{ window.label }} · {{ period.book_code }}
|
||||
<v-chip size="small" :color="statusColor" variant="tonal" class="ml-2">{{ period.status }}</v-chip>
|
||||
<v-icon v-if="period.locked" icon="mdi-lock" color="error" class="ml-1" />
|
||||
</h2>
|
||||
<div class="quiet text-body-2">{{ period.period_type === 'year' ? 'Year-end close' : 'Month-end close' }}</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn v-if="period.locked" color="warning" variant="tonal" size="small" prepend-icon="mdi-lock-open" :loading="busy" @click="reopen">Reopen</v-btn>
|
||||
<v-btn v-else color="success" prepend-icon="mdi-lock-check" :loading="busy" :disabled="!canFinalize" @click="finalize">Finalize & lock</v-btn>
|
||||
</div>
|
||||
<v-alert v-if="!period.locked && !canFinalize" type="info" density="compact" variant="tonal" class="mb-3">
|
||||
Complete the required steps to finalize: {{ missingRequired.join(', ') || 'run the reconciliation' }}.
|
||||
</v-alert>
|
||||
<v-alert v-if="message" type="success" density="compact" class="mb-3">{{ message }}</v-alert>
|
||||
|
||||
<!-- Step cards -->
|
||||
<div v-for="(step, index) in steps" :key="step.key" class="close-step" :class="{ done: isDone(step.key) }">
|
||||
<div class="toolbar-row">
|
||||
<div class="step-num">{{ index + 1 }}</div>
|
||||
<div>
|
||||
<div class="font-weight-medium">
|
||||
{{ step.title }}
|
||||
<v-chip v-if="step.required" size="x-small" color="primary" variant="tonal" class="ml-1">required</v-chip>
|
||||
<v-icon v-if="isDone(step.key)" icon="mdi-check-circle" color="success" size="small" class="ml-1" />
|
||||
</div>
|
||||
<div class="quiet text-caption">{{ step.description }}</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn v-if="!period.locked" size="x-small" :variant="isDone(step.key) ? 'tonal' : 'outlined'" :color="isDone(step.key) ? 'success' : undefined" @click="toggleStep(step.key)">
|
||||
{{ isDone(step.key) ? 'Done' : 'Mark done' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="step-body">
|
||||
<!-- Additions -->
|
||||
<template v-if="step.key === 'additions'">
|
||||
<div class="quiet text-caption mb-1" v-if="data.threshold">Capitalization threshold: {{ currency(data.threshold) }}</div>
|
||||
<CloseTable :rows="data.additions" :columns="addCols" empty="No additions in this period.">
|
||||
<template #cell-below="{ row }"><v-chip v-if="row.below_threshold" size="x-small" color="warning" variant="tonal">below threshold</v-chip></template>
|
||||
</CloseTable>
|
||||
</template>
|
||||
|
||||
<!-- Disposals -->
|
||||
<template v-else-if="step.key === 'disposals'">
|
||||
<CloseTable :rows="data.disposals" :columns="dispCols" empty="No disposals in this period." />
|
||||
<v-btn v-if="!period.locked && data.disposals.length" size="x-small" variant="tonal" class="mt-2" prepend-icon="mdi-bank-transfer" :loading="busy" @click="postDisposals">Post disposal entries</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- WIP -->
|
||||
<template v-else-if="step.key === 'wip'">
|
||||
<CloseTable :rows="data.wip" :columns="wipCols" empty="No work-in-progress assets.">
|
||||
<template #cell-action="{ row }">
|
||||
<v-btn v-if="!period.locked" size="x-small" color="primary" variant="tonal" :loading="busy" @click="capitalize(row)">Capitalize</v-btn>
|
||||
</template>
|
||||
</CloseTable>
|
||||
</template>
|
||||
|
||||
<!-- Depreciation -->
|
||||
<template v-else-if="step.key === 'depreciation'">
|
||||
<div class="ledger-summary mb-2">
|
||||
<div class="ledger-chip"><span class="quiet">Period depreciation</span><strong>{{ currency(data.depreciation.total) }}</strong></div>
|
||||
</div>
|
||||
<CloseTable :rows="data.depreciation.by_department" :columns="deptCols" empty="No depreciation this period." />
|
||||
<v-alert v-if="data.depreciation.fully_depreciated_still_running.length" type="warning" density="compact" variant="tonal" class="mt-2">
|
||||
{{ data.depreciation.fully_depreciated_still_running.length }} fully-depreciated asset(s) still computing depreciation — review.
|
||||
</v-alert>
|
||||
<v-btn v-if="!period.locked" size="small" color="primary" class="mt-2" prepend-icon="mdi-bank-plus" :loading="busy" @click="postDepreciation">Post depreciation</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- Impairments -->
|
||||
<template v-else-if="step.key === 'impairments'">
|
||||
<CloseTable :rows="data.impairments" :columns="impairCols" empty="No impairment candidates flagged." />
|
||||
<div class="quiet text-caption mt-1">Record impairments from the asset's Adjustments tab; they post when this period is reconciled.</div>
|
||||
</template>
|
||||
|
||||
<!-- Reconcile -->
|
||||
<template v-else-if="step.key === 'reconcile'">
|
||||
<v-btn v-if="!period.locked" size="small" variant="tonal" prepend-icon="mdi-scale-balance" :loading="busy" @click="runReconcile">Run reconciliation</v-btn>
|
||||
<div v-if="reconciliation" class="mt-2">
|
||||
<v-chip size="small" :color="reconciliation.balanced ? 'success' : 'warning'" variant="tonal" class="mb-2">
|
||||
{{ reconciliation.balanced ? 'Balanced' : `Variance ${currency(reconciliation.max_variance)} — clear or acknowledge` }}
|
||||
</v-chip>
|
||||
<CloseTable :rows="reconciliation.lines" :columns="reconCols" empty="" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reports -->
|
||||
<template v-else-if="step.key === 'reports'">
|
||||
<div class="quiet text-body-2">Run and file the final reports for the period:</div>
|
||||
<ul class="quiet text-body-2 mt-1">
|
||||
<li>Fixed Asset Register · Depreciation Summary · Additions / Disposals</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Year-only confirm steps -->
|
||||
<template v-else-if="['final_month_close', 'physical_inventory', 'tax_books'].includes(step.key)">
|
||||
<div class="quiet text-body-2">{{ step.description }} Mark done once verified.</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- History -->
|
||||
<v-card :class="period ? 'span-4 panel-pad' : 'span-12 panel-pad'">
|
||||
<h3 class="section-title mb-2">Recent closes</h3>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="p in periods"
|
||||
:key="p.id"
|
||||
:active="period && p.id === period.id"
|
||||
rounded="sm"
|
||||
@click="openPeriod(p.id)"
|
||||
>
|
||||
<v-list-item-title>{{ labelFor(p) }} · {{ p.book_code }}</v-list-item-title>
|
||||
<template #append>
|
||||
<v-icon v-if="p.status === 'closed'" icon="mdi-lock" color="error" size="small" />
|
||||
<v-chip v-else size="x-small" variant="tonal">{{ p.status }}</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="!periods.length" class="quiet text-body-2">No closes yet.</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { apiRequest } from '../services/api';
|
||||
import { currency } from '../utils/format';
|
||||
import CloseTable from '../components/CloseTable.vue';
|
||||
|
||||
// Period Close wizard (Financial → Period Close). Walks the month/year close checklist, posts the
|
||||
// period's depreciation / WIP / disposal / roll-forward journal entries to the GL, reconciles the
|
||||
// register to the GL, and finalizes (locks) the period. Server enforces gating, scoping, and the lock.
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
export default {
|
||||
components: { CloseTable },
|
||||
props: { token: { type: String, required: true } },
|
||||
data() {
|
||||
return {
|
||||
books: [],
|
||||
form: { book: 'GAAP', period_type: 'month', fiscal_year: new Date().getFullYear(), period_month: new Date().getMonth() + 1 },
|
||||
periodTypeItems: [{ title: 'Month', value: 'month' }, { title: 'Year', value: 'year' }],
|
||||
monthItems: MONTHS.map((title, i) => ({ title, value: i + 1 })),
|
||||
period: null,
|
||||
steps: [],
|
||||
data: null,
|
||||
periods: [],
|
||||
starting: false,
|
||||
busy: false,
|
||||
error: '',
|
||||
message: '',
|
||||
addCols: [
|
||||
{ key: 'asset_id', label: 'Asset' }, { key: 'description', label: 'Description' },
|
||||
{ key: 'acquisition_cost', label: 'Cost', format: 'currency' }, { key: 'in_service_date', label: 'In service' }, { key: 'below', label: '' }
|
||||
],
|
||||
dispCols: [
|
||||
{ key: 'asset_id', label: 'Asset' }, { key: 'description', label: 'Description' }, { key: 'disposal_date', label: 'Date' },
|
||||
{ key: 'disposed_cost', label: 'Cost', format: 'currency' }, { key: 'accumulated_depreciation', label: 'Accum.', format: 'currency' }, { key: 'gain_loss', label: 'Gain/(loss)', format: 'currency' }
|
||||
],
|
||||
wipCols: [
|
||||
{ key: 'asset_id', label: 'Asset' }, { key: 'description', label: 'Description' }, { key: 'acquisition_cost', label: 'Cost', format: 'currency' }, { key: 'action', label: '' }
|
||||
],
|
||||
deptCols: [{ key: 'department', label: 'Department / cost center' }, { key: 'amount', label: 'Depreciation', format: 'currency' }],
|
||||
impairCols: [{ key: 'asset_id', label: 'Asset' }, { key: 'description', label: 'Description' }, { key: 'condition', label: 'Condition' }, { key: 'acquisition_cost', label: 'Cost', format: 'currency' }],
|
||||
reconCols: [
|
||||
{ key: 'account', label: 'Account' }, { key: 'subledger', label: 'Subledger', format: 'currency' },
|
||||
{ key: 'gl', label: 'General ledger', format: 'currency' }, { key: 'variance', label: 'Variance', format: 'currency' }
|
||||
]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
bookItems() {
|
||||
return this.books.map((b) => b.code);
|
||||
},
|
||||
window() {
|
||||
return this.data?.window || { label: '' };
|
||||
},
|
||||
reconciliation() {
|
||||
return this.data?.reconciliation || this.period?.reconciliation || null;
|
||||
},
|
||||
statusColor() {
|
||||
return { open: 'grey', in_progress: 'info', closed: 'success' }[this.period?.status] || undefined;
|
||||
},
|
||||
requiredKeys() {
|
||||
return this.steps.filter((s) => s.required).map((s) => s.key);
|
||||
},
|
||||
missingRequired() {
|
||||
return this.requiredKeys.filter((key) => !this.isDone(key));
|
||||
},
|
||||
canFinalize() {
|
||||
return this.period && !this.period.locked && this.missingRequired.length === 0 && Boolean(this.reconciliation);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadBooks();
|
||||
this.loadPeriods();
|
||||
},
|
||||
methods: {
|
||||
currency,
|
||||
async api(path, options = {}) {
|
||||
return apiRequest(path, this.token, options);
|
||||
},
|
||||
labelFor(p) {
|
||||
return p.period_type === 'year' ? `FY ${p.fiscal_year}` : `${MONTHS[(p.period_month || 1) - 1].slice(0, 3)} ${p.fiscal_year}`;
|
||||
},
|
||||
isDone(key) {
|
||||
return Boolean(this.period?.steps?.[key]?.done);
|
||||
},
|
||||
async loadBooks() {
|
||||
try {
|
||||
const data = await (await this.api('/api/books')).json();
|
||||
this.books = data.books;
|
||||
const primary = this.books.find((b) => b.is_primary);
|
||||
if (primary) this.form.book = primary.code;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
},
|
||||
async loadPeriods() {
|
||||
try {
|
||||
this.periods = (await (await this.api('/api/close-periods')).json()).periods;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
},
|
||||
async startClose() {
|
||||
this.starting = true;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const data = await (await this.api('/api/close-periods/start', { method: 'POST', body: JSON.stringify(this.form) })).json();
|
||||
await this.openPeriod(data.period.id);
|
||||
await this.loadPeriods();
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.starting = false;
|
||||
}
|
||||
},
|
||||
async openPeriod(id) {
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const data = await (await this.api(`/api/close-periods/${id}`)).json();
|
||||
this.period = data.period;
|
||||
this.steps = data.steps;
|
||||
this.data = data.data;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
},
|
||||
async refresh() {
|
||||
if (this.period) await this.openPeriod(this.period.id);
|
||||
},
|
||||
async act(path, body) {
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const res = await (await this.api(path, { method: 'POST', body: JSON.stringify(body || {}) })).json();
|
||||
await this.refresh();
|
||||
return res;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
return null;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async toggleStep(key) {
|
||||
await this.act(`/api/close-periods/${this.period.id}/steps/${key}`, { done: !this.isDone(key) });
|
||||
},
|
||||
async postDepreciation() {
|
||||
const res = await this.act(`/api/close-periods/${this.period.id}/post-depreciation`);
|
||||
if (res) this.message = `Posted ${currency(res.summary.debit)} of depreciation across ${res.summary.expense_lines} line(s).`;
|
||||
},
|
||||
async postDisposals() {
|
||||
const res = await this.act(`/api/close-periods/${this.period.id}/post-disposals`);
|
||||
if (res) this.message = `Posted entries for ${res.result.posted} disposal(s).`;
|
||||
},
|
||||
async runReconcile() {
|
||||
const res = await this.act(`/api/close-periods/${this.period.id}/reconcile`);
|
||||
if (res) this.message = res.reconciliation.balanced ? 'Subledger reconciles to the GL.' : `Variance of ${currency(res.reconciliation.max_variance)} — clear it or mark the step done to acknowledge.`;
|
||||
},
|
||||
async capitalize(row) {
|
||||
const date = window.prompt('In-service date (YYYY-MM-DD):', new Date().toISOString().slice(0, 10));
|
||||
if (!date) return;
|
||||
const res = await this.act(`/api/close-periods/${this.period.id}/wip/${row.id}/capitalize`, { in_service_date: date });
|
||||
if (res) this.message = `Capitalized ${row.asset_id} into service.`;
|
||||
},
|
||||
async finalize() {
|
||||
if (!window.confirm('Finalize and lock this period? Backdated changes will be blocked until it is reopened.')) return;
|
||||
const res = await this.act(`/api/close-periods/${this.period.id}/finalize`, { acknowledge_variance: true });
|
||||
if (res) {
|
||||
this.message = 'Period closed and locked.';
|
||||
await this.loadPeriods();
|
||||
}
|
||||
},
|
||||
async reopen() {
|
||||
if (!window.confirm('Reopen this closed period? Its posted close entries will be reversed.')) return;
|
||||
const res = await this.act(`/api/close-periods/${this.period.id}/reopen`);
|
||||
if (res) {
|
||||
this.message = 'Period reopened; close entries reversed.';
|
||||
await this.loadPeriods();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.close-step {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.close-step.done {
|
||||
border-color: rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
.step-num {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.step-body {
|
||||
margin-top: 10px;
|
||||
margin-left: 32px;
|
||||
}
|
||||
.ledger-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.ledger-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 160px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ledger-chip strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user