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 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 ( CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER REFERENCES users(id), actor_id INTEGER REFERENCES users(id),
@@ -694,6 +703,8 @@ function migrate() {
ensureColumn('assets', 'servicenow_sys_id', 'TEXT'); ensureColumn('assets', 'servicenow_sys_id', 'TEXT');
ensureColumn('assets', 'asset_class_number', 'TEXT'); ensureColumn('assets', 'asset_class_number', 'TEXT');
ensureColumn('assets', 'special_zone', '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(); 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, ?, ?)`, '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] [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')) { if (!one('SELECT id FROM teams LIMIT 1')) {
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [ run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
'Finance Operations', 'Finance Operations',

View File

@@ -1,6 +1,7 @@
const { parseJson } = require('./db'); const { parseJson } = require('./db');
const { buildDepreciationMethods } = require('./rule-catalog'); const { buildDepreciationMethods } = require('./rule-catalog');
const { getZone } = require('./services/zones'); 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 // 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. // 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) { 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 // 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 { formatAssetId } = require('../services/assets');
const assetClasses = require('../services/assetClasses'); const assetClasses = require('../services/assetClasses');
const zones = require('../services/zones'); const zones = require('../services/zones');
const section179Limits = require('../services/section179Limits');
const router = express.Router(); const router = express.Router();
@@ -36,6 +37,35 @@ router.delete('/depreciation-zones/:code', requireCapability('config.manage'), (
return res.status(204).end(); 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 // Asset class catalog (seeded from the IRS tables, then user-managed). GET is reference
// data used by the category picker; mutations require config.manage. // data used by the category picker; mutations require config.manage.
router.get('/asset-classes', (req, res) => { 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, real_property_end: row.real_property_end,
max_recovery_years: row.max_recovery_years, max_recovery_years: row.max_recovery_years,
leasehold_life_years: row.leasehold_life_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, notes: row.notes,
enabled: Boolean(row.enabled), enabled: Boolean(row.enabled),
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c 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, real_property_end: body.real_property_end || null,
max_recovery_years: num(body.max_recovery_years), max_recovery_years: num(body.max_recovery_years),
leasehold_life_years: num(body.leasehold_life_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, notes: body.notes || null,
enabled: body.enabled === false ? 0 : 1 enabled: body.enabled === false ? 0 : 1
}; };
@@ -72,9 +76,9 @@ function createZone(body, userId) {
} }
const ts = now(); const ts = now();
run( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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] [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(); clearCache();
audit(userId, 'create', 'depreciation_zone', code, null, input); 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 }); const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled });
run( run(
`UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?, `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 = ?`, 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.notes, input.enabled, now(), 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(); clearCache();
audit(userId, 'update', 'depreciation_zone', code, before, input); 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); .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}`); 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. // Disposal: preview gain/loss, then record a full disposal.
const disposalAsset = await request('/api/assets', { const disposalAsset = await request('/api/assets', {
method: 'POST', method: 'POST',

View File

@@ -748,10 +748,18 @@ export default {
if (!zone) return ''; if (!zone) return '';
const pis = this.localAsset.in_service_date || this.localAsset.acquired_date || ''; 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 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` const benefits = [];
+ `${zone.pis_start || zone.pis_end ? ` for assets placed in service ${zone.pis_start || '…'} to ${zone.pis_end || '…'}` : ''}.`; 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.` : ''; 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}`; return `${base}${lease}`;
}, },
changedCriticalLabels() { changedCriticalLabels() {

View File

@@ -4,8 +4,9 @@
<div> <div>
<h2 class="section-title">Depreciation zones</h2> <h2 class="section-title">Depreciation zones</h2>
<p class="quiet text-body-2"> <p class="quiet text-body-2">
Special-allowance zones (e.g. New York Liberty Zone). Assign one to an asset and the engine applies the zones §168 Special-allowance zones (e.g. New York Liberty Zone, Enterprise/Empowerment Zone). Assign one to an asset and the engine
allowance to the federal book for assets placed in service within the date window. applies the zones §168 allowance and/or increased §179 limit to the federal book for assets placed in service within the
date window.
</p> </p>
</div> </div>
<v-spacer /> <v-spacer />
@@ -27,6 +28,7 @@
<v-chip v-if="!row.enabled" size="x-small" variant="tonal" class="ml-2">Disabled</v-chip> <v-chip v-if="!row.enabled" size="x-small" variant="tonal" class="ml-2">Disabled</v-chip>
</template> </template>
<template #cell-allowance_percent="{ row }">{{ row.allowance_percent }}%</template> <template #cell-allowance_percent="{ row }">{{ row.allowance_percent }}%</template>
<template #cell-section_179_increase="{ row }">{{ row.section_179_increase ? `+$${Number(row.section_179_increase).toLocaleString()}` : '—' }}</template>
<template #cell-window="{ row }">{{ row.pis_start || '—' }} {{ row.pis_end || '—' }}</template> <template #cell-window="{ row }">{{ row.pis_start || '—' }} {{ row.pis_end || '—' }}</template>
<template #actions="{ row }"> <template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" /> <v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
@@ -49,6 +51,8 @@
<v-text-field v-model="form.real_property_end" type="date" label="Real-property PIS end (optional)" /> <v-text-field v-model="form.real_property_end" type="date" label="Real-property PIS end (optional)" />
<v-text-field v-model.number="form.max_recovery_years" type="number" label="Max recovery years (optional)" /> <v-text-field v-model.number="form.max_recovery_years" type="number" label="Max recovery years (optional)" />
<v-text-field v-model.number="form.leasehold_life_years" type="number" label="Leasehold improvement life (yrs, optional)" /> <v-text-field v-model.number="form.leasehold_life_years" type="number" label="Leasehold improvement life (yrs, optional)" />
<v-text-field v-model.number="form.section_179_increase" type="number" label="§179 limit increase ($)" prefix="$" hint="Enterprise/empowerment zones, e.g. 35000" persistent-hint />
<v-text-field v-model.number="form.section_179_cost_factor" type="number" step="0.01" label="§179 cost factor for phaseout" hint="Share of cost counted toward the threshold (e.g. 0.5)" persistent-hint />
</div> </div>
<v-textarea v-model="form.notes" class="mt-2" label="Notes" rows="3" /> <v-textarea v-model="form.notes" class="mt-2" label="Notes" rows="3" />
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert> <v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
@@ -87,7 +91,8 @@ import { apiRequest } from '../services/api';
function blankForm() { function blankForm() {
return { return {
exists: false, code: '', name: '', allowance_percent: 0, enabled: true, exists: false, code: '', name: '', allowance_percent: 0, enabled: true,
pis_start: '', pis_end: '', real_property_end: '', max_recovery_years: null, leasehold_life_years: null, notes: '' pis_start: '', pis_end: '', real_property_end: '', max_recovery_years: null, leasehold_life_years: null,
section_179_increase: 0, section_179_cost_factor: 1, notes: ''
}; };
} }
@@ -109,7 +114,8 @@ export default {
dialogError: '', dialogError: '',
columns: [ columns: [
{ key: 'name', label: 'Zone' }, { key: 'name', label: 'Zone' },
{ key: 'allowance_percent', label: 'Allowance' }, { key: 'allowance_percent', label: '§168 allowance' },
{ key: 'section_179_increase', label: '§179 increase', sortable: false },
{ key: 'window', label: 'Placed-in-service window', sortable: false }, { key: 'window', label: 'Placed-in-service window', sortable: false },
{ key: 'asset_count', label: 'Assets', format: 'number' } { key: 'asset_count', label: 'Assets', format: 'number' }
] ]
@@ -140,7 +146,10 @@ export default {
this.form = { this.form = {
exists: true, code: row.code, name: row.name, allowance_percent: row.allowance_percent, enabled: row.enabled, exists: true, code: row.code, name: row.name, allowance_percent: row.allowance_percent, enabled: row.enabled,
pis_start: row.pis_start || '', pis_end: row.pis_end || '', real_property_end: row.real_property_end || '', pis_start: row.pis_start || '', pis_end: row.pis_end || '', real_property_end: row.real_property_end || '',
max_recovery_years: row.max_recovery_years, leasehold_life_years: row.leasehold_life_years, notes: row.notes || '' max_recovery_years: row.max_recovery_years, leasehold_life_years: row.leasehold_life_years,
section_179_increase: row.section_179_increase || 0,
section_179_cost_factor: row.section_179_cost_factor === null || row.section_179_cost_factor === undefined ? 1 : row.section_179_cost_factor,
notes: row.notes || ''
}; };
this.dialogError = ''; this.dialogError = '';
this.dialog = true; this.dialog = true;

View File

@@ -0,0 +1,162 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Section 179 limits</h2>
<p class="quiet text-body-2">
Annual §179 deduction limits and investment-phaseout thresholds. The engine caps each assets §179 deduction at the
applicable years limit (less any dollar-for-dollar phaseout). Enterprise/empowerment-zone assets get the increased limit
configured on their depreciation zone.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New year</v-btn>
</div>
<DataTable
:columns="columns"
:rows="limits"
row-key="tax_year"
:page-size="10"
has-actions
empty-text="No §179 limits configured deductions are uncapped."
>
<template #cell-base_limit="{ row }">${{ Number(row.base_limit).toLocaleString() }}</template>
<template #cell-phaseout_threshold="{ row }">${{ Number(row.phaseout_threshold).toLocaleString() }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<v-dialog v-model="dialog" max-width="480">
<v-card>
<v-card-title>{{ form.exists ? `Edit ${form.tax_year}` : 'New §179 limit year' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model.number="form.tax_year" type="number" label="Tax year *" :disabled="form.exists" />
<v-text-field v-model.number="form.base_limit" type="number" label="Base deduction limit ($)" prefix="$" />
<v-text-field v-model.number="form.phaseout_threshold" type="number" label="Investment phaseout threshold ($)" prefix="$" />
</div>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.tax_year" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog" max-width="440">
<v-card>
<v-card-title class="text-error">Delete limit</v-card-title>
<v-card-text>
<p>Remove the §179 limit for <strong>{{ deleteTarget?.tax_year }}</strong>? Assets placed in service that year fall back to
the nearest earlier configured year (or become uncapped).</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
function blankForm() {
return { exists: false, tax_year: new Date().getFullYear(), base_limit: 0, phaseout_threshold: 0 };
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
limits: [],
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: blankForm(),
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'tax_year', label: 'Tax year' },
{ key: 'base_limit', label: 'Base deduction limit' },
{ key: 'phaseout_threshold', label: 'Phaseout threshold' }
]
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/section-179-limits')).json();
this.limits = data.limits;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankForm();
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = { exists: true, tax_year: row.tax_year, base_limit: row.base_limit, phaseout_threshold: row.phaseout_threshold };
this.dialogError = '';
this.dialog = true;
},
async save() {
if (!this.form.tax_year) return;
this.saving = true;
this.dialogError = '';
try {
const method = this.form.exists ? 'PUT' : 'POST';
const url = this.form.exists ? `/api/section-179-limits/${this.form.tax_year}` : '/api/section-179-limits';
await this.api(url, { method, body: JSON.stringify(this.form) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
async performDelete() {
const target = this.deleteTarget;
this.deleteDialog = false;
this.deleteTarget = null;
if (!target) return;
try {
await this.api(`/api/section-179-limits/${target.tax_year}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>

View File

@@ -291,6 +291,17 @@
and any leasehold-improvement life. Zones are managed in <strong>Admin Application Configuration Depreciation zones</strong> and any leasehold-improvement life. Zones are managed in <strong>Admin Application Configuration Depreciation zones</strong>
(create your own, set the allowance %, date window, and notes). (create your own, set the allowance %, date window, and notes).
</p> </p>
<p>
A zone can instead (or also) carry an <strong>increased §179 limit</strong> this is how
<strong>Enterprise / Empowerment Zone</strong> property is handled. Set the zones <em>§179 limit increase</em>
(e.g. +$35,000 for 20022020) and its <em>§179 cost factor</em> (e.g. 0.5, meaning only 50% of the propertys cost counts
toward the investment-phaseout threshold). When such a zone is assigned to an asset placed in service within the window,
the federal books §179 deduction may exceed the standard annual limit by that increase. The base annual §179 deduction
limits and phaseout thresholds themselves are maintained in
<strong>Admin Application Configuration Section 179 limits</strong>; the engine caps each assets §179 deduction at the
applicable years limit (less any dollar-for-dollar phaseout). If no limit is configured for an assets placed-in-service
year, the entered §179 amount is left uncapped.
</p>
<h3>The PM (maintenance) book</h3> <h3>The PM (maintenance) book</h3>
<p> <p>
@@ -620,7 +631,7 @@
<h2>Administration</h2> <h2>Administration</h2>
<p> <p>
<strong>Admin</strong> (admin role) is where the application is configured. It is split into three sub-screens in the <strong>Admin</strong> (admin role) is where the application is configured. It is split into three sub-screens in the
navigation rail: <strong>Users &amp; Teams</strong>, <strong>Application Configuration</strong> (application settings, asset classes, depreciation zones, asset ID templates, asset categories, navigation rail: <strong>Users &amp; Teams</strong>, <strong>Application Configuration</strong> (application settings, asset classes, depreciation zones, Section 179 limits, asset ID templates, asset categories,
asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and <strong>Integrations</strong> (email, webhook, ServiceNow, Workday). Each is described below. asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and <strong>Integrations</strong> (email, webhook, ServiceNow, Workday). Each is described below.
</p> </p>

View File

@@ -172,6 +172,8 @@
<DepreciationZoneSettings :token="token" /> <DepreciationZoneSettings :token="token" />
<Section179LimitSettings :token="token" />
<AssetIdTemplateSettings :token="token" /> <AssetIdTemplateSettings :token="token" />
<CategorySettings :token="token" @updated="$emit('categories-updated')" /> <CategorySettings :token="token" @updated="$emit('categories-updated')" />
@@ -280,6 +282,7 @@ import CategorySettings from '../components/CategorySettings.vue';
import DataTable from '../components/DataTable.vue'; import DataTable from '../components/DataTable.vue';
import DepartmentSettings from '../components/DepartmentSettings.vue'; import DepartmentSettings from '../components/DepartmentSettings.vue';
import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue'; import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue';
import Section179LimitSettings from '../components/Section179LimitSettings.vue';
import NotificationSettings from '../components/NotificationSettings.vue'; import NotificationSettings from '../components/NotificationSettings.vue';
import PartCategorySettings from '../components/PartCategorySettings.vue'; import PartCategorySettings from '../components/PartCategorySettings.vue';
import PmCategorySettings from '../components/PmCategorySettings.vue'; import PmCategorySettings from '../components/PmCategorySettings.vue';
@@ -288,7 +291,7 @@ import ServiceNowSettings from '../components/ServiceNowSettings.vue';
import WebhookSettings from '../components/WebhookSettings.vue'; import WebhookSettings from '../components/WebhookSettings.vue';
export default { export default {
components: { AssetClassSettings, AssetIdTemplateSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PmCategorySettings, PmSettings, ServiceNowSettings, WebhookSettings }, components: { AssetClassSettings, AssetIdTemplateSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, WebhookSettings },
props: { props: {
token: { type: String, default: '' }, token: { type: String, default: '' },
section: { type: String, default: 'admin-users' }, section: { type: String, default: 'admin-users' },