Added Section 179 Limits
This commit is contained in:
39
server/db.js
39
server/db.js
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
95
server/services/section179Limits.js
Normal file
95
server/services/section179Limits.js
Normal 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
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user