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

@@ -358,6 +358,32 @@ function initialize() {
updated_at TEXT NOT NULL
);
-- §280F passenger-automobile annual depreciation caps (IRS Rev. Proc., indexed yearly).
CREATE TABLE IF NOT EXISTS section_280f_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pis_year INTEGER NOT NULL UNIQUE,
year1_no_bonus REAL NOT NULL DEFAULT 0,
year1_bonus REAL NOT NULL DEFAULT 0,
year2 REAL NOT NULL DEFAULT 0,
year3 REAL NOT NULL DEFAULT 0,
year4plus REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- §179 taxable-income limitation: per company+book+year entered income and computed carryover.
CREATE TABLE IF NOT EXISTS section_179_carryover (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER REFERENCES entities(id),
book_code TEXT NOT NULL DEFAULT 'FEDERAL',
tax_year INTEGER NOT NULL,
taxable_income REAL,
carryover_to_next REAL NOT NULL DEFAULT 0,
updated_by INTEGER REFERENCES users(id),
updated_at TEXT NOT NULL,
UNIQUE(entity_id, book_code, tax_year)
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
@@ -889,6 +915,17 @@ function migrate() {
ensureColumn('gl_account_defaults', 'proceeds_account', 'TEXT');
ensureColumn('gl_account_defaults', 'retained_earnings_account', 'TEXT');
ensureColumn('entities', 'capitalization_threshold', 'REAL NOT NULL DEFAULT 0');
// §280F: flag passenger automobiles subject to the annual luxury-auto depreciation caps.
ensureColumn('assets', 'passenger_auto', 'INTEGER NOT NULL DEFAULT 0');
// Depreciation-recapture characterization stored on disposals (Form 4797).
ensureColumn('disposals', 'recapture_section', 'TEXT');
ensureColumn('disposals', 'ordinary_recapture', 'REAL NOT NULL DEFAULT 0');
ensureColumn('disposals', 'section_1231_gain', 'REAL NOT NULL DEFAULT 0');
ensureColumn('disposals', 'unrecaptured_1250_gain', 'REAL NOT NULL DEFAULT 0');
// Refresh stale §179 limits seeded before the 2025 OBBBA increase (only touch the old seed values,
// never a figure an administrator has deliberately raised). 2026 per IRS Pub 946 (2025 ed.).
run('UPDATE section_179_limits SET base_limit = 2500000, phaseout_threshold = 4000000 WHERE tax_year = 2025 AND base_limit < 2000000');
run('UPDATE section_179_limits SET base_limit = 2560000, phaseout_threshold = 4090000 WHERE tax_year = 2026 AND base_limit < 2000000');
backfillCompanyScope();
}
@@ -1245,8 +1282,9 @@ function seed() {
[2011, 500000, 2000000], [2012, 500000, 2000000], [2013, 500000, 2000000],
[2014, 500000, 2000000], [2015, 500000, 2000000], [2016, 500000, 2010000], [2017, 510000, 2030000],
[2018, 1000000, 2500000], [2019, 1020000, 2550000], [2020, 1040000, 2590000], [2021, 1050000, 2620000],
[2022, 1080000, 2700000], [2023, 1160000, 2890000], [2024, 1220000, 3050000], [2025, 1250000, 3130000],
[2026, 1250000, 3130000]
[2022, 1080000, 2700000], [2023, 1160000, 2890000], [2024, 1220000, 3050000],
// 2025+ reflect the 2025 OBBBA increase; 2026 per IRS Pub 946 (2025 ed.), "What's New for 2026".
[2025, 2500000, 4000000], [2026, 2560000, 4090000]
];
for (const [year, limit, threshold] of section179Limits) {
run(
@@ -1255,6 +1293,22 @@ function seed() {
[year, limit, threshold, createdAt, createdAt]
);
}
// §280F passenger-automobile depreciation caps [year, y1-no-bonus, y1-bonus, y2, y3, y4+]. Published
// IRS figures (Rev. Proc. 2023-14 / 2024-13); out-years are estimates — these are editable data.
const section280fLimits = [
[2022, 11200, 19200, 18000, 10800, 6460],
[2023, 12200, 20200, 19500, 11700, 6960],
[2024, 12400, 20400, 19800, 11900, 7160],
[2025, 12600, 20600, 20200, 12100, 7260],
[2026, 12800, 20800, 20600, 12300, 7360]
];
for (const [year, y1nb, y1b, y2, y3, y4] of section280fLimits) {
run(
`INSERT OR IGNORE INTO section_280f_limits (pis_year, year1_no_bonus, year1_bonus, year2, year3, year4plus, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[year, y1nb, y1b, y2, y3, y4, createdAt, createdAt]
);
}
if (!one('SELECT id FROM teams LIMIT 1')) {
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
'Finance Operations',

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

View File

@@ -6,6 +6,7 @@ const { exportReport } = require('../services/reportExport');
const { rowsFromImport, extensionForUpload } = require('../services/importExport');
const glEntries = require('../services/glEntries');
const glAccounts = require('../services/glAccounts');
const section179Limitation = require('../services/section179Limitation');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
@@ -79,6 +80,19 @@ router.delete('/gl-accounts/:id', ledgerEditor, (req, res) => {
return res.status(204).end();
});
// §179 return-level taxable-income limitation + carryover (per company + book + year).
router.get('/section-179/limitation', ledgerReader, (req, res) => {
const book = req.query.book || 'FEDERAL';
const year = Number(req.query.year) || new Date().getFullYear();
res.json({ summary: section179Limitation.summary(req.companyId, book, year, req.query.taxable_income) });
});
router.put('/section-179/limitation', ledgerEditor, (req, res) => {
const book = req.body.book || 'FEDERAL';
const year = Number(req.body.year) || new Date().getFullYear();
res.json({ summary: section179Limitation.save(req.companyId, book, year, req.body.taxable_income, req.user.id) });
});
// GL posting defaults (the fallback accounts the ledgers post to).
router.get('/gl-account-defaults', ledgerReader, (req, res) => {
res.json({ defaults: glAccounts.getDefaults(req.companyId) });

View File

@@ -6,6 +6,7 @@ const companies = require('../services/companies');
const assetClasses = require('../services/assetClasses');
const zones = require('../services/zones');
const section179Limits = require('../services/section179Limits');
const section280fLimits = require('../services/section280fLimits');
const router = express.Router();
@@ -67,6 +68,34 @@ router.delete('/section-179-limits/:year', requireCapability('config.manage'), (
return res.status(204).end();
});
// §280F passenger-automobile depreciation caps (indexed yearly; user-managed).
router.get('/section-280f-limits', (req, res) => {
res.json({ limits: section280fLimits.list() });
});
router.post('/section-280f-limits', requireCapability('config.manage'), (req, res, next) => {
try {
res.status(201).json({ limit: section280fLimits.createLimit(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/section-280f-limits/:year', requireCapability('config.manage'), (req, res, next) => {
try {
const limit = section280fLimits.updateLimit(req.params.year, req.body, req.user.id);
if (!limit) return res.status(404).json({ error: 'Limit not found' });
return res.json({ limit });
} catch (error) {
return next(error);
}
});
router.delete('/section-280f-limits/:year', requireCapability('config.manage'), (req, res) => {
if (!section280fLimits.deleteLimit(req.params.year, req.user.id)) return res.status(404).json({ error: 'Limit not found' });
return res.status(204).end();
});
// Asset class catalog (seeded from the IRS tables, then user-managed). GET is reference
// data used by the category picker; mutations require config.manage.
router.get('/asset-classes', (req, res) => {

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

View File

@@ -1985,6 +1985,51 @@ async function main() {
if (!Array.isArray(deprAgg.byBook) || !deprAgg.byBook.some((b) => b.book === 'GAAP')) throw new Error('Dashboard depreciation aggregator missing byBook/GAAP');
if (!Array.isArray(deprAgg.byCategory)) throw new Error('Dashboard depreciation aggregator missing byCategory');
// ---- MACRS mid-quarter 40% auto-test (IRS Pub 946) --------------------------
// Q4-heavy portfolio (80% of basis placed in service in Q4) → mid-quarter convention is mandatory and
// must be applied automatically: the Q4 asset's first-year MACRS depreciation should be the mid-quarter
// amount (8000 × 0.40 × 0.125 = 400), not the half-year amount (8000 × 0.40 × 0.5 = 1600).
const macrsBook = (cost) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost });
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MQ1-${suffix}`, description: 'Mid-quarter Q1', category: 'Computer Equipment', acquisition_cost: 2000, acquired_date: '2029-02-15', in_service_date: '2029-02-15', useful_life_months: 60, books: [macrsBook(2000)] }) });
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MQ4-${suffix}`, description: 'Mid-quarter Q4', category: 'Computer Equipment', acquisition_cost: 8000, acquired_date: '2029-11-15', in_service_date: '2029-11-15', useful_life_months: 60, books: [macrsBook(8000)] }) });
const mqReport = (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year: 2029 } }) })).report;
const mqRow = mqReport.rows.find((r) => r.asset_id === `MQ4-${suffix}`);
if (!mqRow) throw new Error('Mid-quarter test asset missing from the depreciation report');
if (Math.abs(mqRow.depreciation - 400) > 1) throw new Error(`Mid-quarter convention not auto-applied: expected ~400 (mid-quarter), got ${mqRow.depreciation}`);
// Verify the §179 limits were updated to the IRS Pub 946 (2026) figures.
const s179 = (await request('/api/section-179-limits', { headers: auth })).limits;
const y2026 = s179.find((l) => l.tax_year === 2026);
if (!y2026 || Number(y2026.base_limit) !== 2560000 || Number(y2026.phaseout_threshold) !== 4090000) {
throw new Error(`2026 §179 limit should be 2,560,000 / 4,090,000, got ${JSON.stringify(y2026)}`);
}
// ---- §280F passenger-auto cap + listed-property >50% ADS test (IRS Pub 946) ---
const macrsBook5 = (cost) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost });
// $90k luxury auto, 100% business, 2024 → year-1 MACRS would be 18,000 but §280F caps it to 12,400.
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `AUTO-${suffix}`, description: 'Luxury auto', category: 'Computer Equipment', acquisition_cost: 90000, acquired_date: '2024-06-01', in_service_date: '2024-06-01', useful_life_months: 60, business_use_percent: 100, passenger_auto: true, books: [macrsBook5(90000)] }) });
// Listed property at 40% business use → forced ADS straight-line (basis 10k×40% / 5yr × half-year = 400).
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `LIST-${suffix}`, description: 'Listed low-use', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-06-01', in_service_date: '2024-06-01', useful_life_months: 60, business_use_percent: 40, listed_property: true, books: [macrsBook5(10000)] }) });
const depr2024 = (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year: 2024 } }) })).report;
const autoRow = depr2024.rows.find((r) => r.asset_id === `AUTO-${suffix}`);
if (!autoRow || Math.abs(autoRow.depreciation - 12400) > 1) throw new Error(`§280F auto cap not applied: expected 12,400, got ${autoRow && autoRow.depreciation}`);
const listRow = depr2024.rows.find((r) => r.asset_id === `LIST-${suffix}`);
if (!listRow || Math.abs(listRow.depreciation - 400) > 1) throw new Error(`Listed ≤50% ADS not applied: expected 400, got ${listRow && listRow.depreciation}`);
if (!(await request('/api/section-280f-limits', { headers: auth })).limits.some((l) => l.pis_year === 2024)) throw new Error('§280F limits not seeded');
// ---- §179 taxable-income limitation + carryover ----------------------------
const s179Book = (amt) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_7', useful_life_months: 84, section_179_amount: amt, cost: amt });
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `S179A-${suffix}`, description: '179 A', category: 'Computer Equipment', acquisition_cost: 1500000, acquired_date: '2031-03-01', in_service_date: '2031-03-01', useful_life_months: 84, books: [s179Book(1500000)] }) });
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `S179B-${suffix}`, description: '179 B', category: 'Computer Equipment', acquisition_cost: 1500000, acquired_date: '2031-04-01', in_service_date: '2031-04-01', useful_life_months: 84, books: [s179Book(1500000)] }) });
// Elected 3,000,000; 2031 dollar limit 2,560,000; taxable income 1,000,000 → allowed 1,000,000, carry 2,000,000.
const limSaved = (await request('/api/section-179/limitation', { method: 'PUT', headers: aHeaders, body: JSON.stringify({ book: 'FEDERAL', year: 2031, taxable_income: 1000000 }) })).summary;
if (limSaved.elected !== 3000000) throw new Error(`§179 elected wrong: ${limSaved.elected}`);
if (limSaved.dollar_allowed !== 2560000) throw new Error(`§179 dollar limit wrong: ${limSaved.dollar_allowed}`);
if (limSaved.allowed_deduction !== 1000000) throw new Error(`§179 income limitation wrong: ${limSaved.allowed_deduction}`);
if (limSaved.carryover_to_next !== 2000000) throw new Error(`§179 carryover wrong: ${limSaved.carryover_to_next}`);
const lim2032 = (await request('/api/section-179/limitation?book=FEDERAL&year=2032', { headers: aHeaders })).summary;
if (lim2032.prior_carryover !== 2000000) throw new Error(`§179 carryover did not chain to next year: ${lim2032.prior_carryover}`);
console.log('Smoke test passed');
}