198 lines
7.6 KiB
JavaScript
198 lines
7.6 KiB
JavaScript
const { all, audit, now, one, run, tx } = require('../db');
|
|
const { annualSchedule, money } = require('../depreciation');
|
|
const { ruleSetForBook } = require('./ruleSets');
|
|
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();
|
|
}
|
|
|
|
function netAdjustments(assetId, bookType) {
|
|
// Impairments and write-downs reduce carrying value; write-ups/revaluations restore it.
|
|
const rows = all('SELECT type, amount FROM asset_adjustments WHERE asset_id = ? AND book_type = ?', [assetId, bookType]);
|
|
return rows.reduce((sum, row) => {
|
|
const sign = row.type === 'write_up' || row.type === 'revaluation' ? -1 : 1;
|
|
return sum + sign * Number(row.amount || 0);
|
|
}, 0);
|
|
}
|
|
|
|
function bookFor(asset, bookType) {
|
|
return asset.books.find((book) => book.book_type === bookType) || asset.books[0] || { book_type: bookType };
|
|
}
|
|
|
|
function computeDisposal(asset, input = {}) {
|
|
const bookType = input.book_type || 'GAAP';
|
|
const book = bookFor(asset, bookType);
|
|
const rules = ruleSetForBook(bookType);
|
|
const disposalDate = input.disposal_date || new Date().toISOString().slice(0, 10);
|
|
const year = yearOf(disposalDate);
|
|
|
|
const totalCost = Number(book.cost ?? asset.acquisition_cost ?? 0);
|
|
const partial = Boolean(input.partial);
|
|
const disposedCost = partial ? Math.min(totalCost, Number(input.disposed_cost || 0)) : totalCost;
|
|
const proportion = totalCost > 0 ? disposedCost / totalCost : 1;
|
|
|
|
const row = annualSchedule(asset, book, rules, { startYear: year, endYear: year })[0] || {};
|
|
const accumulatedFull = Number(row.accumulated || 0);
|
|
const nbvFull = row.netBookValue != null ? Number(row.netBookValue) : Math.max(0, totalCost - accumulatedFull);
|
|
|
|
const accumulated = money(accumulatedFull * proportion);
|
|
const impairment = money(netAdjustments(asset.id, bookType) * proportion);
|
|
const netBookValue = money(Math.max(0, nbvFull * proportion - impairment));
|
|
|
|
const price = Number(input.disposal_price || 0);
|
|
const expense = Number(input.disposal_expense || 0);
|
|
const realized = money(price - expense);
|
|
const gainLoss = money(realized - netBookValue);
|
|
|
|
return {
|
|
book_type: bookType,
|
|
disposal_date: disposalDate,
|
|
method: DISPOSAL_METHODS.includes(input.method) ? input.method : 'sale',
|
|
property_type: input.property_type || asset.property_type || null,
|
|
partial: partial ? 1 : 0,
|
|
disposed_cost: money(disposedCost),
|
|
total_cost: money(totalCost),
|
|
disposal_price: money(price),
|
|
disposal_expense: money(expense),
|
|
accumulated_depreciation: accumulated,
|
|
impairment,
|
|
net_book_value: netBookValue,
|
|
realized,
|
|
gain_loss: gainLoss
|
|
};
|
|
}
|
|
|
|
function previewDisposal(assetId, input) {
|
|
const asset = hydrateAsset(assetId);
|
|
if (!asset) return null;
|
|
return computeDisposal(asset, input);
|
|
}
|
|
|
|
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(() => {
|
|
const inserted = run(
|
|
`INSERT INTO disposals (
|
|
asset_id, disposal_date, method, property_type, partial, disposed_cost,
|
|
disposal_price, disposal_expense, accumulated_depreciation, net_book_value,
|
|
gain_loss, book_type, notes, created_by, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
assetId, result.disposal_date, result.method, result.property_type, result.partial,
|
|
result.disposed_cost, result.disposal_price, result.disposal_expense,
|
|
result.accumulated_depreciation, result.net_book_value, result.gain_loss,
|
|
result.book_type, input.notes || null, userId, timestamp
|
|
]
|
|
);
|
|
|
|
if (result.partial) {
|
|
const totalCost = result.total_cost;
|
|
const remainingProportion = totalCost > 0 ? Math.max(0, (totalCost - result.disposed_cost) / totalCost) : 1;
|
|
run('UPDATE assets SET acquisition_cost = acquisition_cost - ?, updated_at = ? WHERE id = ?', [
|
|
result.disposed_cost,
|
|
timestamp,
|
|
assetId
|
|
]);
|
|
run('UPDATE asset_books SET cost = cost * ? WHERE asset_id = ? AND cost IS NOT NULL', [remainingProportion, assetId]);
|
|
} else {
|
|
run(
|
|
`UPDATE assets SET
|
|
status = 'disposed', disposal_date = ?, disposal_price = ?, disposal_expense = ?,
|
|
disposal_type = ?, property_type = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[result.disposal_date, result.disposal_price, result.disposal_expense, result.method, result.property_type, timestamp, assetId]
|
|
);
|
|
}
|
|
|
|
const disposal = one('SELECT * FROM disposals WHERE id = ?', [inserted.lastInsertRowid]);
|
|
audit(userId, 'dispose', 'asset', assetId, null, disposal);
|
|
return { disposal, asset: hydrateAsset(assetId) };
|
|
});
|
|
}
|
|
|
|
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(() => {
|
|
run('DELETE FROM disposals WHERE id = ?', [disposalId]);
|
|
if (disposal.partial) {
|
|
run('UPDATE assets SET acquisition_cost = acquisition_cost + ?, updated_at = ? WHERE id = ?', [
|
|
disposal.disposed_cost,
|
|
timestamp,
|
|
assetId
|
|
]);
|
|
} else if (!one('SELECT id FROM disposals WHERE asset_id = ? AND partial = 0', [assetId])) {
|
|
run(
|
|
`UPDATE assets SET
|
|
status = 'in_service', disposal_date = NULL, disposal_price = NULL,
|
|
disposal_expense = NULL, disposal_type = NULL, updated_at = ?
|
|
WHERE id = ?`,
|
|
[timestamp, assetId]
|
|
);
|
|
}
|
|
audit(userId, 'reverse_disposal', 'asset', assetId, disposal, null);
|
|
return hydrateAsset(assetId);
|
|
});
|
|
}
|
|
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
assetId,
|
|
input.adjustment_date || timestamp.slice(0, 10),
|
|
input.type || 'impairment',
|
|
Number(input.amount || 0),
|
|
input.book_type || 'GAAP',
|
|
input.reason || null,
|
|
userId,
|
|
timestamp
|
|
]
|
|
);
|
|
const adjustment = one('SELECT * FROM asset_adjustments WHERE id = ?', [result.lastInsertRowid]);
|
|
audit(userId, 'adjust', 'asset', assetId, null, adjustment);
|
|
return adjustment;
|
|
}
|
|
|
|
function deleteAdjustment(assetId, adjustmentId, userId) {
|
|
const result = run('DELETE FROM asset_adjustments WHERE id = ? AND asset_id = ?', [adjustmentId, assetId]);
|
|
if (!result.changes) return false;
|
|
audit(userId, 'delete', 'asset_adjustment', adjustmentId, null, { asset_id: assetId });
|
|
return true;
|
|
}
|
|
|
|
module.exports = {
|
|
DISPOSAL_METHODS,
|
|
computeDisposal,
|
|
deleteAdjustment,
|
|
netAdjustments,
|
|
previewDisposal,
|
|
recordAdjustment,
|
|
recordDisposal,
|
|
reverseDisposal
|
|
};
|