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