Files
MixedAssets/server/services/disposals.js
2026-06-10 02:09:25 -05:00

256 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { all, audit, now, one, run, tx } = require('../db');
const { annualSchedule, money, section280fRecapture } = 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 };
}
// Real property (§1250) vs personal property (§1245), by recovery period: 27.5-year residential rental
// and 39-year nonresidential real property are §1250; everything else is §1245.
function isRealProperty(asset, book) {
const months = Number(book.useful_life_months || asset.useful_life_months || 0);
const pt = String(asset.property_type || '').toLowerCase();
return months >= 330 || /real|realty|1250|building/.test(pt);
}
// Characterize the gain on disposal into ordinary-income recapture vs. §1231 (capital) gain (Form 4797).
// §1245: ordinary recapture = lesser of gain or total depreciation taken (incl. §179/bonus). §1250:
// ordinary recapture = lesser of gain or "additional depreciation" (accelerated over straight-line); the
// remaining gain up to straight-line taken is unrecaptured §1250 gain (a 25% capital bucket).
function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, accumulated) {
const section = isRealProperty(asset, book) ? '1250' : '1245';
const gain = Math.max(0, gainLoss);
let ordinary = 0;
let unrecaptured1250 = 0;
if (gain > 0) {
if (section === '1245') {
ordinary = Math.min(gain, accumulated);
} else {
// Straight-line accumulated for the same period (additional depreciation = accelerated SL).
const slBook = { ...book, depreciation_method: 'straight_line', section_179_amount: 0, bonus_percent: 0 };
const slRow = annualSchedule(asset, slBook, rules, { startYear: year, endYear: year })[0] || {};
const slAccum = money(Number(slRow.accumulated || 0) * proportion);
const additional = Math.max(0, money(accumulated - slAccum));
ordinary = Math.min(gain, additional);
unrecaptured1250 = Math.min(money(gain - ordinary), slAccum);
}
}
return {
recapture_section: section,
ordinary_recapture: money(ordinary),
unrecaptured_1250_gain: money(unrecaptured1250),
section_1231_gain: money(gainLoss - ordinary) // negative for a §1231 loss
};
}
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);
// Recapture characterization is computed on the disposed book's depreciation (use a tax book — FEDERAL —
// for tax recapture). "Depreciation taken" includes the impairment write-down already reflected in NBV.
const recapture = characterizeRecapture(asset, book, rules, year, proportion, gainLoss, money(accumulated + impairment));
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,
...recapture
};
}
function previewDisposal(assetId, input) {
const asset = hydrateAsset(assetId);
if (!asset) return null;
return computeDisposal(asset, input);
}
// §280F recapture preview for a listed/passenger asset converting to ≤50% business use in a given year.
function previewSection280fRecapture(assetId, input = {}) {
const asset = hydrateAsset(assetId);
if (!asset) return null;
const bookType = input.book || 'FEDERAL';
const book = bookFor(asset, bookType);
const year = Number(input.year) || new Date().getFullYear();
return section280fRecapture(asset, book, ruleSetForBook(bookType), year);
}
function recordDisposal(assetId, input, userId) {
require('./midQuarter').clearCache();
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, recapture_section, ordinary_recapture, section_1231_gain,
unrecaptured_1250_gain, 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, result.recapture_section, result.ordinary_recapture, result.section_1231_gain,
result.unrecaptured_1250_gain, 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) {
require('./midQuarter').clearCache();
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,
previewSection280fRecapture,
recordAdjustment,
recordDisposal,
reverseDisposal
};