Files
MixedAssets/server/services/disposals.js

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
};