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

@@ -10,6 +10,7 @@ const { parseJson } = require('./db');
const { buildDepreciationMethods } = require('./rule-catalog');
const { getZone } = require('./services/zones');
const { getLimit: getSection179Limit } = require('./services/section179Limits');
const { getLimit: getSection280fLimit } = require('./services/section280fLimits');
const moment = require('moment');
console.log(`${moment().format()} ✅ Depreciation module loaded with dependencies: db, rule-catalog, zones, section179Limits`);
@@ -212,10 +213,75 @@ function totalDepreciableBasis(asset, book) {
return result;
}
// Determine the applicable depreciation convention for a book, based on the method rule's defaultConvention, falling back to the book's convention, and defaulting to 'none' when neither is set.
function conventionFor(book, methodRule) {
console.log(`${moment().format()} 🕒 Determining depreciation convention for book ${book.id}`);
return methodRule.defaultConvention || book.convention || 'none';
// Is this MACRS personal property (eligible for the mid-quarter 40% test)? Real property uses the
// mid-month convention and is excluded; only otherwise-half-year MACRS property can switch to mid-quarter.
function isMidQuarterEligible(book, methodRule) {
return Boolean(methodRule) && methodRule.family === 'MACRS'
&& (methodRule.defaultConvention || book.convention) === 'half_year';
}
// Unadjusted depreciable basis used by the mid-quarter 40% test: post-§179, pre-bonus (IRS Pub 946).
function midQuarterBasis(asset, book) {
return Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
}
// §280F: listed property used 50% or less for qualified business use must be depreciated under ADS
// (straight-line) and may not claim §179 or the special (bonus) allowance. Applies to MACRS tax books.
function listedAdsRequired(asset, book, methodRule) {
if (!asset.listed_property || !methodRule || methodRule.family !== 'MACRS') return false;
const use = businessUseFactor(asset, book);
return use > 0 && use <= 0.5;
}
// §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).
function section280fCap(asset, book, index) {
if (!asset.passenger_auto) return Infinity;
const row = getSection280fLimit(yearFromDate(asset.in_service_date || asset.acquired_date));
if (!row) return Infinity;
let base;
if (index <= 0) base = bonusAmount(asset, book) > 0 ? row.year1_bonus : row.year1_no_bonus;
else if (index === 1) base = row.year2;
else if (index === 2) base = row.year3;
else base = row.year4plus;
return Math.max(0, Number(base) || 0) * businessUseFactor(asset, book);
}
// §280F recapture preview: if a listed/passenger asset's qualified business use drops to ≤50% in
// `year`, the excess of accelerated depreciation actually claimed (through the prior year) over what ADS
// straight-line would have allowed is recaptured as ordinary income. Computed from the asset as it
// currently stands (i.e., its current business use is treated as the prior-years' use).
function section280fRecapture(asset, book, ruleSet, year) {
if (!asset.listed_property && !asset.passenger_auto) return { applies: false, recapture: 0 };
const through = Number(year) - 1;
const accel = annualSchedule(asset, book, ruleSet, { startYear: through, endYear: through, ignoreListedAds: true })[0] || {};
const adsBook = { ...book, depreciation_method: 'straight_line', section_179_amount: 0, bonus_percent: 0 };
const ads = annualSchedule(asset, adsBook, ruleSet, { startYear: through, endYear: through })[0] || {};
const acceleratedAccum = money(Number(accel.accumulated || 0));
const adsAccum = money(Number(ads.accumulated || 0));
return {
applies: true,
conversion_year: Number(year),
accelerated_accumulated: acceleratedAccum,
ads_accumulated: adsAccum,
recapture: Math.max(0, money(acceleratedAccum - adsAccum))
};
}
// Determine the applicable depreciation convention. MACRS mandates the mid-quarter convention when more
// than 40% of the year's MACRS personal-property basis is placed in service in the last quarter (IRS
// Pub 946). The engine determines this automatically and substitutes it for otherwise-half-year property.
function conventionFor(book, methodRule, asset) {
let convention = methodRule.defaultConvention || book.convention || 'none';
if (convention === 'half_year' && asset && isMidQuarterEligible(book, methodRule)) {
const pisYear = yearFromDate(asset.in_service_date || asset.acquired_date);
// Lazy require avoids a load-time cycle (the service reads back basis helpers from this module).
if (require('./services/midQuarter').applies(asset.entity_id, book.book_type, pisYear)) {
convention = 'mid_quarter';
}
}
return convention;
}
// Calculate the year fraction for a given year index based on the specified convention. The index is 0-based, so 0 corresponds to the placed-in-service year. Returns a fraction between 0 and 1 representing how much of the year's depreciation should be taken in that year, or null when the convention doesn't specify an explicit fraction for that year (e.g. for mid-month, which only has a special rule for the first year).
@@ -311,7 +377,7 @@ function straightLine(asset, book, index, totalYears, methodRule) {
console.log(`${moment().format()} 🕒 Calculating straight-line depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}`);
const basis = depreciableBasis(asset, book, methodRule);
console.log(`${moment().format()} 🕒 Depreciable basis for straight-line calculation is ${basis} for asset ${asset.id}`);
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule, asset), asset, totalYears);
}
// Sum-of-years-digits depreciation: (basis * (remaining years / sum of years)) * convention fraction. The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods.
@@ -332,7 +398,7 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule
const salvage = salvageValue(asset, book, methodRule);
let bookValue = start;
let elapsed = 0;
const convention = conventionFor(book, methodRule);
const convention = conventionFor(book, methodRule, asset);
for (let i = 0; i <= index; i += 1) {
// Note: for each year up to and including the target index, calculate the declining balance amount based on the current book value, multiplier, total years, and convention fraction; if the method has a switchToStraightLine flag, also calculate the straight-line amount based on the remaining recoverable basis and remaining life, and use whichever is greater; then reduce the book value by the selected amount and accumulate the elapsed years based on the convention fraction. For the target index, return the selected amount; for prior indices, just update the book value and elapsed years for the next iteration. If we exceed the total years before reaching the target index, return 0 since no depreciation should be taken.
@@ -390,7 +456,14 @@ function getMethodRule(ruleSet, code) {
// Generate an annual depreciation schedule for a given asset and book based on the applicable method rule, conventions, and special treatments. The schedule includes rows for each year from the placed-in-service year to the end year (which is determined by the useful life), with columns for the year, book type, method code and label, depreciation amount, accumulated depreciation, and net book value. The calculation applies Section 179 and bonus amounts in the first year, uses the method rule's formula to calculate depreciation for each year, and ensures that depreciation does not exceed the recoverable basis of the asset.
function annualSchedule(asset, book, ruleSet, options = {}) {
console.log(`${moment().format()} 🕒 Generating annual depreciation schedule for asset ${asset.id}, book ${book.id}`);
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
let methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
// §280F: listed property at ≤50% qualified business use is forced to ADS straight-line with no §179
// 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)` };
}
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
if (!(Number(book.bonus_percent) > 0)) {
// Note: the zone allowance can provide a significant boost to the first-year depreciation through the bonus, so we check for it and apply it as the book's bonus percentage when the book doesn't have its own configured bonus; this allows assets in qualified zones to get the appropriate benefit even when using a book that doesn't have an explicit bonus configured, while still allowing for more flexible handling of cases where a zone allowance is desired but specific books haven't been configured with a bonus percentage.
@@ -447,6 +520,15 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
}
}
// §280F passenger-automobile annual cap: a luxury auto recovers no more than the yearly limit, and
// continues recovering remaining basis at the cap after the normal recovery period ends.
if (asset.passenger_auto && index >= 0) {
const cap280f = section280fCap(asset, book, index);
const remaining = Math.max(0, recoverableBasis(asset, book, methodRule) - accumulated);
let allowed = Math.min(depreciation, cap280f);
if (allowed <= 0 && remaining > 0) allowed = Math.min(cap280f, remaining);
depreciation = allowed;
}
const cap = recoverableBasis(asset, book, methodRule);
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated)));
accumulated += depreciation;
@@ -493,6 +575,11 @@ module.exports = {
depreciableBasis,
totalDepreciableBasis,
getMethodRule,
isMidQuarterEligible,
midQuarterBasis,
monthFromDate,
monthlyFromAnnual,
money
money,
section280fRecapture,
yearFromDate
};