289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
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);
|
||
}
|
||
|
||
// Whole years a property was held (for the §1252 / §1255 applicable-percentage schedules).
|
||
function yearsHeld(asset, disposalDate) {
|
||
const acq = asset.acquired_date || asset.in_service_date;
|
||
if (!acq) return 0;
|
||
return Math.max(0, Math.floor((new Date(`${disposalDate}T00:00:00`) - new Date(`${acq}T00:00:00`)) / (365.25 * 86400000)));
|
||
}
|
||
|
||
// §1252 applicable percentage (IRS Pub 225): 100% if farmland is disposed of within 5 years; then reduced
|
||
// 20 points per year for years 6–10; 0% after 10 years.
|
||
function section1252Percent(years) {
|
||
if (years <= 5) return 1;
|
||
return Math.max(0, 1 - 0.2 * (years - 5));
|
||
}
|
||
|
||
// §1255 applicable percentage: 100% if held under 10 years; reduced 10 points for each year (or part) the
|
||
// property was held beyond 10 years; 0% at 20 years.
|
||
function section1255Percent(years) {
|
||
if (years < 10) return 1;
|
||
return Math.max(0, 1 - 0.1 * (years - 9));
|
||
}
|
||
|
||
// 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). Farm
|
||
// property adds §1252 (soil & water conservation, §175) and §1255 (cost-sharing, §126) ordinary recapture,
|
||
// each by an applicable percentage of the years held and limited to the remaining gain.
|
||
function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, accumulated, disposalDate) {
|
||
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 {
|
||
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);
|
||
}
|
||
}
|
||
// Farm-property recapture (§1252 / §1255), applied to the gain remaining after depreciation recapture.
|
||
const years = yearsHeld(asset, disposalDate);
|
||
let remaining = Math.max(0, gain - ordinary);
|
||
const r1252 = Math.min(remaining, money(section1252Percent(years) * Number(asset.soil_water_deductions || 0) * proportion));
|
||
remaining = Math.max(0, remaining - r1252);
|
||
const r1255 = Math.min(remaining, money(section1255Percent(years) * Number(asset.cost_sharing_excluded || 0) * proportion));
|
||
const totalOrdinary = money(ordinary + r1252 + r1255);
|
||
|
||
return {
|
||
recapture_section: section,
|
||
ordinary_recapture: money(ordinary),
|
||
unrecaptured_1250_gain: money(unrecaptured1250),
|
||
section_1252_recapture: money(r1252),
|
||
section_1255_recapture: money(r1255),
|
||
section_1231_gain: money(gainLoss - totalOrdinary) // 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), disposalDate);
|
||
|
||
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, section_1252_recapture, section_1255_recapture, 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, result.section_1252_recapture, result.section_1255_recapture,
|
||
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
|
||
};
|