From dfd999b6a938a0348b7c490990d009bf64bfb422 Mon Sep 17 00:00:00 2001 From: mpuckett Date: Fri, 5 Jun 2026 05:55:43 -0500 Subject: [PATCH] Added Section 179 Limits --- server/db.js | 39 +++++ server/depreciation.js | 41 ++++- server/routes/reference.js | 30 ++++ server/services/section179Limits.js | 95 ++++++++++++ server/services/zones.js | 14 +- server/smoke-test.js | 38 +++++ src/components/AssetDrawer.vue | 14 +- src/components/DepreciationZoneSettings.vue | 19 ++- src/components/Section179LimitSettings.vue | 162 ++++++++++++++++++++ src/components/UserManual.vue | 13 +- src/views/AdminView.vue | 5 +- 11 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 server/services/section179Limits.js create mode 100644 src/components/Section179LimitSettings.vue diff --git a/server/db.js b/server/db.js index 357448d..e632f08 100644 --- a/server/db.js +++ b/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', diff --git a/server/depreciation.js b/server/depreciation.js index 061511c..f694e47 100644 --- a/server/depreciation.js +++ b/server/depreciation.js @@ -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 diff --git a/server/routes/reference.js b/server/routes/reference.js index 580cb83..acdf758 100644 --- a/server/routes/reference.js +++ b/server/routes/reference.js @@ -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) => { diff --git a/server/services/section179Limits.js b/server/services/section179Limits.js new file mode 100644 index 0000000..f7530d7 --- /dev/null +++ b/server/services/section179Limits.js @@ -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 +}; diff --git a/server/services/zones.js b/server/services/zones.js index ada1d9a..1da626c 100644 --- a/server/services/zones.js +++ b/server/services/zones.js @@ -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); diff --git a/server/smoke-test.js b/server/smoke-test.js index 9210e7c..0ca23b5 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -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', diff --git a/src/components/AssetDrawer.vue b/src/components/AssetDrawer.vue index 18511b6..22234bf 100644 --- a/src/components/AssetDrawer.vue +++ b/src/components/AssetDrawer.vue @@ -748,10 +748,18 @@ export default { if (!zone) return ''; const pis = this.localAsset.in_service_date || this.localAsset.acquired_date || ''; const inWindow = (!zone.pis_start || !pis || pis >= zone.pis_start) && (!zone.pis_end || !pis || pis <= zone.pis_end); - const base = `${zone.name}: ${zone.allowance_percent}% §168 allowance applied to the FEDERAL book` - + `${zone.pis_start || zone.pis_end ? ` for assets placed in service ${zone.pis_start || '…'} to ${zone.pis_end || '…'}` : ''}.`; + const benefits = []; + if (Number(zone.allowance_percent) > 0) benefits.push(`a ${zone.allowance_percent}% §168 special allowance`); + if (Number(zone.section_179_increase) > 0) { + const factor = Number(zone.section_179_cost_factor); + const phaseout = factor && factor !== 1 ? ` (only ${Math.round(factor * 100)}% of cost counts toward the §179 phaseout)` : ''; + benefits.push(`an increased §179 limit of +$${Number(zone.section_179_increase).toLocaleString()}${phaseout}`); + } + if (!benefits.length) benefits.push('special depreciation treatment'); + const window = zone.pis_start || zone.pis_end ? ` for assets placed in service ${zone.pis_start || '…'} to ${zone.pis_end || '…'}` : ''; + const base = `${zone.name}: ${benefits.join(' and ')} on the FEDERAL book${window}.`; const lease = zone.leasehold_life_years ? ` Qualified leasehold improvements use a ${zone.leasehold_life_years}-year life.` : ''; - if (pis && !inWindow) return `${base} This asset's placed-in-service date is OUTSIDE the window, so the allowance will not apply.${lease}`; + if (pis && !inWindow) return `${base} This asset's placed-in-service date is OUTSIDE the window, so it will not apply.${lease}`; return `${base}${lease}`; }, changedCriticalLabels() { diff --git a/src/components/DepreciationZoneSettings.vue b/src/components/DepreciationZoneSettings.vue index 779a132..e658bbe 100644 --- a/src/components/DepreciationZoneSettings.vue +++ b/src/components/DepreciationZoneSettings.vue @@ -4,8 +4,9 @@

Depreciation zones

- Special-allowance zones (e.g. New York Liberty Zone). Assign one to an asset and the engine applies the zone’s §168 - allowance to the federal book for assets placed in service within the date window. + Special-allowance zones (e.g. New York Liberty Zone, Enterprise/Empowerment Zone). Assign one to an asset and the engine + applies the zone’s §168 allowance and/or increased §179 limit to the federal book for assets placed in service within the + date window.

@@ -27,6 +28,7 @@ Disabled +