299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
const { parseJson } = require('./db');
|
|
const { buildDepreciationMethods } = require('./rule-catalog');
|
|
const { getZone } = require('./services/zones');
|
|
const { getLimit: getSection179Limit } = require('./services/section179Limits');
|
|
|
|
// Built-in method catalog, used as a fallback so codes like MF200/DB200 always resolve
|
|
// even if a particular rule set hasn't been re-expanded to include them.
|
|
let methodCatalog = null;
|
|
function catalog() {
|
|
if (!methodCatalog) methodCatalog = buildDepreciationMethods();
|
|
return methodCatalog;
|
|
}
|
|
|
|
function money(value) {
|
|
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
|
|
}
|
|
|
|
function yearFromDate(value) {
|
|
if (!value) return new Date().getFullYear();
|
|
return new Date(`${value}T00:00:00`).getFullYear();
|
|
}
|
|
|
|
function monthFromDate(value) {
|
|
if (!value) return 1;
|
|
return new Date(`${value}T00:00:00`).getMonth() + 1;
|
|
}
|
|
|
|
function parseMaybeJson(value, fallback = null) {
|
|
if (typeof value === 'string') return parseJson(value, fallback);
|
|
return value ?? fallback;
|
|
}
|
|
|
|
function businessUseFactor(asset, book) {
|
|
return Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
|
|
}
|
|
|
|
// Cost basis before salvage: acquisition value less land, scaled by business-use %.
|
|
function grossBasis(asset, book) {
|
|
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
|
|
const land = Number(asset.land_value || 0);
|
|
return Math.max(0, (cost - land) * businessUseFactor(asset, book));
|
|
}
|
|
|
|
// Salvage reduces what standard methods recover, but MACRS ignores it (ignoreSalvage flag).
|
|
function salvageValue(asset, book, methodRule) {
|
|
if (methodRule && methodRule.ignoreSalvage) return 0;
|
|
return Math.max(0, Number(asset.salvage_value || 0) * businessUseFactor(asset, book));
|
|
}
|
|
|
|
function section179Amount(asset, book) {
|
|
return Math.min(grossBasis(asset, book), Number(book.section_179_amount || 0), section179Limit(asset, book));
|
|
}
|
|
|
|
// Enterprise/empowerment-zone Section 179 treatment for the federal book: an increased dollar
|
|
// limit and a reduced (e.g. 50%) share of cost counting toward the phaseout threshold. Applies
|
|
// only when the asset is flagged for such a zone and placed in service within the date window.
|
|
function zoneSection179(asset, book) {
|
|
if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') return null;
|
|
const zone = getZone(asset.special_zone);
|
|
if (!zone) return null;
|
|
const increase = Number(zone.section_179_increase || 0);
|
|
const costFactor = Number(zone.section_179_cost_factor || 0) || 1;
|
|
if (!increase && costFactor === 1) return null;
|
|
const pis = asset.in_service_date || asset.acquired_date || '';
|
|
if (pis && zone.pis_start && pis < zone.pis_start) return null;
|
|
if (pis && zone.pis_end && pis > zone.pis_end) return null;
|
|
return { increase, costFactor };
|
|
}
|
|
|
|
// Applicable §179 dollar cap for an asset's placed-in-service year: the configured base limit
|
|
// (plus any enterprise-zone increase) less the dollar-for-dollar investment phaseout. Returns
|
|
// Infinity when no limit is configured for the year, leaving the entered §179 amount uncapped.
|
|
function section179Limit(asset, book) {
|
|
const pisYear = yearFromDate(asset.in_service_date || asset.acquired_date);
|
|
const limitRow = getSection179Limit(pisYear);
|
|
if (!limitRow) return Infinity;
|
|
let limit = Number(limitRow.base_limit) || 0;
|
|
const threshold = Number(limitRow.phaseout_threshold) || 0;
|
|
let costFactor = 1;
|
|
const zone = zoneSection179(asset, book);
|
|
if (zone) {
|
|
limit += zone.increase;
|
|
costFactor = zone.costFactor;
|
|
}
|
|
if (threshold > 0) {
|
|
const investment = grossBasis(asset, book) * costFactor;
|
|
limit -= Math.max(0, investment - threshold);
|
|
}
|
|
return Math.max(0, limit);
|
|
}
|
|
|
|
// Special-zone §168 allowance (e.g. New York Liberty Zone): applies to the federal tax book
|
|
// when the asset is flagged for a zone and its placed-in-service date is within the window.
|
|
// Returns the allowance % (used as the §168 bonus) or 0.
|
|
function zoneAllowance(asset, book) {
|
|
if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') return 0;
|
|
const zone = getZone(asset.special_zone);
|
|
if (!zone || !Number(zone.allowance_percent)) return 0;
|
|
const pis = asset.in_service_date || asset.acquired_date || '';
|
|
if (pis && zone.pis_start && pis < zone.pis_start) return 0;
|
|
if (pis && zone.pis_end && pis > zone.pis_end) return 0;
|
|
return Number(zone.allowance_percent);
|
|
}
|
|
|
|
// Section 168 (bonus) allowance, applied after Section 179.
|
|
function bonusAmount(asset, book) {
|
|
const after179 = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
|
|
return after179 * (Number(book.bonus_percent || 0) / 100);
|
|
}
|
|
|
|
// Total amount recoverable over the asset's life (the schedule cap): cost basis less salvage.
|
|
function recoverableBasis(asset, book, methodRule) {
|
|
return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, methodRule));
|
|
}
|
|
|
|
// Amount a straight-line / SYD method depreciates: net of Section 179, bonus, and salvage.
|
|
function depreciableBasis(asset, book, methodRule) {
|
|
return Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book) - salvageValue(asset, book, methodRule));
|
|
}
|
|
|
|
// Cost basis net of salvage (no 179/bonus) — retained for reports and disposals.
|
|
function totalDepreciableBasis(asset, book) {
|
|
return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, null));
|
|
}
|
|
|
|
function conventionFor(book, methodRule) {
|
|
return methodRule.defaultConvention || book.convention || 'none';
|
|
}
|
|
|
|
function yearFraction(index, convention, asset) {
|
|
if (index < 0) return 0;
|
|
const month = monthFromDate(asset.in_service_date || asset.acquired_date);
|
|
const quarter = Math.ceil(month / 3);
|
|
|
|
if (convention === 'none') return 1;
|
|
if (convention === 'full_month') {
|
|
const first = (13 - month) / 12;
|
|
if (index === 0) return first;
|
|
return null;
|
|
}
|
|
if (convention === 'mid_month') {
|
|
const first = (12 - month + 0.5) / 12;
|
|
if (index === 0) return first;
|
|
return null;
|
|
}
|
|
if (convention === 'mid_quarter') {
|
|
const firstByQuarter = { 1: 0.875, 2: 0.625, 3: 0.375, 4: 0.125 };
|
|
if (index === 0) return firstByQuarter[quarter] || 0.5;
|
|
return null;
|
|
}
|
|
if (convention === 'half_year') {
|
|
if (index === 0) return 0.5;
|
|
return null;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
function fractionForIndex(index, convention, asset, totalYears) {
|
|
const first = yearFraction(0, convention, asset);
|
|
const explicit = yearFraction(index, convention, asset);
|
|
if (explicit !== null) return explicit;
|
|
const finalIndex = Math.ceil(totalYears + (1 - first)) - 1;
|
|
if (index > finalIndex) return 0;
|
|
if (index === finalIndex) return Math.max(0, 1 - first);
|
|
return 1;
|
|
}
|
|
|
|
function straightLine(asset, book, index, totalYears, methodRule) {
|
|
const basis = depreciableBasis(asset, book, methodRule);
|
|
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
|
|
}
|
|
|
|
function sumOfYearsDigits(asset, book, index, totalYears, methodRule) {
|
|
const basis = depreciableBasis(asset, book, methodRule);
|
|
const denominator = (totalYears * (totalYears + 1)) / 2;
|
|
return basis * ((totalYears - index) / denominator);
|
|
}
|
|
|
|
function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) {
|
|
// The declining-balance rate applies to the full (post-179/bonus) book value; depreciation
|
|
// is floored at salvage. MACRS methods set salvage to 0 via ignoreSalvage.
|
|
const start = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
|
|
const salvage = salvageValue(asset, book, methodRule);
|
|
let bookValue = start;
|
|
let elapsed = 0;
|
|
const convention = conventionFor(book, methodRule);
|
|
|
|
for (let i = 0; i <= index; i += 1) {
|
|
const fraction = fractionForIndex(i, convention, asset, totalYears);
|
|
const recoverable = Math.max(0, bookValue - salvage);
|
|
if (fraction <= 0 || recoverable <= 0) return 0;
|
|
|
|
const dbAmount = bookValue * (multiplier / totalYears) * fraction;
|
|
const remainingLife = Math.max(0.0001, totalYears - elapsed);
|
|
const slAmount = (recoverable / remainingLife) * fraction;
|
|
const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
|
|
const amount = Math.min(recoverable, selected);
|
|
|
|
if (i === index) return amount;
|
|
bookValue -= amount;
|
|
elapsed += fraction;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function rateTable(asset, book, index, methodRule) {
|
|
const basis = depreciableBasis(asset, book, methodRule);
|
|
const rates = methodRule.rates || [];
|
|
if (!rates.length && methodRule.fallbackFormula === 'straight_line') {
|
|
return straightLine(asset, book, index, Math.max(1, Math.ceil((methodRule.lifeMonths || book.useful_life_months || 60) / 12)), methodRule);
|
|
}
|
|
return basis * Number(rates[index] || 0);
|
|
}
|
|
|
|
function getMethodRule(ruleSet, code) {
|
|
const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {};
|
|
return (rules.methods || []).find((method) => method.code === code)
|
|
|| catalog().find((method) => method.code === code)
|
|
|| { code, formula: code, label: code };
|
|
}
|
|
|
|
function annualSchedule(asset, book, ruleSet, options = {}) {
|
|
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
|
|
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
|
|
if (!(Number(book.bonus_percent) > 0)) {
|
|
const allowance = zoneAllowance(asset, book);
|
|
if (allowance > 0) book = { ...book, bonus_percent: allowance };
|
|
}
|
|
const usefulLifeMonths = Number(methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60);
|
|
const totalYears = Math.max(1, Math.ceil(usefulLifeMonths / 12));
|
|
const placedInServiceYear = yearFromDate(asset.in_service_date || asset.acquired_date);
|
|
const startYear = Number(options.startYear || placedInServiceYear);
|
|
const endYear = Number(options.endYear || startYear + totalYears + 1);
|
|
const manual = parseMaybeJson(book.manual_depreciation, {});
|
|
const rows = [];
|
|
let accumulated = Number(asset.prior_depreciation || 0);
|
|
|
|
for (let year = placedInServiceYear; year <= endYear; year += 1) {
|
|
const index = year - placedInServiceYear;
|
|
let depreciation = 0;
|
|
if (index >= 0) {
|
|
if (index === 0) {
|
|
depreciation += section179Amount(asset, book) + bonusAmount(asset, book);
|
|
}
|
|
|
|
if (methodRule.formula === 'rate_table') {
|
|
depreciation += rateTable(asset, book, index, methodRule);
|
|
} else if (methodRule.formula === 'sum_of_years_digits') {
|
|
depreciation += sumOfYearsDigits(asset, book, index, totalYears, methodRule);
|
|
} else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') {
|
|
depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule);
|
|
} else if (methodRule.formula === 'acrs_alternate_straight_line') {
|
|
depreciation += straightLine(asset, book, index, totalYears, { ...methodRule, defaultConvention: 'half_year' });
|
|
} else if (methodRule.formula === 'manual') {
|
|
depreciation = Number(manual[year] || 0);
|
|
} else {
|
|
depreciation += straightLine(asset, book, index, totalYears, methodRule);
|
|
}
|
|
}
|
|
|
|
const cap = recoverableBasis(asset, book, methodRule);
|
|
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated)));
|
|
accumulated += depreciation;
|
|
|
|
if (year >= startYear) {
|
|
rows.push({
|
|
year,
|
|
book: book.book_type,
|
|
method: methodRule.code,
|
|
methodLabel: methodRule.label || methodRule.code,
|
|
depreciation: money(depreciation),
|
|
accumulated: money(accumulated),
|
|
netBookValue: money(Math.max(0, Number(book.cost ?? asset.acquisition_cost ?? 0) - accumulated))
|
|
});
|
|
}
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function monthlyFromAnnual(row) {
|
|
const monthly = money(row.depreciation / 12);
|
|
return Array.from({ length: 12 }, (_, index) => ({
|
|
month: index + 1,
|
|
year: row.year,
|
|
book: row.book,
|
|
depreciation: monthly
|
|
}));
|
|
}
|
|
|
|
module.exports = {
|
|
annualSchedule,
|
|
depreciableBasis,
|
|
totalDepreciableBasis,
|
|
getMethodRule,
|
|
monthlyFromAnnual,
|
|
money
|
|
};
|