const { parseJson } = require('./db'); const { buildDepreciationMethods } = require('./rule-catalog'); const { getZone } = require('./services/zones'); // 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)); } // 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 };