Pub 946 Compliance

This commit is contained in:
2026-06-10 02:09:25 -05:00
parent 7a54d1a386
commit 221ccb7826
18 changed files with 863 additions and 19 deletions

View File

@@ -29,6 +29,7 @@ const assetColumns = [
'condition',
'new_or_used',
'listed_property',
'passenger_auto',
'business_use_percent',
'investment_use_percent',
'disposal_date',
@@ -62,6 +63,7 @@ function assetFromRow(row) {
return {
...row,
listed_property: Boolean(row.listed_property),
passenger_auto: Boolean(row.passenger_auto),
custom_fields: parseJson(row.custom_fields, {}),
books: row.books ? parseJson(row.books, []) : undefined
};
@@ -213,6 +215,7 @@ function normalizeAssetInput(body) {
input.investment_use_percent = Number(input.investment_use_percent || 0);
input.custom_fields = json(input.custom_fields || {});
input.listed_property = input.listed_property ? 1 : 0;
input.passenger_auto = input.passenger_auto ? 1 : 0;
return input;
}
@@ -267,6 +270,7 @@ function listAssets(query = {}) {
}
function createAsset(body, userId, companyId) {
require('./midQuarter').clearCache(); // asset set changed → recompute the mid-quarter determination
const created = now();
const input = normalizeAssetInput(body);
if (companyId) input.entity_id = companyId; // new assets belong to the active company
@@ -289,6 +293,7 @@ function createAsset(body, userId, companyId) {
}
function updateAsset(id, body, userId, companyId) {
require('./midQuarter').clearCache();
const before = hydrateAsset(id);
if (!before) return null;
if (companyId && before.entity_id !== companyId) return null; // belongs to another company
@@ -323,6 +328,7 @@ function updateAsset(id, body, userId, companyId) {
}
function deleteAsset(id, userId, companyId) {
require('./midQuarter').clearCache();
const before = hydrateAsset(id);
if (!before) return false;
if (companyId && before.entity_id !== companyId) return false; // belongs to another company
@@ -332,6 +338,7 @@ function deleteAsset(id, userId, companyId) {
}
function massUpdateAssets(ids, changes, userId, companyId) {
require('./midQuarter').clearCache();
const allowed = ['status', 'location', 'department', 'group_name', 'in_service_date', 'useful_life_months', 'condition', 'disposal_date'];
const columns = allowed.filter((column) => Object.prototype.hasOwnProperty.call(changes, column));
// Restrict to assets in the active company so a forged id list can't touch another company's data.

View File

@@ -363,6 +363,7 @@ function buildLine(o) {
// Capitalize a Construction-in-progress asset: place it in service and post Dr asset / Cr CWIP.
function capitalizeWip(assetId, body, userId, companyId) {
require('./midQuarter').clearCache(); // status cip→in_service changes the placed-in-service set
const glEntries = require('./glEntries');
const { getDefaults } = require('./glAccounts');
const asset = one('SELECT * FROM assets WHERE id = ? AND entity_id = ?', [assetId, companyId]);

View File

@@ -1,5 +1,5 @@
const { all, audit, now, one, run, tx } = require('../db');
const { annualSchedule, money } = require('../depreciation');
const { annualSchedule, money, section280fRecapture } = require('../depreciation');
const { ruleSetForBook } = require('./ruleSets');
const { hydrateAsset } = require('./assets');
@@ -30,6 +30,44 @@ 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);
@@ -55,6 +93,10 @@ function computeDisposal(asset, input = {}) {
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,
@@ -69,7 +111,8 @@ function computeDisposal(asset, input = {}) {
impairment,
net_book_value: netBookValue,
realized,
gain_loss: gainLoss
gain_loss: gainLoss,
...recapture
};
}
@@ -79,7 +122,18 @@ function previewDisposal(assetId, input) {
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);
@@ -91,13 +145,15 @@ function recordDisposal(assetId, input, userId) {
`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, notes, created_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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, input.notes || null, userId, timestamp
result.book_type, result.recapture_section, result.ordinary_recapture, result.section_1231_gain,
result.unrecaptured_1250_gain, input.notes || null, userId, timestamp
]
);
@@ -127,6 +183,7 @@ function recordDisposal(assetId, input, userId) {
}
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);
@@ -191,6 +248,7 @@ module.exports = {
deleteAdjustment,
netAdjustments,
previewDisposal,
previewSection280fRecapture,
recordAdjustment,
recordDisposal,
reverseDisposal

View File

@@ -0,0 +1,68 @@
const { all } = require('../db');
// IRS Pub 946 mid-quarter convention determination. The mid-quarter convention is MANDATORY for all of a
// tax year's MACRS personal property when more than 40% of the aggregate depreciable basis of such
// property is placed in service during the last three months (Q4) of the year. This is a portfolio- and
// year-level fact, computed here once per (company, book, year) and consulted by the depreciation engine
// (depreciation.conventionFor). Real property (mid-month) is excluded. Cached; invalidated on asset writes.
const cache = new Map(); // `${entityId}|${bookCode}|${year}` -> boolean
function clearCache() {
cache.clear();
}
function bookObject(row, bookCode) {
return {
book_type: bookCode,
cost: row.book_cost,
depreciation_method: row.depreciation_method,
convention: row.convention,
useful_life_months: row.book_life,
section_179_amount: row.section_179_amount,
bonus_percent: row.bonus_percent,
business_use_percent: row.book_bup,
investment_use_percent: row.book_ivp,
manual_depreciation: row.manual_depreciation
};
}
// True when the mid-quarter convention applies to (company, book) MACRS personal property placed in
// service in `year`. No-op-friendly: returns false when the portfolio doesn't trip the 40% threshold.
function applies(entityId, bookCode, year) {
if (!entityId || !bookCode || !year) return false;
const key = `${entityId}|${bookCode}|${Number(year)}`;
if (cache.has(key)) return cache.get(key);
const dep = require('../depreciation');
const { ruleSetForBook } = require('./ruleSets');
const { assetFromRow } = require('./assets');
const ruleSet = ruleSetForBook(bookCode);
const rows = all(
`SELECT a.*, b.cost AS book_cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
b.section_179_amount, b.bonus_percent, b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
FROM assets a JOIN asset_books b ON b.asset_id = a.id
WHERE b.book_type = ? AND b.active = 1 AND a.entity_id = ? AND a.status != 'disposed'`,
[bookCode, entityId]
);
let total = 0;
let lastQuarter = 0;
for (const row of rows) {
const pis = row.in_service_date || row.acquired_date;
if (!pis || dep.yearFromDate(pis) !== Number(year)) continue;
const book = bookObject(row, bookCode);
const methodRule = dep.getMethodRule(ruleSet, book.depreciation_method);
if (!dep.isMidQuarterEligible(book, methodRule)) continue;
const basis = dep.midQuarterBasis(assetFromRow(row), book);
if (basis <= 0) continue;
total += basis;
if (Math.ceil(dep.monthFromDate(pis) / 3) === 4) lastQuarter += basis;
}
const result = total > 0 && lastQuarter / total > 0.4;
cache.set(key, result);
return result;
}
module.exports = { applies, clearCache };

View File

@@ -0,0 +1,107 @@
const { all, audit, now, one, run } = require('../db');
const { getLimit } = require('./section179Limits');
// §179 return-level taxable-income limitation and carryover (IRS Pub 946 / IRC §179(b)). The per-asset
// engine already caps each asset's §179 at the dollar limit and phase-out; THIS layer aggregates the
// §179 elected across a company's assets for a tax year, applies the single annual dollar limit (reduced
// by the investment phase-out) AND the business taxable-income limitation, and carries any disallowed
// amount forward to the next year. Stateful: the prior year's carryover feeds the next year's available
// deduction. Per company + book + year.
function yearOf(value) {
const s = String(value || '');
return s ? Number(s.slice(0, 4)) : 0;
}
function round2(n) {
return Math.round((Number(n) || 0) * 100) / 100;
}
// §179 elected per asset and the cost of §179 property placed in service, for a (company, book, year).
function electedFor(companyId, bookCode, year) {
const rows = all(
`SELECT a.acquisition_cost, a.in_service_date, a.acquired_date, b.section_179_amount, b.cost
FROM assets a JOIN asset_books b ON b.asset_id = a.id
WHERE a.entity_id = ? AND b.book_type = ? AND b.active = 1 AND a.status != 'disposed'`,
[companyId, bookCode]
);
let elected = 0;
let propertyCost = 0;
let count = 0;
for (const r of rows) {
if (yearOf(r.in_service_date || r.acquired_date) !== Number(year)) continue;
const amt = Number(r.section_179_amount || 0);
if (amt <= 0) continue;
elected += amt;
propertyCost += Number(r.cost ?? r.acquisition_cost ?? 0);
count += 1;
}
return { elected: round2(elected), propertyCost: round2(propertyCost), count };
}
function savedRow(companyId, bookCode, year) {
return one('SELECT * FROM section_179_carryover WHERE entity_id = ? AND book_code = ? AND tax_year = ?', [companyId, bookCode, Number(year)]);
}
// Compute the limitation. `taxableIncome` overrides the stored value when provided (null/undefined uses
// the stored value, if any). The business-income limitation is only applied when an income figure exists.
function summary(companyId, bookCode, year, taxableIncome) {
const yr = Number(year);
const limitRow = getLimit(yr);
const { elected, propertyCost, count } = electedFor(companyId, bookCode, yr);
const priorRow = savedRow(companyId, bookCode, yr - 1);
const priorCarryover = round2(priorRow ? priorRow.carryover_to_next : 0);
const baseLimit = limitRow ? Number(limitRow.base_limit) : Infinity;
const phaseoutThreshold = limitRow ? Number(limitRow.phaseout_threshold) : Infinity;
const phaseoutReduction = Number.isFinite(phaseoutThreshold) ? Math.max(0, propertyCost - phaseoutThreshold) : 0;
const dollarLimit = Number.isFinite(baseLimit) ? Math.max(0, baseLimit - phaseoutReduction) : Infinity;
const available = round2(elected + priorCarryover);
const dollarAllowed = round2(Math.min(available, dollarLimit));
const stored = savedRow(companyId, bookCode, yr);
const income = taxableIncome !== undefined && taxableIncome !== null
? Number(taxableIncome)
: (stored && stored.taxable_income !== null ? Number(stored.taxable_income) : null);
const incomeApplied = income !== null && !Number.isNaN(income);
const allowed = round2(incomeApplied ? Math.min(dollarAllowed, Math.max(0, income)) : dollarAllowed);
const carryover = round2(Math.max(0, available - allowed));
return {
company_id: companyId,
book_code: bookCode,
tax_year: yr,
asset_count: count,
elected,
property_cost: propertyCost,
prior_carryover: priorCarryover,
base_limit: Number.isFinite(baseLimit) ? baseLimit : null,
phaseout_threshold: Number.isFinite(phaseoutThreshold) ? phaseoutThreshold : null,
phaseout_reduction: round2(phaseoutReduction),
dollar_limit: Number.isFinite(dollarLimit) ? round2(dollarLimit) : null,
available,
dollar_allowed: dollarAllowed,
taxable_income: incomeApplied ? round2(income) : null,
income_limited: incomeApplied && allowed < dollarAllowed,
allowed_deduction: allowed,
carryover_to_next: carryover
};
}
// Persist the entered taxable income and the resulting carryover (so the next year picks it up). Audited.
function save(companyId, bookCode, year, taxableIncome, userId) {
const result = summary(companyId, bookCode, year, taxableIncome);
const income = taxableIncome === undefined || taxableIncome === null || taxableIncome === '' ? null : Number(taxableIncome);
run(
`INSERT INTO section_179_carryover (entity_id, book_code, tax_year, taxable_income, carryover_to_next, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entity_id, book_code, tax_year) DO UPDATE SET
taxable_income = excluded.taxable_income, carryover_to_next = excluded.carryover_to_next,
updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
[companyId, bookCode, Number(year), income, result.carryover_to_next, userId, now()]
);
audit(userId, 'update', 'section_179_limitation', `${bookCode}:${year}`, null, { taxable_income: income, carryover: result.carryover_to_next });
return result;
}
module.exports = { summary, save };

View File

@@ -0,0 +1,95 @@
const { all, audit, now, one, run } = require('../db');
// §280F passenger-automobile annual depreciation caps, keyed by placed-in-service year (IRS Rev. Proc.,
// indexed annually). Mirrors the §179-limits reference service: cached for the engine, CRUD for admins.
let cache = null;
function clearCache() {
cache = null;
}
function rows() {
if (!cache) cache = all('SELECT * FROM section_280f_limits ORDER BY pis_year');
return cache;
}
// Caps for a placed-in-service year: the most recent configured year at or before it, or null.
function getLimit(year) {
const y = Number(year);
if (!y) return null;
let match = null;
for (const row of rows()) {
if (row.pis_year <= y) match = row;
else break;
}
return match;
}
function fromRow(row) {
return {
id: row.id,
pis_year: row.pis_year,
year1_no_bonus: row.year1_no_bonus,
year1_bonus: row.year1_bonus,
year2: row.year2,
year3: row.year3,
year4plus: row.year4plus
};
}
function list() {
return all('SELECT * FROM section_280f_limits ORDER BY pis_year DESC').map(fromRow);
}
function normalize(body) {
return {
pis_year: Math.trunc(Number(body.pis_year)) || 0,
year1_no_bonus: Number(body.year1_no_bonus) || 0,
year1_bonus: Number(body.year1_bonus) || 0,
year2: Number(body.year2) || 0,
year3: Number(body.year3) || 0,
year4plus: Number(body.year4plus) || 0
};
}
function createLimit(body, userId) {
const input = normalize(body);
if (!input.pis_year) throw Object.assign(new Error('A valid placed-in-service year is required'), { status: 400 });
if (one('SELECT id FROM section_280f_limits WHERE pis_year = ?', [input.pis_year])) {
throw Object.assign(new Error('Caps for that year already exist'), { status: 400 });
}
const ts = now();
run(
`INSERT INTO section_280f_limits (pis_year, year1_no_bonus, year1_bonus, year2, year3, year4plus, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[input.pis_year, input.year1_no_bonus, input.year1_bonus, input.year2, input.year3, input.year4plus, ts, ts]
);
clearCache();
audit(userId, 'create', 'section_280f_limit', String(input.pis_year), null, input);
return fromRow(one('SELECT * FROM section_280f_limits WHERE pis_year = ?', [input.pis_year]));
}
function updateLimit(year, body, userId) {
const before = one('SELECT * FROM section_280f_limits WHERE pis_year = ?', [Number(year)]);
if (!before) return null;
const input = normalize({ ...fromRow(before), ...body, pis_year: before.pis_year });
run(
'UPDATE section_280f_limits SET year1_no_bonus = ?, year1_bonus = ?, year2 = ?, year3 = ?, year4plus = ?, updated_at = ? WHERE pis_year = ?',
[input.year1_no_bonus, input.year1_bonus, input.year2, input.year3, input.year4plus, now(), before.pis_year]
);
clearCache();
audit(userId, 'update', 'section_280f_limit', String(before.pis_year), fromRow(before), input);
return fromRow(one('SELECT * FROM section_280f_limits WHERE pis_year = ?', [before.pis_year]));
}
function deleteLimit(year, userId) {
const before = one('SELECT * FROM section_280f_limits WHERE pis_year = ?', [Number(year)]);
if (!before) return false;
run('DELETE FROM section_280f_limits WHERE pis_year = ?', [before.pis_year]);
clearCache();
audit(userId, 'delete', 'section_280f_limit', String(before.pis_year), fromRow(before), null);
return true;
}
module.exports = { clearCache, createLimit, deleteLimit, getLimit, list, updateLimit };