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

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