Pub 225 Compliance

This commit is contained in:
2026-06-10 15:23:02 -05:00
parent 221ccb7826
commit 5074a3ad63
16 changed files with 434 additions and 24 deletions

View File

@@ -7,6 +7,12 @@
// IRS Table 1 — Alphabetical Listing of Commonly Used Assets.
const COMMON = [
{ description: 'Adding Machines', class_number: '00.13', gds: 5, ads: 6 },
// Farm land improvements & orchards/groves (IRS Pub 225 / Pub 946 farm property).
{ description: 'Water Wells (farm land improvement)', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Irrigation Systems (farm land improvement)', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Drainage Facilities (farm land improvement)', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Paved Lots / Farm Roads (land improvement)', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Orchards, Groves & Vineyards (fruit/nut trees & vines)', class_number: '00.0', gds: 10, ads: 20 },
{ description: 'Agricultural Machinery and Equipment', class_number: '01.1', gds: 7, ads: 10 },
{ description: 'Airplanes, Commercial', class_number: '45.0', gds: 7, ads: 12 },
{ description: 'Airplanes, Noncommercial', class_number: '00.21', gds: 5, ads: 6 },

View File

@@ -371,6 +371,18 @@ function initialize() {
updated_at TEXT NOT NULL
);
-- Short tax years (less than 12 months) prorate that year's MACRS depreciation, per company.
CREATE TABLE IF NOT EXISTS short_tax_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER REFERENCES entities(id),
tax_year INTEGER NOT NULL,
months REAL NOT NULL DEFAULT 12,
note TEXT,
updated_by INTEGER REFERENCES users(id),
updated_at TEXT NOT NULL,
UNIQUE(entity_id, tax_year)
);
-- §179 taxable-income limitation: per company+book+year entered income and computed carryover.
CREATE TABLE IF NOT EXISTS section_179_carryover (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -922,6 +934,12 @@ function migrate() {
ensureColumn('disposals', 'ordinary_recapture', 'REAL NOT NULL DEFAULT 0');
ensureColumn('disposals', 'section_1231_gain', 'REAL NOT NULL DEFAULT 0');
ensureColumn('disposals', 'unrecaptured_1250_gain', 'REAL NOT NULL DEFAULT 0');
// Farm-property recapture inputs (IRS Pub 225): §175 soil/water conservation deductions (§1252) and
// §126 excluded cost-sharing payments (§1255), with the computed recapture stored on disposals.
ensureColumn('assets', 'soil_water_deductions', 'REAL NOT NULL DEFAULT 0');
ensureColumn('assets', 'cost_sharing_excluded', 'REAL NOT NULL DEFAULT 0');
ensureColumn('disposals', 'section_1252_recapture', 'REAL NOT NULL DEFAULT 0');
ensureColumn('disposals', 'section_1255_recapture', 'REAL NOT NULL DEFAULT 0');
// Refresh stale §179 limits seeded before the 2025 OBBBA increase (only touch the old seed values,
// never a figure an administrator has deliberately raised). 2026 per IRS Pub 946 (2025 ed.).
run('UPDATE section_179_limits SET base_limit = 2500000, phaseout_threshold = 4000000 WHERE tax_year = 2025 AND base_limit < 2000000');

View File

@@ -6,7 +6,7 @@
salvage value, and conventions is included.
*/
const { parseJson } = require('./db');
const { one, parseJson } = require('./db');
const { buildDepreciationMethods } = require('./rule-catalog');
const { getZone } = require('./services/zones');
const { getLimit: getSection179Limit } = require('./services/section179Limits');
@@ -233,6 +233,17 @@ function listedAdsRequired(asset, book, methodRule) {
return use > 0 && use <= 0.5;
}
// ADS recovery period (in months) for an asset, from its IRS asset class when available; otherwise the
// asset's configured life. Used when ADS is forced (listed property ≤50% business use).
function adsRecoveryMonths(asset, book) {
const classNumber = asset.asset_class_number;
if (classNumber) {
const row = one('SELECT ads FROM asset_classes WHERE class_number = ? AND ads IS NOT NULL AND ads > 0 ORDER BY id LIMIT 1', [String(classNumber)]);
if (row && Number(row.ads) > 0) return Math.round(Number(row.ads) * 12);
}
return Number(book.useful_life_months || asset.useful_life_months || 60);
}
// §280F passenger-automobile annual depreciation cap for a given recovery year (0-based index), reduced
// by business-use percentage. Returns Infinity when the asset isn't a flagged passenger auto or no cap
// table is configured for the placed-in-service year (leaving depreciation uncapped).
@@ -430,6 +441,18 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule
}
// Rate table depreciation: (basis * rate). The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods. The rate is determined by the method rule's rates array based on the year index, with an optional fallback to straight-line when no rates are provided.
// MACRS optional percentage tables (Pub 946 Appendix A). Applies the published half-year percentage to
// the depreciable basis when a literal table exists for the recovery period; otherwise (mid-quarter or an
// unsupported period) falls back to the equivalent declining-balance formula.
function macrsTable(asset, book, index, methodRule, convention) {
const years = Math.max(1, Math.round((methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60) / 12));
const table = convention === 'half_year' ? require('./macrs-tables').gdsHalfYear(years) : null;
if (!table) {
return decliningBalance(asset, book, index, years, Number(methodRule.rateMultiplier || 2), methodRule);
}
return depreciableBasis(asset, book, methodRule) * (Number(table[index] || 0) / 100);
}
function rateTable(asset, book, index, methodRule) {
console.log(`${moment().format()} 🕒 Calculating rate table depreciation for asset ${asset.id}, index ${index}`);
const basis = depreciableBasis(asset, book, methodRule);
@@ -461,8 +484,9 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
// or bonus allowance (the ADS recovery period uses the asset's configured life). `ignoreListedAds`
// computes the as-if-accelerated path used by the §280F recapture preview.
if (!options.ignoreListedAds && listedAdsRequired(asset, book, methodRule)) {
book = { ...book, section_179_amount: 0, bonus_percent: 0 };
methodRule = { ...methodRule, formula: 'straight_line', label: `${methodRule.label || methodRule.code} (ADS — listed ≤50% use)` };
const adsMonths = adsRecoveryMonths(asset, book);
book = { ...book, section_179_amount: 0, bonus_percent: 0, useful_life_months: adsMonths };
methodRule = { ...methodRule, formula: 'straight_line', lifeMonths: adsMonths, label: `${methodRule.label || methodRule.code} (ADS — listed ≤50% use)` };
}
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
if (!(Number(book.bonus_percent) > 0)) {
@@ -493,7 +517,10 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
}
console.log(`${moment().format()} 🕒 Evaluating method rule for asset ${asset.id}, year ${year}`);
if (methodRule.formula === 'rate_table') {
if (methodRule.formula === 'macrs_table') {
// MACRS optional percentage tables (literal Pub 946 Appendix A) with formula fallback.
depreciation += macrsTable(asset, book, index, methodRule, conventionFor(book, methodRule, asset));
} else if (methodRule.formula === 'rate_table') {
// Note: for the rate table formula, we look up the rate for the current year index and apply it to the depreciable basis; if no rates are defined but a fallback to straight-line is specified, we use the straight-line formula instead with the total years determined by the method rule's lifeMonths or the book's useful_life_months. This allows for flexible handling of cases where a rate table is desired but specific rates haven't been configured, while still providing a reasonable default calculation.
console.log(`${moment().format()} 🕒 Using rate table formula for asset ${asset.id}, year ${year}`);
depreciation += rateTable(asset, book, index, methodRule);
@@ -529,6 +556,12 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
if (allowed <= 0 && remaining > 0) allowed = Math.min(cap280f, remaining);
depreciation = allowed;
}
// Short tax year: prorate the year's MACRS depreciation by (months ÷ 12) for the company (simplified
// method; default fraction 1 leaves normal years unchanged). Lazy-required to avoid a load cycle.
if (index >= 0 && asset.entity_id) {
const f = require('./services/shortTaxYear').fraction(asset.entity_id, year);
if (f !== 1) depreciation *= f;
}
const cap = recoverableBasis(asset, book, methodRule);
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated)));
accumulated += depreciation;

