Added Section 179 Limits

This commit is contained in:
2026-06-05 05:55:43 -05:00
parent 4072695432
commit dfd999b6a9
11 changed files with 454 additions and 16 deletions

View File

@@ -652,6 +652,15 @@ function initialize() {
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS section_179_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tax_year INTEGER NOT NULL UNIQUE,
base_limit REAL NOT NULL DEFAULT 0,
phaseout_threshold REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER REFERENCES users(id),
@@ -694,6 +703,8 @@ function migrate() {
ensureColumn('assets', 'servicenow_sys_id', 'TEXT');
ensureColumn('assets', 'asset_class_number', 'TEXT');
ensureColumn('assets', 'special_zone', 'TEXT');
ensureColumn('depreciation_zones', 'section_179_increase', 'REAL NOT NULL DEFAULT 0');
ensureColumn('depreciation_zones', 'section_179_cost_factor', 'REAL NOT NULL DEFAULT 1');
dropUsersRoleCheck();
}
@@ -758,6 +769,34 @@ function seed() {
'30% special depreciation allowance for qualified NY Liberty Zone property; qualified leasehold improvements use a 5-year life; increased Section 179. Verify current tax law before filing.', 1, ?, ?)`,
[createdAt, createdAt]
);
// Enterprise / Empowerment Zone: raises the Section 179 limit by $35,000 (2002-2020) and counts
// only 50% of the property's cost toward the §179 investment-phaseout threshold.
run(
`INSERT OR IGNORE INTO depreciation_zones
(code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years,
section_179_increase, section_179_cost_factor, notes, enabled, created_at, updated_at)
VALUES ('enterprise_zone', 'Enterprise Zone (Empowerment Zone)', 0, '2002-01-01', '2020-12-31', NULL, NULL, NULL,
35000, 0.5,
'Section 179 limit increase for qualified empowerment/enterprise zone property: +$35,000 (2002-2020; +$20,000 for 1993-2001) and only 50% of cost counts toward the investment-phaseout threshold. Not available after 2020. Verify current tax law before filing.', 1, ?, ?)`,
[createdAt, createdAt]
);
// Annual Section 179 deduction limits & investment-phaseout thresholds (IRS historical values).
// Seeded once, then user-managed in Admin → Application Configuration → Section 179 limits.
const section179Limits = [
[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]
];
for (const [year, limit, threshold] of section179Limits) {
run(
`INSERT OR IGNORE INTO section_179_limits (tax_year, base_limit, phaseout_threshold, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
[year, limit, threshold, createdAt, createdAt]
);
}
if (!one('SELECT id FROM teams LIMIT 1')) {
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
'Finance Operations',

View File

@@ -1,6 +1,7 @@
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.
@@ -47,7 +48,45 @@ function salvageValue(asset, book, methodRule) {
}
function section179Amount(asset, book) {
return Math.min(grossBasis(asset, book), Number(book.section_179_amount || 0));
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

View File

@@ -4,6 +4,7 @@ const { requireCapability } = require('../middleware/auth');
const { formatAssetId } = require('../services/assets');
const assetClasses = require('../services/assetClasses');
const zones = require('../services/zones');
const section179Limits = require('../services/section179Limits');
const router = express.Router();
@@ -36,6 +37,35 @@ router.delete('/depreciation-zones/:code', requireCapability('config.manage'), (
return res.status(204).end();
});
// Annual Section 179 deduction limits & phaseout thresholds. GET is reference data; mutations
// require config.manage. The engine caps each asset's §179 deduction at the applicable year's limit.
router.get('/section-179-limits', (req, res) => {
res.json({ limits: section179Limits.list() });
});
router.post('/section-179-limits', requireCapability('config.manage'), (req, res, next) => {
try {
res.status(201).json({ limit: section179Limits.createLimit(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/section-179-limits/:year', requireCapability('config.manage'), (req, res, next) => {
try {
const limit = section179Limits.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-179-limits/:year', requireCapability('config.manage'), (req, res) => {
if (!section179Limits.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

@@ -0,0 +1,95 @@
const { all, audit, now, one, run } = require('../db');
let cache = null;
function clearCache() {
cache = null;
}
// Sorted ascending by tax_year, cached for the engine.
function rows() {
if (!cache) cache = all('SELECT * FROM section_179_limits ORDER BY tax_year');
return cache;
}
// Applicable limit for a placed-in-service year: the most recent year at or before it.
// Returns null when nothing is configured for/under the year, so the engine leaves §179 uncapped.
function getLimit(year) {
const y = Number(year);
if (!y) return null;
let match = null;
for (const row of rows()) {
if (row.tax_year <= y) match = row;
else break;
}
return match;
}
function fromRow(row) {
return {
id: row.id,
tax_year: row.tax_year,
base_limit: row.base_limit,
phaseout_threshold: row.phaseout_threshold
};
}
function list() {
return all('SELECT * FROM section_179_limits ORDER BY tax_year DESC').map(fromRow);
}
function normalize(body) {
return {
tax_year: Math.trunc(Number(body.tax_year)) || 0,
base_limit: Number(body.base_limit) || 0,
phaseout_threshold: Number(body.phaseout_threshold) || 0
};
}
function createLimit(body, userId) {
const input = normalize(body);
if (!input.tax_year) throw Object.assign(new Error('A valid tax year is required'), { status: 400 });
if (one('SELECT id FROM section_179_limits WHERE tax_year = ?', [input.tax_year])) {
throw Object.assign(new Error('A limit for that tax year already exists'), { status: 400 });
}
const ts = now();
run(
`INSERT INTO section_179_limits (tax_year, base_limit, phaseout_threshold, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
[input.tax_year, input.base_limit, input.phaseout_threshold, ts, ts]
);
clearCache();
audit(userId, 'create', 'section_179_limit', String(input.tax_year), null, input);
return fromRow(one('SELECT * FROM section_179_limits WHERE tax_year = ?', [input.tax_year]));
}
function updateLimit(taxYear, body, userId) {
const before = one('SELECT * FROM section_179_limits WHERE tax_year = ?', [Number(taxYear)]);
if (!before) return null;
const input = normalize({ ...before, ...body, tax_year: before.tax_year });
run(
'UPDATE section_179_limits SET base_limit = ?, phaseout_threshold = ?, updated_at = ? WHERE tax_year = ?',
[input.base_limit, input.phaseout_threshold, now(), before.tax_year]
);
clearCache();
audit(userId, 'update', 'section_179_limit', String(before.tax_year), before, input);
return fromRow(one('SELECT * FROM section_179_limits WHERE tax_year = ?', [before.tax_year]));
}
function deleteLimit(taxYear, userId) {
const before = one('SELECT * FROM section_179_limits WHERE tax_year = ?', [Number(taxYear)]);
if (!before) return false;
run('DELETE FROM section_179_limits WHERE tax_year = ?', [before.tax_year]);
clearCache();
audit(userId, 'delete', 'section_179_limit', String(before.tax_year), before, null);
return true;
}
module.exports = {
clearCache,
createLimit,
deleteLimit,
getLimit,
list,
updateLimit
};

View File

@@ -37,6 +37,8 @@ function fromRow(row) {
real_property_end: row.real_property_end,
max_recovery_years: row.max_recovery_years,
leasehold_life_years: row.leasehold_life_years,
section_179_increase: row.section_179_increase,
section_179_cost_factor: row.section_179_cost_factor,
notes: row.notes,
enabled: Boolean(row.enabled),
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c
@@ -57,6 +59,8 @@ function normalize(body) {
real_property_end: body.real_property_end || null,
max_recovery_years: num(body.max_recovery_years),
leasehold_life_years: num(body.leasehold_life_years),
section_179_increase: Number(body.section_179_increase) || 0,
section_179_cost_factor: body.section_179_cost_factor === '' || body.section_179_cost_factor === null || body.section_179_cost_factor === undefined ? 1 : Number(body.section_179_cost_factor),
notes: body.notes || null,
enabled: body.enabled === false ? 0 : 1
};
@@ -72,9 +76,9 @@ function createZone(body, userId) {
}
const ts = now();
run(
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, notes, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.notes, input.enabled, ts, ts]
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, section_179_increase, section_179_cost_factor, notes, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.notes, input.enabled, ts, ts]
);
clearCache();
audit(userId, 'create', 'depreciation_zone', code, null, input);
@@ -87,8 +91,8 @@ function updateZone(code, body, userId) {
const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled });
run(
`UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?,
max_recovery_years = ?, leasehold_life_years = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`,
[input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.notes, input.enabled, now(), code]
max_recovery_years = ?, leasehold_life_years = ?, section_179_increase = ?, section_179_cost_factor = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`,
[input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.notes, input.enabled, now(), code]
);
clearCache();
audit(userId, 'update', 'depreciation_zone', code, before, input);

View File

@@ -911,6 +911,44 @@ async function main() {
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
if (outYear1 !== 2000) throw new Error(`Out-of-window asset should get plain MACRS year-1 (2000), got ${outYear1}`);
// Section 179 limits + Enterprise Zone: the seeded 2015 limit ($500,000) caps a plain asset's
// §179, but an Enterprise Zone asset gets the +$35,000 increase, letting a larger §179 through.
const limitList = await request('/api/section-179-limits', { headers: auth });
if (!limitList.limits.some((l) => l.tax_year === 2015 && l.base_limit === 500000)) {
throw new Error('2015 §179 limit was not seeded');
}
const ezList = await request('/api/depreciation-zones', { headers: auth });
const ez = ezList.zones.find((z) => z.code === 'enterprise_zone');
if (!ez || ez.section_179_increase !== 35000 || ez.section_179_cost_factor !== 0.5) {
throw new Error('Enterprise Zone was not seeded with the §179 increase/cost factor');
}
// Plain asset (no zone): §179 entered 520k is capped at the 500k base limit. Straight-line, 5yr,
// half-year → basis 100k, year-1 SL 10k → year-1 = 500000 + 10000 = 510000.
const s179Plain = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `S179-${suffix}`, description: '§179 capped', category: 'Computer Equipment',
acquisition_cost: 600000, in_service_date: '2015-01-01', useful_life_months: 60,
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 520000, cost: 600000 }]
})
});
const plainY1 = round((await request(`/api/assets/${s179Plain.asset.id}/depreciation?startYear=2015&endYear=2015`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
if (plainY1 !== 510000) throw new Error(`§179 should cap at the 500k base limit (year-1 510000), got ${plainY1}`);
// Enterprise Zone asset: limit 500k + 35k = 535k, so the full 520k §179 applies. basis 80k,
// year-1 SL 8k → year-1 = 520000 + 8000 = 528000.
const s179Ez = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `S179EZ-${suffix}`, description: '§179 enterprise zone', category: 'Computer Equipment',
acquisition_cost: 600000, in_service_date: '2015-01-01', useful_life_months: 60, special_zone: 'enterprise_zone',
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 520000, cost: 600000 }]
})
});
const ezY1 = round((await request(`/api/assets/${s179Ez.asset.id}/depreciation?startYear=2015&endYear=2015`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
if (ezY1 !== 528000) throw new Error(`Enterprise Zone §179 increase should allow 520k (year-1 528000), got ${ezY1}`);
// Disposal: preview gain/loss, then record a full disposal.
const disposalAsset = await request('/api/assets', {
method: 'POST',