21
server/macrs-tables.js Normal file
View File

@@ -0,0 +1,21 @@
// IRS Publication 946, Appendix A — GDS optional depreciation tables (percentage of unadjusted basis
// deducted each recovery year), half-year convention. 3/5/7/10-year property use 200% declining balance;
// 15/20-year property use 150% declining balance. These are the published, rounded percentages used for
// the "MACRS Tables" method (MT200); the formula method reproduces them mathematically. Mid-quarter and
// other conventions fall back to the formula in the engine.
const GDS_HALF_YEAR = {
3: [33.33, 44.45, 14.81, 7.41],
5: [20.00, 32.00, 19.20, 11.52, 11.52, 5.76],
7: [14.29, 24.49, 17.49, 12.49, 8.93, 8.92, 8.93, 4.46],
10: [10.00, 18.00, 14.40, 11.52, 9.22, 7.37, 6.55, 6.55, 6.56, 6.55, 3.28],
15: [5.00, 9.50, 8.55, 7.70, 6.93, 6.23, 5.90, 5.90, 5.91, 5.90, 5.91, 5.90, 5.91, 5.90, 5.91, 2.95],
20: [3.750, 7.219, 6.677, 6.177, 5.713, 5.285, 4.888, 4.522, 4.462, 4.461, 4.462, 4.461, 4.462, 4.461, 4.462, 4.461, 4.462, 4.461, 4.462, 4.461, 2.231]
};
// Returns the published half-year percentage array for a recovery period (years), or null if unsupported.
function gdsHalfYear(years) {
return GDS_HALF_YEAR[years] || null;
}
module.exports = { gdsHalfYear };

View File

@@ -3,6 +3,7 @@ const { requireCapability } = require('../middleware/auth');
const {
deleteAdjustment,
previewDisposal,
previewSection280fRecapture,
recordAdjustment,
recordDisposal,
reverseDisposal
@@ -16,6 +17,13 @@ router.post('/assets/:id/disposal/preview', (req, res) => {
return res.json({ preview });
});
// §280F recapture preview if this listed/passenger asset's business use drops to ≤50% in the given year.
router.get('/assets/:id/section-280f-recapture', (req, res) => {
const result = previewSection280fRecapture(req.params.id, { book: req.query.book, year: req.query.year });
if (!result) return res.status(404).json({ error: 'Asset not found' });
return res.json({ recapture: result });
});
router.post('/assets/:id/disposals', requireCapability('finance.manage'), (req, res) => {
const result = recordDisposal(req.params.id, req.body, req.user.id);
if (!result) return res.status(404).json({ error: 'Asset not found' });

View File

@@ -7,6 +7,7 @@ const assetClasses = require('../services/assetClasses');
const zones = require('../services/zones');
const section179Limits = require('../services/section179Limits');
const section280fLimits = require('../services/section280fLimits');
const shortTaxYear = require('../services/shortTaxYear');
const router = express.Router();
@@ -96,6 +97,24 @@ router.delete('/section-280f-limits/:year', requireCapability('config.manage'),
return res.status(204).end();
});
// Short tax years (per company) — prorate that year's MACRS depreciation.
router.get('/short-tax-years', (req, res) => {
res.json({ years: shortTaxYear.list(req.companyId) });
});
router.post('/short-tax-years', requireCapability('config.manage'), (req, res, next) => {
try {
res.status(201).json({ year: shortTaxYear.save(req.companyId, req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.delete('/short-tax-years/:year', requireCapability('config.manage'), (req, res) => {
if (!shortTaxYear.remove(req.companyId, req.params.year, req.user.id)) return res.status(404).json({ error: 'Not found' });
return res.status(204).end();
});
// Asset class catalog (seeded from the IRS tables, then user-managed). GET is reference
// data used by the category picker; mutations require config.manage.
router.get('/asset-classes', (req, res) => {

View File

@@ -146,7 +146,7 @@ function build200Methods() {
method('MF200', 'MACRS', 'MF200 — MACRS Formula, 200% DB (GDS)', 'macrs_declining_balance', {
system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true
}),
method('MT200', 'MACRS', 'MT200 — MACRS Tables, 200% DB (GDS)', 'macrs_declining_balance', {
method('MT200', 'MACRS', 'MT200 — MACRS Tables, 200% DB (GDS)', 'macrs_table', {
system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true, usesTables: true
}),
method('MI200', 'MACRS', 'MI200 — MACRS 200% DB, Indian Reservation', 'macrs_declining_balance', {

View File

@@ -32,6 +32,8 @@ const assetColumns = [
'passenger_auto',
'business_use_percent',
'investment_use_percent',
'soil_water_deductions',
'cost_sharing_excluded',
'disposal_date',
'disposal_price',
'disposal_expense',
@@ -64,6 +66,8 @@ function assetFromRow(row) {
...row,
listed_property: Boolean(row.listed_property),
passenger_auto: Boolean(row.passenger_auto),
soil_water_deductions: Number(row.soil_water_deductions || 0),
cost_sharing_excluded: Number(row.cost_sharing_excluded || 0),
custom_fields: parseJson(row.custom_fields, {}),
books: row.books ? parseJson(row.books, []) : undefined
};
@@ -213,6 +217,8 @@ function normalizeAssetInput(body) {
input.prior_depreciation = Number(input.prior_depreciation || 0);
input.business_use_percent = Number(input.business_use_percent || 100);
input.investment_use_percent = Number(input.investment_use_percent || 0);
input.soil_water_deductions = Number(input.soil_water_deductions || 0);
input.cost_sharing_excluded = Number(input.cost_sharing_excluded || 0);
input.custom_fields = json(input.custom_fields || {});
input.listed_property = input.listed_property ? 1 : 0;
input.passenger_auto = input.passenger_auto ? 1 : 0;

View File

@@ -38,11 +38,34 @@ function isRealProperty(asset, book) {
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 610; 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).
function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, accumulated) {
// 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;
@@ -51,7 +74,6 @@ function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, a
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);
@@ -60,11 +82,21 @@ function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, a
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_1231_gain: money(gainLoss - ordinary) // negative for a §1231 loss
section_1252_recapture: money(r1252),
section_1255_recapture: money(r1255),
section_1231_gain: money(gainLoss - totalOrdinary) // negative for a §1231 loss
};
}
@@ -95,7 +127,7 @@ function computeDisposal(asset, input = {}) {
// 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));
const recapture = characterizeRecapture(asset, book, rules, year, proportion, gainLoss, money(accumulated + impairment), disposalDate);
return {
book_type: bookType,
@@ -146,14 +178,15 @@ function recordDisposal(assetId, input, userId) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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, input.notes || null, userId, timestamp
result.unrecaptured_1250_gain, result.section_1252_recapture, result.section_1255_recapture,
input.notes || null, userId, timestamp
]
);

View File

@@ -296,16 +296,22 @@ function disposals(options) {
disposal_date: row.disposal_date,
proceeds: money(row.disposal_price - row.disposal_expense),
net_book_value: money(row.net_book_value),
gain_loss: money(row.gain_loss)
gain_loss: money(row.gain_loss),
recapture_section: row.recapture_section ? `§${row.recapture_section}` : '',
ordinary_recapture: money(row.ordinary_recapture || 0),
farm_recapture: money((row.section_1252_recapture || 0) + (row.section_1255_recapture || 0)),
section_1231_gain: money(row.section_1231_gain || 0)
}));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Type'),
col('disposal_date', 'Date', 'date'), col('proceeds', 'Net proceeds', 'currency'),
col('net_book_value', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')
col('net_book_value', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency'),
col('recapture_section', 'Recapture'), col('ordinary_recapture', 'Ordinary recapture', 'currency'),
col('farm_recapture', '§1252/§1255 (farm)', 'currency'), col('section_1231_gain', '§1231 gain / (loss)', 'currency')
],
rows,
totals: sumBy(rows, ['proceeds', 'net_book_value', 'gain_loss'])
totals: sumBy(rows, ['proceeds', 'net_book_value', 'gain_loss', 'ordinary_recapture', 'farm_recapture', 'section_1231_gain'])
};
}

View File

@@ -0,0 +1,55 @@
const { all, audit, now, one, run } = require('../db');
// Short tax years (IRS Pub 946): when a company's tax year is shorter than 12 months, that year's MACRS
// depreciation is prorated. This is the simplified proration — the year's deduction is multiplied by
// (months ÷ 12); it is most accurate for the placed-in-service (first) short year. Per company + year,
// cached for the engine and invalidated on edits.
const cache = new Map(); // `${entityId}|${year}` -> fraction
function clearCache() {
cache.clear();
}
// Proration fraction (01) for a company's tax year; 1 when the year is a normal 12-month year.
function fraction(entityId, year) {
if (!entityId || !year) return 1;
const key = `${entityId}|${Number(year)}`;
if (cache.has(key)) return cache.get(key);
const row = one('SELECT months FROM short_tax_years WHERE entity_id = ? AND tax_year = ?', [entityId, Number(year)]);
const months = row ? Math.max(0, Math.min(12, Number(row.months) || 12)) : 12;
const f = months / 12;
cache.set(key, f);
return f;
}
function list(companyId) {
return all('SELECT * FROM short_tax_years WHERE entity_id = ? ORDER BY tax_year DESC', [companyId]);
}
function save(companyId, body, userId) {
const taxYear = Math.trunc(Number(body.tax_year)) || 0;
if (!taxYear) throw Object.assign(new Error('A valid tax year is required'), { status: 400 });
const months = Math.max(1, Math.min(12, Number(body.months) || 12));
run(
`INSERT INTO short_tax_years (entity_id, tax_year, months, note, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(entity_id, tax_year) DO UPDATE SET months = excluded.months, note = excluded.note,
updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
[companyId, taxYear, months, body.note || null, userId, now()]
);
clearCache();
audit(userId, 'update', 'short_tax_year', `${companyId}:${taxYear}`, null, { tax_year: taxYear, months });
return one('SELECT * FROM short_tax_years WHERE entity_id = ? AND tax_year = ?', [companyId, taxYear]);
}
function remove(companyId, taxYear, userId) {
const before = one('SELECT * FROM short_tax_years WHERE entity_id = ? AND tax_year = ?', [companyId, Number(taxYear)]);
if (!before) return false;
run('DELETE FROM short_tax_years WHERE entity_id = ? AND tax_year = ?', [companyId, Number(taxYear)]);
clearCache();
audit(userId, 'delete', 'short_tax_year', `${companyId}:${taxYear}`, before, null);
return true;
}
module.exports = { clearCache, fraction, list, remove, save };

View File

@@ -2030,6 +2030,52 @@ async function main() {
const lim2032 = (await request('/api/section-179/limitation?book=FEDERAL&year=2032', { headers: aHeaders })).summary;
if (lim2032.prior_carryover !== 2000000) throw new Error(`§179 carryover did not chain to next year: ${lim2032.prior_carryover}`);
// ---- Depreciation recapture: §1245 on disposal + §280F conversion (Form 4797) ---
// Fully-depreciated equipment (cost 10k, placed 2018) sold for 12k in 2026 → gain 12k: §1245 ordinary
// recapture = depreciation taken (10k), the remaining 2k is §1231 capital gain.
const eq = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `EQ-${suffix}`, description: 'Equipment', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2018-01-01', in_service_date: '2018-01-01', useful_life_months: 60, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) })).asset;
const recapDisp = (await request(`/api/assets/${eq.id}/disposal/preview`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ book_type: 'FEDERAL', disposal_date: '2026-08-01', disposal_price: 12000 }) })).preview;
if (recapDisp.recapture_section !== '1245') throw new Error(`Recapture section wrong: ${disp.recapture_section}`);
if (recapDisp.ordinary_recapture !== 10000) throw new Error(`§1245 ordinary recapture wrong: expected 10,000, got ${recapDisp.ordinary_recapture}`);
if (recapDisp.section_1231_gain !== 2000) throw new Error(`§1231 gain wrong: expected 2,000, got ${recapDisp.section_1231_gain}`);
// §280F conversion recapture: listed PC at 100% use (2024) converting to ≤50% in 2026 → excess of
// accelerated-over-ADS through 2025 = 5,200 3,000 = 2,200 ordinary income.
const pc = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `PC-${suffix}`, description: 'Listed PC', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-01-01', in_service_date: '2024-01-01', useful_life_months: 60, business_use_percent: 100, listed_property: true, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) })).asset;
const rec280 = (await request(`/api/assets/${pc.id}/section-280f-recapture?book=FEDERAL&year=2026`, { headers: aHeaders })).recapture;
if (!rec280.applies || rec280.recapture !== 2200) throw new Error(`§280F recapture wrong: ${JSON.stringify(rec280)}`);
// Farm-property recapture (IRS Pub 225): §1252 soil/water (§175) + §1255 cost-sharing (§126). Farmland
// held 7 years with $20k §175 and $10k §126, sold at a $50k gain → §1252 = 60% × 20k = 12k; §1255 = 10k.
const farm = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `FARM-${suffix}`, description: 'Farmland', category: 'Computer Equipment', acquisition_cost: 100000, land_value: 100000, in_service_date: '2019-06-01', acquired_date: '2019-06-01', useful_life_months: 0, soil_water_deductions: 20000, cost_sharing_excluded: 10000, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', useful_life_months: 0, cost: 100000 }] }) })).asset;
const farmDisp = (await request(`/api/assets/${farm.id}/disposal/preview`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ book_type: 'FEDERAL', disposal_date: '2026-07-01', disposal_price: 150000 }) })).preview;
if (farmDisp.section_1252_recapture !== 12000) throw new Error(`§1252 recapture wrong: expected 12,000, got ${farmDisp.section_1252_recapture}`);
if (farmDisp.section_1255_recapture !== 10000) throw new Error(`§1255 recapture wrong: expected 10,000, got ${farmDisp.section_1255_recapture}`);
if (farmDisp.section_1231_gain !== 28000) throw new Error(`Farm §1231 gain wrong: expected 28,000, got ${farmDisp.section_1231_gain}`);
// ---- Literal MACRS tables (MT200), true ADS recovery period, short tax year ---
const deprRow = async (assetId, year) => (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year } }) })).report.rows.find((r) => r.asset_id === assetId);
// #1 MT200 uses the literal Pub 946 Appendix-A percentages (5-year property year-1 = 20% = 2,000).
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MT-${suffix}`, description: 'MT200', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-05-01', in_service_date: '2024-05-01', useful_life_months: 60, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MT200', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) });
const mtRow = await deprRow(`MT-${suffix}`, 2024);
if (!mtRow || Math.abs(mtRow.depreciation - 2000) > 1) throw new Error(`MT200 literal table year-1 wrong: expected 2,000, got ${mtRow && mtRow.depreciation}`);
// #3 Listed property ≤50% business use uses the ADS recovery period from its asset class (GDS 7 / ADS 10):
// basis 10k×40% = 4k, ADS 10-year straight-line, half-year → 200 (not the 7-year GDS life).
await request('/api/asset-classes', { method: 'POST', headers: auth, body: JSON.stringify({ class_number: `88.${suffix}`, description: 'ADS test', gds: 7, ads: 10 }) });
await request('/api/asset-categories', { method: 'POST', headers: aHeaders, body: JSON.stringify({ name: `ADSCat-${suffix}`, asset_class_number: `88.${suffix}` }) });
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `ADS-${suffix}`, description: 'ADS', category: `ADSCat-${suffix}`, acquisition_cost: 10000, acquired_date: '2024-05-01', in_service_date: '2024-05-01', useful_life_months: 84, business_use_percent: 40, listed_property: true, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_7', convention: 'half_year', useful_life_months: 84, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) });
const adsRow = await deprRow(`ADS-${suffix}`, 2024);
if (!adsRow || Math.abs(adsRow.depreciation - 200) > 1) throw new Error(`ADS recovery period not applied: expected 200 (10-yr ADS), got ${adsRow && adsRow.depreciation}`);
// #2 Short tax year prorates that year's MACRS depreciation by months ÷ 12 (2,000 → 1,000 for 6 months).
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `STY-${suffix}`, description: 'STY', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2033-01-01', in_service_date: '2033-01-01', useful_life_months: 60, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) });
const styBaseline = await deprRow(`STY-${suffix}`, 2033);
if (!styBaseline || Math.abs(styBaseline.depreciation - 2000) > 1) throw new Error(`Short-year baseline wrong: ${styBaseline && styBaseline.depreciation}`);
await request('/api/short-tax-years', { method: 'POST', headers: aHeaders, body: JSON.stringify({ tax_year: 2033, months: 6 }) });
const styShort = await deprRow(`STY-${suffix}`, 2033);
if (!styShort || Math.abs(styShort.depreciation - 1000) > 1) throw new Error(`Short tax year not prorated: expected 1,000, got ${styShort && styShort.depreciation}`);
console.log('Smoke test passed');
}