Pub 946 Compliance
This commit is contained in:
58
server/db.js
58
server/db.js
@@ -358,6 +358,32 @@ function initialize() {
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- §280F passenger-automobile annual depreciation caps (IRS Rev. Proc., indexed yearly).
|
||||||
|
CREATE TABLE IF NOT EXISTS section_280f_limits (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pis_year INTEGER NOT NULL UNIQUE,
|
||||||
|
year1_no_bonus REAL NOT NULL DEFAULT 0,
|
||||||
|
year1_bonus REAL NOT NULL DEFAULT 0,
|
||||||
|
year2 REAL NOT NULL DEFAULT 0,
|
||||||
|
year3 REAL NOT NULL DEFAULT 0,
|
||||||
|
year4plus REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- §179 taxable-income limitation: per company+book+year entered income and computed carryover.
|
||||||
|
CREATE TABLE IF NOT EXISTS section_179_carryover (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
|
book_code TEXT NOT NULL DEFAULT 'FEDERAL',
|
||||||
|
tax_year INTEGER NOT NULL,
|
||||||
|
taxable_income REAL,
|
||||||
|
carryover_to_next REAL NOT NULL DEFAULT 0,
|
||||||
|
updated_by INTEGER REFERENCES users(id),
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(entity_id, book_code, tax_year)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
@@ -889,6 +915,17 @@ function migrate() {
|
|||||||
ensureColumn('gl_account_defaults', 'proceeds_account', 'TEXT');
|
ensureColumn('gl_account_defaults', 'proceeds_account', 'TEXT');
|
||||||
ensureColumn('gl_account_defaults', 'retained_earnings_account', 'TEXT');
|
ensureColumn('gl_account_defaults', 'retained_earnings_account', 'TEXT');
|
||||||
ensureColumn('entities', 'capitalization_threshold', 'REAL NOT NULL DEFAULT 0');
|
ensureColumn('entities', 'capitalization_threshold', 'REAL NOT NULL DEFAULT 0');
|
||||||
|
// §280F: flag passenger automobiles subject to the annual luxury-auto depreciation caps.
|
||||||
|
ensureColumn('assets', 'passenger_auto', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
// Depreciation-recapture characterization stored on disposals (Form 4797).
|
||||||
|
ensureColumn('disposals', 'recapture_section', 'TEXT');
|
||||||
|
ensureColumn('disposals', 'ordinary_recapture', 'REAL NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn('disposals', 'section_1231_gain', 'REAL NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn('disposals', 'unrecaptured_1250_gain', 'REAL NOT NULL DEFAULT 0');
|
||||||
|
// Refresh stale §179 limits seeded before the 2025 OBBBA increase (only touch the old seed values,
|
||||||
|
// never a figure an administrator has deliberately raised). 2026 per IRS Pub 946 (2025 ed.).
|
||||||
|
run('UPDATE section_179_limits SET base_limit = 2500000, phaseout_threshold = 4000000 WHERE tax_year = 2025 AND base_limit < 2000000');
|
||||||
|
run('UPDATE section_179_limits SET base_limit = 2560000, phaseout_threshold = 4090000 WHERE tax_year = 2026 AND base_limit < 2000000');
|
||||||
backfillCompanyScope();
|
backfillCompanyScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,8 +1282,9 @@ function seed() {
|
|||||||
[2011, 500000, 2000000], [2012, 500000, 2000000], [2013, 500000, 2000000],
|
[2011, 500000, 2000000], [2012, 500000, 2000000], [2013, 500000, 2000000],
|
||||||
[2014, 500000, 2000000], [2015, 500000, 2000000], [2016, 500000, 2010000], [2017, 510000, 2030000],
|
[2014, 500000, 2000000], [2015, 500000, 2000000], [2016, 500000, 2010000], [2017, 510000, 2030000],
|
||||||
[2018, 1000000, 2500000], [2019, 1020000, 2550000], [2020, 1040000, 2590000], [2021, 1050000, 2620000],
|
[2018, 1000000, 2500000], [2019, 1020000, 2550000], [2020, 1040000, 2590000], [2021, 1050000, 2620000],
|
||||||
[2022, 1080000, 2700000], [2023, 1160000, 2890000], [2024, 1220000, 3050000], [2025, 1250000, 3130000],
|
[2022, 1080000, 2700000], [2023, 1160000, 2890000], [2024, 1220000, 3050000],
|
||||||
[2026, 1250000, 3130000]
|
// 2025+ reflect the 2025 OBBBA increase; 2026 per IRS Pub 946 (2025 ed.), "What's New for 2026".
|
||||||
|
[2025, 2500000, 4000000], [2026, 2560000, 4090000]
|
||||||
];
|
];
|
||||||
for (const [year, limit, threshold] of section179Limits) {
|
for (const [year, limit, threshold] of section179Limits) {
|
||||||
run(
|
run(
|
||||||
@@ -1255,6 +1293,22 @@ function seed() {
|
|||||||
[year, limit, threshold, createdAt, createdAt]
|
[year, limit, threshold, createdAt, createdAt]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// §280F passenger-automobile depreciation caps [year, y1-no-bonus, y1-bonus, y2, y3, y4+]. Published
|
||||||
|
// IRS figures (Rev. Proc. 2023-14 / 2024-13); out-years are estimates — these are editable data.
|
||||||
|
const section280fLimits = [
|
||||||
|
[2022, 11200, 19200, 18000, 10800, 6460],
|
||||||
|
[2023, 12200, 20200, 19500, 11700, 6960],
|
||||||
|
[2024, 12400, 20400, 19800, 11900, 7160],
|
||||||
|
[2025, 12600, 20600, 20200, 12100, 7260],
|
||||||
|
[2026, 12800, 20800, 20600, 12300, 7360]
|
||||||
|
];
|
||||||
|
for (const [year, y1nb, y1b, y2, y3, y4] of section280fLimits) {
|
||||||
|
run(
|
||||||
|
`INSERT OR IGNORE INTO section_280f_limits (pis_year, year1_no_bonus, year1_bonus, year2, year3, year4plus, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[year, y1nb, y1b, y2, y3, y4, createdAt, createdAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!one('SELECT id FROM teams LIMIT 1')) {
|
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',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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');
|
const { getLimit: getSection179Limit } = require('./services/section179Limits');
|
||||||
|
const { getLimit: getSection280fLimit } = require('./services/section280fLimits');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
console.log(`${moment().format()} ✅ Depreciation module loaded with dependencies: db, rule-catalog, zones, section179Limits`);
|
console.log(`${moment().format()} ✅ Depreciation module loaded with dependencies: db, rule-catalog, zones, section179Limits`);
|
||||||
|
|
||||||
@@ -212,10 +213,75 @@ function totalDepreciableBasis(asset, book) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the applicable depreciation convention for a book, based on the method rule's defaultConvention, falling back to the book's convention, and defaulting to 'none' when neither is set.
|
// Is this MACRS personal property (eligible for the mid-quarter 40% test)? Real property uses the
|
||||||
function conventionFor(book, methodRule) {
|
// mid-month convention and is excluded; only otherwise-half-year MACRS property can switch to mid-quarter.
|
||||||
console.log(`${moment().format()} 🕒 Determining depreciation convention for book ${book.id}`);
|
function isMidQuarterEligible(book, methodRule) {
|
||||||
return methodRule.defaultConvention || book.convention || 'none';
|
return Boolean(methodRule) && methodRule.family === 'MACRS'
|
||||||
|
&& (methodRule.defaultConvention || book.convention) === 'half_year';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unadjusted depreciable basis used by the mid-quarter 40% test: post-§179, pre-bonus (IRS Pub 946).
|
||||||
|
function midQuarterBasis(asset, book) {
|
||||||
|
return Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
|
||||||
|
}
|
||||||
|
|
||||||
|
// §280F: listed property used 50% or less for qualified business use must be depreciated under ADS
|
||||||
|
// (straight-line) and may not claim §179 or the special (bonus) allowance. Applies to MACRS tax books.
|
||||||
|
function listedAdsRequired(asset, book, methodRule) {
|
||||||
|
if (!asset.listed_property || !methodRule || methodRule.family !== 'MACRS') return false;
|
||||||
|
const use = businessUseFactor(asset, book);
|
||||||
|
return use > 0 && use <= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §280F passenger-automobile annual depreciation cap for a given recovery year (0-based index), reduced
|
||||||
|
// by business-use percentage. Returns Infinity when the asset isn't a flagged passenger auto or no cap
|
||||||
|
// table is configured for the placed-in-service year (leaving depreciation uncapped).
|
||||||
|
function section280fCap(asset, book, index) {
|
||||||
|
if (!asset.passenger_auto) return Infinity;
|
||||||
|
const row = getSection280fLimit(yearFromDate(asset.in_service_date || asset.acquired_date));
|
||||||
|
if (!row) return Infinity;
|
||||||
|
let base;
|
||||||
|
if (index <= 0) base = bonusAmount(asset, book) > 0 ? row.year1_bonus : row.year1_no_bonus;
|
||||||
|
else if (index === 1) base = row.year2;
|
||||||
|
else if (index === 2) base = row.year3;
|
||||||
|
else base = row.year4plus;
|
||||||
|
return Math.max(0, Number(base) || 0) * businessUseFactor(asset, book);
|
||||||
|
}
|
||||||
|
|
||||||
|
// §280F recapture preview: if a listed/passenger asset's qualified business use drops to ≤50% in
|
||||||
|
// `year`, the excess of accelerated depreciation actually claimed (through the prior year) over what ADS
|
||||||
|
// straight-line would have allowed is recaptured as ordinary income. Computed from the asset as it
|
||||||
|
// currently stands (i.e., its current business use is treated as the prior-years' use).
|
||||||
|
function section280fRecapture(asset, book, ruleSet, year) {
|
||||||
|
if (!asset.listed_property && !asset.passenger_auto) return { applies: false, recapture: 0 };
|
||||||
|
const through = Number(year) - 1;
|
||||||
|
const accel = annualSchedule(asset, book, ruleSet, { startYear: through, endYear: through, ignoreListedAds: true })[0] || {};
|
||||||
|
const adsBook = { ...book, depreciation_method: 'straight_line', section_179_amount: 0, bonus_percent: 0 };
|
||||||
|
const ads = annualSchedule(asset, adsBook, ruleSet, { startYear: through, endYear: through })[0] || {};
|
||||||
|
const acceleratedAccum = money(Number(accel.accumulated || 0));
|
||||||
|
const adsAccum = money(Number(ads.accumulated || 0));
|
||||||
|
return {
|
||||||
|
applies: true,
|
||||||
|
conversion_year: Number(year),
|
||||||
|
accelerated_accumulated: acceleratedAccum,
|
||||||
|
ads_accumulated: adsAccum,
|
||||||
|
recapture: Math.max(0, money(acceleratedAccum - adsAccum))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the applicable depreciation convention. MACRS mandates the mid-quarter convention when more
|
||||||
|
// than 40% of the year's MACRS personal-property basis is placed in service in the last quarter (IRS
|
||||||
|
// Pub 946). The engine determines this automatically and substitutes it for otherwise-half-year property.
|
||||||
|
function conventionFor(book, methodRule, asset) {
|
||||||
|
let convention = methodRule.defaultConvention || book.convention || 'none';
|
||||||
|
if (convention === 'half_year' && asset && isMidQuarterEligible(book, methodRule)) {
|
||||||
|
const pisYear = yearFromDate(asset.in_service_date || asset.acquired_date);
|
||||||
|
// Lazy require avoids a load-time cycle (the service reads back basis helpers from this module).
|
||||||
|
if (require('./services/midQuarter').applies(asset.entity_id, book.book_type, pisYear)) {
|
||||||
|
convention = 'mid_quarter';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return convention;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the year fraction for a given year index based on the specified convention. The index is 0-based, so 0 corresponds to the placed-in-service year. Returns a fraction between 0 and 1 representing how much of the year's depreciation should be taken in that year, or null when the convention doesn't specify an explicit fraction for that year (e.g. for mid-month, which only has a special rule for the first year).
|
// Calculate the year fraction for a given year index based on the specified convention. The index is 0-based, so 0 corresponds to the placed-in-service year. Returns a fraction between 0 and 1 representing how much of the year's depreciation should be taken in that year, or null when the convention doesn't specify an explicit fraction for that year (e.g. for mid-month, which only has a special rule for the first year).
|
||||||
@@ -311,7 +377,7 @@ function straightLine(asset, book, index, totalYears, methodRule) {
|
|||||||
console.log(`${moment().format()} 🕒 Calculating straight-line depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}`);
|
console.log(`${moment().format()} 🕒 Calculating straight-line depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}`);
|
||||||
const basis = depreciableBasis(asset, book, methodRule);
|
const basis = depreciableBasis(asset, book, methodRule);
|
||||||
console.log(`${moment().format()} 🕒 Depreciable basis for straight-line calculation is ${basis} for asset ${asset.id}`);
|
console.log(`${moment().format()} 🕒 Depreciable basis for straight-line calculation is ${basis} for asset ${asset.id}`);
|
||||||
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
|
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule, asset), asset, totalYears);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum-of-years-digits depreciation: (basis * (remaining years / sum of years)) * convention fraction. The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods.
|
// Sum-of-years-digits depreciation: (basis * (remaining years / sum of years)) * convention fraction. The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods.
|
||||||
@@ -332,7 +398,7 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule
|
|||||||
const salvage = salvageValue(asset, book, methodRule);
|
const salvage = salvageValue(asset, book, methodRule);
|
||||||
let bookValue = start;
|
let bookValue = start;
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
const convention = conventionFor(book, methodRule);
|
const convention = conventionFor(book, methodRule, asset);
|
||||||
|
|
||||||
for (let i = 0; i <= index; i += 1) {
|
for (let i = 0; i <= index; i += 1) {
|
||||||
// Note: for each year up to and including the target index, calculate the declining balance amount based on the current book value, multiplier, total years, and convention fraction; if the method has a switchToStraightLine flag, also calculate the straight-line amount based on the remaining recoverable basis and remaining life, and use whichever is greater; then reduce the book value by the selected amount and accumulate the elapsed years based on the convention fraction. For the target index, return the selected amount; for prior indices, just update the book value and elapsed years for the next iteration. If we exceed the total years before reaching the target index, return 0 since no depreciation should be taken.
|
// Note: for each year up to and including the target index, calculate the declining balance amount based on the current book value, multiplier, total years, and convention fraction; if the method has a switchToStraightLine flag, also calculate the straight-line amount based on the remaining recoverable basis and remaining life, and use whichever is greater; then reduce the book value by the selected amount and accumulate the elapsed years based on the convention fraction. For the target index, return the selected amount; for prior indices, just update the book value and elapsed years for the next iteration. If we exceed the total years before reaching the target index, return 0 since no depreciation should be taken.
|
||||||
@@ -390,7 +456,14 @@ function getMethodRule(ruleSet, code) {
|
|||||||
// Generate an annual depreciation schedule for a given asset and book based on the applicable method rule, conventions, and special treatments. The schedule includes rows for each year from the placed-in-service year to the end year (which is determined by the useful life), with columns for the year, book type, method code and label, depreciation amount, accumulated depreciation, and net book value. The calculation applies Section 179 and bonus amounts in the first year, uses the method rule's formula to calculate depreciation for each year, and ensures that depreciation does not exceed the recoverable basis of the asset.
|
// Generate an annual depreciation schedule for a given asset and book based on the applicable method rule, conventions, and special treatments. The schedule includes rows for each year from the placed-in-service year to the end year (which is determined by the useful life), with columns for the year, book type, method code and label, depreciation amount, accumulated depreciation, and net book value. The calculation applies Section 179 and bonus amounts in the first year, uses the method rule's formula to calculate depreciation for each year, and ensures that depreciation does not exceed the recoverable basis of the asset.
|
||||||
function annualSchedule(asset, book, ruleSet, options = {}) {
|
function annualSchedule(asset, book, ruleSet, options = {}) {
|
||||||
console.log(`${moment().format()} 🕒 Generating annual depreciation schedule for asset ${asset.id}, book ${book.id}`);
|
console.log(`${moment().format()} 🕒 Generating annual depreciation schedule for asset ${asset.id}, book ${book.id}`);
|
||||||
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
|
let methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
|
||||||
|
// §280F: listed property at ≤50% qualified business use is forced to ADS straight-line with no §179
|
||||||
|
// or bonus allowance (the ADS recovery period uses the asset's configured life). `ignoreListedAds`
|
||||||
|
// computes the as-if-accelerated path used by the §280F recapture preview.
|
||||||
|
if (!options.ignoreListedAds && listedAdsRequired(asset, book, methodRule)) {
|
||||||
|
book = { ...book, section_179_amount: 0, bonus_percent: 0 };
|
||||||
|
methodRule = { ...methodRule, formula: 'straight_line', label: `${methodRule.label || methodRule.code} (ADS — listed ≤50% use)` };
|
||||||
|
}
|
||||||
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
|
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
|
||||||
if (!(Number(book.bonus_percent) > 0)) {
|
if (!(Number(book.bonus_percent) > 0)) {
|
||||||
// Note: the zone allowance can provide a significant boost to the first-year depreciation through the bonus, so we check for it and apply it as the book's bonus percentage when the book doesn't have its own configured bonus; this allows assets in qualified zones to get the appropriate benefit even when using a book that doesn't have an explicit bonus configured, while still allowing for more flexible handling of cases where a zone allowance is desired but specific books haven't been configured with a bonus percentage.
|
// Note: the zone allowance can provide a significant boost to the first-year depreciation through the bonus, so we check for it and apply it as the book's bonus percentage when the book doesn't have its own configured bonus; this allows assets in qualified zones to get the appropriate benefit even when using a book that doesn't have an explicit bonus configured, while still allowing for more flexible handling of cases where a zone allowance is desired but specific books haven't been configured with a bonus percentage.
|
||||||
@@ -447,6 +520,15 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §280F passenger-automobile annual cap: a luxury auto recovers no more than the yearly limit, and
|
||||||
|
// continues recovering remaining basis at the cap after the normal recovery period ends.
|
||||||
|
if (asset.passenger_auto && index >= 0) {
|
||||||
|
const cap280f = section280fCap(asset, book, index);
|
||||||
|
const remaining = Math.max(0, recoverableBasis(asset, book, methodRule) - accumulated);
|
||||||
|
let allowed = Math.min(depreciation, cap280f);
|
||||||
|
if (allowed <= 0 && remaining > 0) allowed = Math.min(cap280f, remaining);
|
||||||
|
depreciation = allowed;
|
||||||
|
}
|
||||||
const cap = recoverableBasis(asset, book, methodRule);
|
const cap = recoverableBasis(asset, book, methodRule);
|
||||||
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated)));
|
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated)));
|
||||||
accumulated += depreciation;
|
accumulated += depreciation;
|
||||||
@@ -493,6 +575,11 @@ module.exports = {
|
|||||||
depreciableBasis,
|
depreciableBasis,
|
||||||
totalDepreciableBasis,
|
totalDepreciableBasis,
|
||||||
getMethodRule,
|
getMethodRule,
|
||||||
|
isMidQuarterEligible,
|
||||||
|
midQuarterBasis,
|
||||||
|
monthFromDate,
|
||||||
monthlyFromAnnual,
|
monthlyFromAnnual,
|
||||||
money
|
money,
|
||||||
|
section280fRecapture,
|
||||||
|
yearFromDate
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { exportReport } = require('../services/reportExport');
|
|||||||
const { rowsFromImport, extensionForUpload } = require('../services/importExport');
|
const { rowsFromImport, extensionForUpload } = require('../services/importExport');
|
||||||
const glEntries = require('../services/glEntries');
|
const glEntries = require('../services/glEntries');
|
||||||
const glAccounts = require('../services/glAccounts');
|
const glAccounts = require('../services/glAccounts');
|
||||||
|
const section179Limitation = require('../services/section179Limitation');
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -79,6 +80,19 @@ router.delete('/gl-accounts/:id', ledgerEditor, (req, res) => {
|
|||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// §179 return-level taxable-income limitation + carryover (per company + book + year).
|
||||||
|
router.get('/section-179/limitation', ledgerReader, (req, res) => {
|
||||||
|
const book = req.query.book || 'FEDERAL';
|
||||||
|
const year = Number(req.query.year) || new Date().getFullYear();
|
||||||
|
res.json({ summary: section179Limitation.summary(req.companyId, book, year, req.query.taxable_income) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/section-179/limitation', ledgerEditor, (req, res) => {
|
||||||
|
const book = req.body.book || 'FEDERAL';
|
||||||
|
const year = Number(req.body.year) || new Date().getFullYear();
|
||||||
|
res.json({ summary: section179Limitation.save(req.companyId, book, year, req.body.taxable_income, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
// GL posting defaults (the fallback accounts the ledgers post to).
|
// GL posting defaults (the fallback accounts the ledgers post to).
|
||||||
router.get('/gl-account-defaults', ledgerReader, (req, res) => {
|
router.get('/gl-account-defaults', ledgerReader, (req, res) => {
|
||||||
res.json({ defaults: glAccounts.getDefaults(req.companyId) });
|
res.json({ defaults: glAccounts.getDefaults(req.companyId) });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const companies = require('../services/companies');
|
|||||||
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 section179Limits = require('../services/section179Limits');
|
||||||
|
const section280fLimits = require('../services/section280fLimits');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -67,6 +68,34 @@ router.delete('/section-179-limits/:year', requireCapability('config.manage'), (
|
|||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// §280F passenger-automobile depreciation caps (indexed yearly; user-managed).
|
||||||
|
router.get('/section-280f-limits', (req, res) => {
|
||||||
|
res.json({ limits: section280fLimits.list() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/section-280f-limits', requireCapability('config.manage'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ limit: section280fLimits.createLimit(req.body, req.user.id) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/section-280f-limits/:year', requireCapability('config.manage'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const limit = section280fLimits.updateLimit(req.params.year, req.body, req.user.id);
|
||||||
|
if (!limit) return res.status(404).json({ error: 'Limit not found' });
|
||||||
|
return res.json({ limit });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/section-280f-limits/:year', requireCapability('config.manage'), (req, res) => {
|
||||||
|
if (!section280fLimits.deleteLimit(req.params.year, req.user.id)) return res.status(404).json({ error: 'Limit not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
// Asset class catalog (seeded from the IRS tables, then user-managed). GET is reference
|
// 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) => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const assetColumns = [
|
|||||||
'condition',
|
'condition',
|
||||||
'new_or_used',
|
'new_or_used',
|
||||||
'listed_property',
|
'listed_property',
|
||||||
|
'passenger_auto',
|
||||||
'business_use_percent',
|
'business_use_percent',
|
||||||
'investment_use_percent',
|
'investment_use_percent',
|
||||||
'disposal_date',
|
'disposal_date',
|
||||||
@@ -62,6 +63,7 @@ function assetFromRow(row) {
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
listed_property: Boolean(row.listed_property),
|
listed_property: Boolean(row.listed_property),
|
||||||
|
passenger_auto: Boolean(row.passenger_auto),
|
||||||
custom_fields: parseJson(row.custom_fields, {}),
|
custom_fields: parseJson(row.custom_fields, {}),
|
||||||
books: row.books ? parseJson(row.books, []) : undefined
|
books: row.books ? parseJson(row.books, []) : undefined
|
||||||
};
|
};
|
||||||
@@ -213,6 +215,7 @@ function normalizeAssetInput(body) {
|
|||||||
input.investment_use_percent = Number(input.investment_use_percent || 0);
|
input.investment_use_percent = Number(input.investment_use_percent || 0);
|
||||||
input.custom_fields = json(input.custom_fields || {});
|
input.custom_fields = json(input.custom_fields || {});
|
||||||
input.listed_property = input.listed_property ? 1 : 0;
|
input.listed_property = input.listed_property ? 1 : 0;
|
||||||
|
input.passenger_auto = input.passenger_auto ? 1 : 0;
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +270,7 @@ function listAssets(query = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAsset(body, userId, companyId) {
|
function createAsset(body, userId, companyId) {
|
||||||
|
require('./midQuarter').clearCache(); // asset set changed → recompute the mid-quarter determination
|
||||||
const created = now();
|
const created = now();
|
||||||
const input = normalizeAssetInput(body);
|
const input = normalizeAssetInput(body);
|
||||||
if (companyId) input.entity_id = companyId; // new assets belong to the active company
|
if (companyId) input.entity_id = companyId; // new assets belong to the active company
|
||||||
@@ -289,6 +293,7 @@ function createAsset(body, userId, companyId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateAsset(id, body, userId, companyId) {
|
function updateAsset(id, body, userId, companyId) {
|
||||||
|
require('./midQuarter').clearCache();
|
||||||
const before = hydrateAsset(id);
|
const before = hydrateAsset(id);
|
||||||
if (!before) return null;
|
if (!before) return null;
|
||||||
if (companyId && before.entity_id !== companyId) return null; // belongs to another company
|
if (companyId && before.entity_id !== companyId) return null; // belongs to another company
|
||||||
@@ -323,6 +328,7 @@ function updateAsset(id, body, userId, companyId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteAsset(id, userId, companyId) {
|
function deleteAsset(id, userId, companyId) {
|
||||||
|
require('./midQuarter').clearCache();
|
||||||
const before = hydrateAsset(id);
|
const before = hydrateAsset(id);
|
||||||
if (!before) return false;
|
if (!before) return false;
|
||||||
if (companyId && before.entity_id !== companyId) return false; // belongs to another company
|
if (companyId && before.entity_id !== companyId) return false; // belongs to another company
|
||||||
@@ -332,6 +338,7 @@ function deleteAsset(id, userId, companyId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function massUpdateAssets(ids, changes, userId, companyId) {
|
function massUpdateAssets(ids, changes, userId, companyId) {
|
||||||
|
require('./midQuarter').clearCache();
|
||||||
const allowed = ['status', 'location', 'department', 'group_name', 'in_service_date', 'useful_life_months', 'condition', 'disposal_date'];
|
const allowed = ['status', 'location', 'department', 'group_name', 'in_service_date', 'useful_life_months', 'condition', 'disposal_date'];
|
||||||
const columns = allowed.filter((column) => Object.prototype.hasOwnProperty.call(changes, column));
|
const columns = allowed.filter((column) => Object.prototype.hasOwnProperty.call(changes, column));
|
||||||
// Restrict to assets in the active company so a forged id list can't touch another company's data.
|
// Restrict to assets in the active company so a forged id list can't touch another company's data.
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ function buildLine(o) {
|
|||||||
|
|
||||||
// Capitalize a Construction-in-progress asset: place it in service and post Dr asset / Cr CWIP.
|
// Capitalize a Construction-in-progress asset: place it in service and post Dr asset / Cr CWIP.
|
||||||
function capitalizeWip(assetId, body, userId, companyId) {
|
function capitalizeWip(assetId, body, userId, companyId) {
|
||||||
|
require('./midQuarter').clearCache(); // status cip→in_service changes the placed-in-service set
|
||||||
const glEntries = require('./glEntries');
|
const glEntries = require('./glEntries');
|
||||||
const { getDefaults } = require('./glAccounts');
|
const { getDefaults } = require('./glAccounts');
|
||||||
const asset = one('SELECT * FROM assets WHERE id = ? AND entity_id = ?', [assetId, companyId]);
|
const asset = one('SELECT * FROM assets WHERE id = ? AND entity_id = ?', [assetId, companyId]);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { all, audit, now, one, run, tx } = require('../db');
|
const { all, audit, now, one, run, tx } = require('../db');
|
||||||
const { annualSchedule, money } = require('../depreciation');
|
const { annualSchedule, money, section280fRecapture } = require('../depreciation');
|
||||||
const { ruleSetForBook } = require('./ruleSets');
|
const { ruleSetForBook } = require('./ruleSets');
|
||||||
const { hydrateAsset } = require('./assets');
|
const { hydrateAsset } = require('./assets');
|
||||||
|
|
||||||
@@ -30,6 +30,44 @@ function bookFor(asset, bookType) {
|
|||||||
return asset.books.find((book) => book.book_type === bookType) || asset.books[0] || { book_type: bookType };
|
return asset.books.find((book) => book.book_type === bookType) || asset.books[0] || { book_type: bookType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real property (§1250) vs personal property (§1245), by recovery period: 27.5-year residential rental
|
||||||
|
// and 39-year nonresidential real property are §1250; everything else is §1245.
|
||||||
|
function isRealProperty(asset, book) {
|
||||||
|
const months = Number(book.useful_life_months || asset.useful_life_months || 0);
|
||||||
|
const pt = String(asset.property_type || '').toLowerCase();
|
||||||
|
return months >= 330 || /real|realty|1250|building/.test(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Characterize the gain on disposal into ordinary-income recapture vs. §1231 (capital) gain (Form 4797).
|
||||||
|
// §1245: ordinary recapture = lesser of gain or total depreciation taken (incl. §179/bonus). §1250:
|
||||||
|
// ordinary recapture = lesser of gain or "additional depreciation" (accelerated over straight-line); the
|
||||||
|
// remaining gain up to straight-line taken is unrecaptured §1250 gain (a 25% capital bucket).
|
||||||
|
function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, accumulated) {
|
||||||
|
const section = isRealProperty(asset, book) ? '1250' : '1245';
|
||||||
|
const gain = Math.max(0, gainLoss);
|
||||||
|
let ordinary = 0;
|
||||||
|
let unrecaptured1250 = 0;
|
||||||
|
if (gain > 0) {
|
||||||
|
if (section === '1245') {
|
||||||
|
ordinary = Math.min(gain, accumulated);
|
||||||
|
} else {
|
||||||
|
// Straight-line accumulated for the same period (additional depreciation = accelerated − SL).
|
||||||
|
const slBook = { ...book, depreciation_method: 'straight_line', section_179_amount: 0, bonus_percent: 0 };
|
||||||
|
const slRow = annualSchedule(asset, slBook, rules, { startYear: year, endYear: year })[0] || {};
|
||||||
|
const slAccum = money(Number(slRow.accumulated || 0) * proportion);
|
||||||
|
const additional = Math.max(0, money(accumulated - slAccum));
|
||||||
|
ordinary = Math.min(gain, additional);
|
||||||
|
unrecaptured1250 = Math.min(money(gain - ordinary), slAccum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
recapture_section: section,
|
||||||
|
ordinary_recapture: money(ordinary),
|
||||||
|
unrecaptured_1250_gain: money(unrecaptured1250),
|
||||||
|
section_1231_gain: money(gainLoss - ordinary) // negative for a §1231 loss
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function computeDisposal(asset, input = {}) {
|
function computeDisposal(asset, input = {}) {
|
||||||
const bookType = input.book_type || 'GAAP';
|
const bookType = input.book_type || 'GAAP';
|
||||||
const book = bookFor(asset, bookType);
|
const book = bookFor(asset, bookType);
|
||||||
@@ -55,6 +93,10 @@ function computeDisposal(asset, input = {}) {
|
|||||||
const realized = money(price - expense);
|
const realized = money(price - expense);
|
||||||
const gainLoss = money(realized - netBookValue);
|
const gainLoss = money(realized - netBookValue);
|
||||||
|
|
||||||
|
// Recapture characterization is computed on the disposed book's depreciation (use a tax book — FEDERAL —
|
||||||
|
// for tax recapture). "Depreciation taken" includes the impairment write-down already reflected in NBV.
|
||||||
|
const recapture = characterizeRecapture(asset, book, rules, year, proportion, gainLoss, money(accumulated + impairment));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book_type: bookType,
|
book_type: bookType,
|
||||||
disposal_date: disposalDate,
|
disposal_date: disposalDate,
|
||||||
@@ -69,7 +111,8 @@ function computeDisposal(asset, input = {}) {
|
|||||||
impairment,
|
impairment,
|
||||||
net_book_value: netBookValue,
|
net_book_value: netBookValue,
|
||||||
realized,
|
realized,
|
||||||
gain_loss: gainLoss
|
gain_loss: gainLoss,
|
||||||
|
...recapture
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +122,18 @@ function previewDisposal(assetId, input) {
|
|||||||
return computeDisposal(asset, input);
|
return computeDisposal(asset, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §280F recapture preview for a listed/passenger asset converting to ≤50% business use in a given year.
|
||||||
|
function previewSection280fRecapture(assetId, input = {}) {
|
||||||
|
const asset = hydrateAsset(assetId);
|
||||||
|
if (!asset) return null;
|
||||||
|
const bookType = input.book || 'FEDERAL';
|
||||||
|
const book = bookFor(asset, bookType);
|
||||||
|
const year = Number(input.year) || new Date().getFullYear();
|
||||||
|
return section280fRecapture(asset, book, ruleSetForBook(bookType), year);
|
||||||
|
}
|
||||||
|
|
||||||
function recordDisposal(assetId, input, userId) {
|
function recordDisposal(assetId, input, userId) {
|
||||||
|
require('./midQuarter').clearCache();
|
||||||
const asset = hydrateAsset(assetId);
|
const asset = hydrateAsset(assetId);
|
||||||
if (!asset) return null;
|
if (!asset) return null;
|
||||||
const result = computeDisposal(asset, input);
|
const result = computeDisposal(asset, input);
|
||||||
@@ -91,13 +145,15 @@ function recordDisposal(assetId, input, userId) {
|
|||||||
`INSERT INTO disposals (
|
`INSERT INTO disposals (
|
||||||
asset_id, disposal_date, method, property_type, partial, disposed_cost,
|
asset_id, disposal_date, method, property_type, partial, disposed_cost,
|
||||||
disposal_price, disposal_expense, accumulated_depreciation, net_book_value,
|
disposal_price, disposal_expense, accumulated_depreciation, net_book_value,
|
||||||
gain_loss, book_type, notes, created_by, created_at
|
gain_loss, book_type, recapture_section, ordinary_recapture, section_1231_gain,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
unrecaptured_1250_gain, notes, created_by, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
assetId, result.disposal_date, result.method, result.property_type, result.partial,
|
assetId, result.disposal_date, result.method, result.property_type, result.partial,
|
||||||
result.disposed_cost, result.disposal_price, result.disposal_expense,
|
result.disposed_cost, result.disposal_price, result.disposal_expense,
|
||||||
result.accumulated_depreciation, result.net_book_value, result.gain_loss,
|
result.accumulated_depreciation, result.net_book_value, result.gain_loss,
|
||||||
result.book_type, input.notes || null, userId, timestamp
|
result.book_type, result.recapture_section, result.ordinary_recapture, result.section_1231_gain,
|
||||||
|
result.unrecaptured_1250_gain, input.notes || null, userId, timestamp
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -127,6 +183,7 @@ function recordDisposal(assetId, input, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reverseDisposal(assetId, disposalId, userId) {
|
function reverseDisposal(assetId, disposalId, userId) {
|
||||||
|
require('./midQuarter').clearCache();
|
||||||
const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]);
|
const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]);
|
||||||
if (!disposal) return null;
|
if (!disposal) return null;
|
||||||
guardSubledger(assetId, disposal.disposal_date);
|
guardSubledger(assetId, disposal.disposal_date);
|
||||||
@@ -191,6 +248,7 @@ module.exports = {
|
|||||||
deleteAdjustment,
|
deleteAdjustment,
|
||||||
netAdjustments,
|
netAdjustments,
|
||||||
previewDisposal,
|
previewDisposal,
|
||||||
|
previewSection280fRecapture,
|
||||||
recordAdjustment,
|
recordAdjustment,
|
||||||
recordDisposal,
|
recordDisposal,
|
||||||
reverseDisposal
|
reverseDisposal
|
||||||
|
|||||||
68
server/services/midQuarter.js
Normal file
68
server/services/midQuarter.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const { all } = require('../db');
|
||||||
|
|
||||||
|
// IRS Pub 946 mid-quarter convention determination. The mid-quarter convention is MANDATORY for all of a
|
||||||
|
// tax year's MACRS personal property when more than 40% of the aggregate depreciable basis of such
|
||||||
|
// property is placed in service during the last three months (Q4) of the year. This is a portfolio- and
|
||||||
|
// year-level fact, computed here once per (company, book, year) and consulted by the depreciation engine
|
||||||
|
// (depreciation.conventionFor). Real property (mid-month) is excluded. Cached; invalidated on asset writes.
|
||||||
|
|
||||||
|
const cache = new Map(); // `${entityId}|${bookCode}|${year}` -> boolean
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookObject(row, bookCode) {
|
||||||
|
return {
|
||||||
|
book_type: bookCode,
|
||||||
|
cost: row.book_cost,
|
||||||
|
depreciation_method: row.depreciation_method,
|
||||||
|
convention: row.convention,
|
||||||
|
useful_life_months: row.book_life,
|
||||||
|
section_179_amount: row.section_179_amount,
|
||||||
|
bonus_percent: row.bonus_percent,
|
||||||
|
business_use_percent: row.book_bup,
|
||||||
|
investment_use_percent: row.book_ivp,
|
||||||
|
manual_depreciation: row.manual_depreciation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the mid-quarter convention applies to (company, book) MACRS personal property placed in
|
||||||
|
// service in `year`. No-op-friendly: returns false when the portfolio doesn't trip the 40% threshold.
|
||||||
|
function applies(entityId, bookCode, year) {
|
||||||
|
if (!entityId || !bookCode || !year) return false;
|
||||||
|
const key = `${entityId}|${bookCode}|${Number(year)}`;
|
||||||
|
if (cache.has(key)) return cache.get(key);
|
||||||
|
|
||||||
|
const dep = require('../depreciation');
|
||||||
|
const { ruleSetForBook } = require('./ruleSets');
|
||||||
|
const { assetFromRow } = require('./assets');
|
||||||
|
const ruleSet = ruleSetForBook(bookCode);
|
||||||
|
const rows = all(
|
||||||
|
`SELECT a.*, b.cost AS book_cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
|
||||||
|
b.section_179_amount, b.bonus_percent, b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
|
||||||
|
FROM assets a JOIN asset_books b ON b.asset_id = a.id
|
||||||
|
WHERE b.book_type = ? AND b.active = 1 AND a.entity_id = ? AND a.status != 'disposed'`,
|
||||||
|
[bookCode, entityId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let lastQuarter = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const pis = row.in_service_date || row.acquired_date;
|
||||||
|
if (!pis || dep.yearFromDate(pis) !== Number(year)) continue;
|
||||||
|
const book = bookObject(row, bookCode);
|
||||||
|
const methodRule = dep.getMethodRule(ruleSet, book.depreciation_method);
|
||||||
|
if (!dep.isMidQuarterEligible(book, methodRule)) continue;
|
||||||
|
const basis = dep.midQuarterBasis(assetFromRow(row), book);
|
||||||
|
if (basis <= 0) continue;
|
||||||
|
total += basis;
|
||||||
|
if (Math.ceil(dep.monthFromDate(pis) / 3) === 4) lastQuarter += basis;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = total > 0 && lastQuarter / total > 0.4;
|
||||||
|
cache.set(key, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { applies, clearCache };
|
||||||
107
server/services/section179Limitation.js
Normal file
107
server/services/section179Limitation.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
const { all, audit, now, one, run } = require('../db');
|
||||||
|
const { getLimit } = require('./section179Limits');
|
||||||
|
|
||||||
|
// §179 return-level taxable-income limitation and carryover (IRS Pub 946 / IRC §179(b)). The per-asset
|
||||||
|
// engine already caps each asset's §179 at the dollar limit and phase-out; THIS layer aggregates the
|
||||||
|
// §179 elected across a company's assets for a tax year, applies the single annual dollar limit (reduced
|
||||||
|
// by the investment phase-out) AND the business taxable-income limitation, and carries any disallowed
|
||||||
|
// amount forward to the next year. Stateful: the prior year's carryover feeds the next year's available
|
||||||
|
// deduction. Per company + book + year.
|
||||||
|
|
||||||
|
function yearOf(value) {
|
||||||
|
const s = String(value || '');
|
||||||
|
return s ? Number(s.slice(0, 4)) : 0;
|
||||||
|
}
|
||||||
|
function round2(n) {
|
||||||
|
return Math.round((Number(n) || 0) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §179 elected per asset and the cost of §179 property placed in service, for a (company, book, year).
|
||||||
|
function electedFor(companyId, bookCode, year) {
|
||||||
|
const rows = all(
|
||||||
|
`SELECT a.acquisition_cost, a.in_service_date, a.acquired_date, b.section_179_amount, b.cost
|
||||||
|
FROM assets a JOIN asset_books b ON b.asset_id = a.id
|
||||||
|
WHERE a.entity_id = ? AND b.book_type = ? AND b.active = 1 AND a.status != 'disposed'`,
|
||||||
|
[companyId, bookCode]
|
||||||
|
);
|
||||||
|
let elected = 0;
|
||||||
|
let propertyCost = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (yearOf(r.in_service_date || r.acquired_date) !== Number(year)) continue;
|
||||||
|
const amt = Number(r.section_179_amount || 0);
|
||||||
|
if (amt <= 0) continue;
|
||||||
|
elected += amt;
|
||||||
|
propertyCost += Number(r.cost ?? r.acquisition_cost ?? 0);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return { elected: round2(elected), propertyCost: round2(propertyCost), count };
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedRow(companyId, bookCode, year) {
|
||||||
|
return one('SELECT * FROM section_179_carryover WHERE entity_id = ? AND book_code = ? AND tax_year = ?', [companyId, bookCode, Number(year)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the limitation. `taxableIncome` overrides the stored value when provided (null/undefined uses
|
||||||
|
// the stored value, if any). The business-income limitation is only applied when an income figure exists.
|
||||||
|
function summary(companyId, bookCode, year, taxableIncome) {
|
||||||
|
const yr = Number(year);
|
||||||
|
const limitRow = getLimit(yr);
|
||||||
|
const { elected, propertyCost, count } = electedFor(companyId, bookCode, yr);
|
||||||
|
const priorRow = savedRow(companyId, bookCode, yr - 1);
|
||||||
|
const priorCarryover = round2(priorRow ? priorRow.carryover_to_next : 0);
|
||||||
|
|
||||||
|
const baseLimit = limitRow ? Number(limitRow.base_limit) : Infinity;
|
||||||
|
const phaseoutThreshold = limitRow ? Number(limitRow.phaseout_threshold) : Infinity;
|
||||||
|
const phaseoutReduction = Number.isFinite(phaseoutThreshold) ? Math.max(0, propertyCost - phaseoutThreshold) : 0;
|
||||||
|
const dollarLimit = Number.isFinite(baseLimit) ? Math.max(0, baseLimit - phaseoutReduction) : Infinity;
|
||||||
|
|
||||||
|
const available = round2(elected + priorCarryover);
|
||||||
|
const dollarAllowed = round2(Math.min(available, dollarLimit));
|
||||||
|
|
||||||
|
const stored = savedRow(companyId, bookCode, yr);
|
||||||
|
const income = taxableIncome !== undefined && taxableIncome !== null
|
||||||
|
? Number(taxableIncome)
|
||||||
|
: (stored && stored.taxable_income !== null ? Number(stored.taxable_income) : null);
|
||||||
|
const incomeApplied = income !== null && !Number.isNaN(income);
|
||||||
|
const allowed = round2(incomeApplied ? Math.min(dollarAllowed, Math.max(0, income)) : dollarAllowed);
|
||||||
|
const carryover = round2(Math.max(0, available - allowed));
|
||||||
|
|
||||||
|
return {
|
||||||
|
company_id: companyId,
|
||||||
|
book_code: bookCode,
|
||||||
|
tax_year: yr,
|
||||||
|
asset_count: count,
|
||||||
|
elected,
|
||||||
|
property_cost: propertyCost,
|
||||||
|
prior_carryover: priorCarryover,
|
||||||
|
base_limit: Number.isFinite(baseLimit) ? baseLimit : null,
|
||||||
|
phaseout_threshold: Number.isFinite(phaseoutThreshold) ? phaseoutThreshold : null,
|
||||||
|
phaseout_reduction: round2(phaseoutReduction),
|
||||||
|
dollar_limit: Number.isFinite(dollarLimit) ? round2(dollarLimit) : null,
|
||||||
|
available,
|
||||||
|
dollar_allowed: dollarAllowed,
|
||||||
|
taxable_income: incomeApplied ? round2(income) : null,
|
||||||
|
income_limited: incomeApplied && allowed < dollarAllowed,
|
||||||
|
allowed_deduction: allowed,
|
||||||
|
carryover_to_next: carryover
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the entered taxable income and the resulting carryover (so the next year picks it up). Audited.
|
||||||
|
function save(companyId, bookCode, year, taxableIncome, userId) {
|
||||||
|
const result = summary(companyId, bookCode, year, taxableIncome);
|
||||||
|
const income = taxableIncome === undefined || taxableIncome === null || taxableIncome === '' ? null : Number(taxableIncome);
|
||||||
|
run(
|
||||||
|
`INSERT INTO section_179_carryover (entity_id, book_code, tax_year, taxable_income, carryover_to_next, updated_by, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(entity_id, book_code, tax_year) DO UPDATE SET
|
||||||
|
taxable_income = excluded.taxable_income, carryover_to_next = excluded.carryover_to_next,
|
||||||
|
updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
|
||||||
|
[companyId, bookCode, Number(year), income, result.carryover_to_next, userId, now()]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'section_179_limitation', `${bookCode}:${year}`, null, { taxable_income: income, carryover: result.carryover_to_next });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { summary, save };
|
||||||
95
server/services/section280fLimits.js
Normal file
95
server/services/section280fLimits.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
const { all, audit, now, one, run } = require('../db');
|
||||||
|
|
||||||
|
// §280F passenger-automobile annual depreciation caps, keyed by placed-in-service year (IRS Rev. Proc.,
|
||||||
|
// indexed annually). Mirrors the §179-limits reference service: cached for the engine, CRUD for admins.
|
||||||
|
|
||||||
|
let cache = null;
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows() {
|
||||||
|
if (!cache) cache = all('SELECT * FROM section_280f_limits ORDER BY pis_year');
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caps for a placed-in-service year: the most recent configured year at or before it, or null.
|
||||||
|
function getLimit(year) {
|
||||||
|
const y = Number(year);
|
||||||
|
if (!y) return null;
|
||||||
|
let match = null;
|
||||||
|
for (const row of rows()) {
|
||||||
|
if (row.pis_year <= y) match = row;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRow(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
pis_year: row.pis_year,
|
||||||
|
year1_no_bonus: row.year1_no_bonus,
|
||||||
|
year1_bonus: row.year1_bonus,
|
||||||
|
year2: row.year2,
|
||||||
|
year3: row.year3,
|
||||||
|
year4plus: row.year4plus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return all('SELECT * FROM section_280f_limits ORDER BY pis_year DESC').map(fromRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(body) {
|
||||||
|
return {
|
||||||
|
pis_year: Math.trunc(Number(body.pis_year)) || 0,
|
||||||
|
year1_no_bonus: Number(body.year1_no_bonus) || 0,
|
||||||
|
year1_bonus: Number(body.year1_bonus) || 0,
|
||||||
|
year2: Number(body.year2) || 0,
|
||||||
|
year3: Number(body.year3) || 0,
|
||||||
|
year4plus: Number(body.year4plus) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLimit(body, userId) {
|
||||||
|
const input = normalize(body);
|
||||||
|
if (!input.pis_year) throw Object.assign(new Error('A valid placed-in-service year is required'), { status: 400 });
|
||||||
|
if (one('SELECT id FROM section_280f_limits WHERE pis_year = ?', [input.pis_year])) {
|
||||||
|
throw Object.assign(new Error('Caps for that year already exist'), { status: 400 });
|
||||||
|
}
|
||||||
|
const ts = now();
|
||||||
|
run(
|
||||||
|
`INSERT INTO section_280f_limits (pis_year, year1_no_bonus, year1_bonus, year2, year3, year4plus, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[input.pis_year, input.year1_no_bonus, input.year1_bonus, input.year2, input.year3, input.year4plus, ts, ts]
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
audit(userId, 'create', 'section_280f_limit', String(input.pis_year), null, input);
|
||||||
|
return fromRow(one('SELECT * FROM section_280f_limits WHERE pis_year = ?', [input.pis_year]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLimit(year, body, userId) {
|
||||||
|
const before = one('SELECT * FROM section_280f_limits WHERE pis_year = ?', [Number(year)]);
|
||||||
|
if (!before) return null;
|
||||||
|
const input = normalize({ ...fromRow(before), ...body, pis_year: before.pis_year });
|
||||||
|
run(
|
||||||
|
'UPDATE section_280f_limits SET year1_no_bonus = ?, year1_bonus = ?, year2 = ?, year3 = ?, year4plus = ?, updated_at = ? WHERE pis_year = ?',
|
||||||
|
[input.year1_no_bonus, input.year1_bonus, input.year2, input.year3, input.year4plus, now(), before.pis_year]
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
audit(userId, 'update', 'section_280f_limit', String(before.pis_year), fromRow(before), input);
|
||||||
|
return fromRow(one('SELECT * FROM section_280f_limits WHERE pis_year = ?', [before.pis_year]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteLimit(year, userId) {
|
||||||
|
const before = one('SELECT * FROM section_280f_limits WHERE pis_year = ?', [Number(year)]);
|
||||||
|
if (!before) return false;
|
||||||
|
run('DELETE FROM section_280f_limits WHERE pis_year = ?', [before.pis_year]);
|
||||||
|
clearCache();
|
||||||
|
audit(userId, 'delete', 'section_280f_limit', String(before.pis_year), fromRow(before), null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { clearCache, createLimit, deleteLimit, getLimit, list, updateLimit };
|
||||||
@@ -1985,6 +1985,51 @@ async function main() {
|
|||||||
if (!Array.isArray(deprAgg.byBook) || !deprAgg.byBook.some((b) => b.book === 'GAAP')) throw new Error('Dashboard depreciation aggregator missing byBook/GAAP');
|
if (!Array.isArray(deprAgg.byBook) || !deprAgg.byBook.some((b) => b.book === 'GAAP')) throw new Error('Dashboard depreciation aggregator missing byBook/GAAP');
|
||||||
if (!Array.isArray(deprAgg.byCategory)) throw new Error('Dashboard depreciation aggregator missing byCategory');
|
if (!Array.isArray(deprAgg.byCategory)) throw new Error('Dashboard depreciation aggregator missing byCategory');
|
||||||
|
|
||||||
|
// ---- MACRS mid-quarter 40% auto-test (IRS Pub 946) --------------------------
|
||||||
|
// Q4-heavy portfolio (80% of basis placed in service in Q4) → mid-quarter convention is mandatory and
|
||||||
|
// must be applied automatically: the Q4 asset's first-year MACRS depreciation should be the mid-quarter
|
||||||
|
// amount (8000 × 0.40 × 0.125 = 400), not the half-year amount (8000 × 0.40 × 0.5 = 1600).
|
||||||
|
const macrsBook = (cost) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost });
|
||||||
|
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MQ1-${suffix}`, description: 'Mid-quarter Q1', category: 'Computer Equipment', acquisition_cost: 2000, acquired_date: '2029-02-15', in_service_date: '2029-02-15', useful_life_months: 60, books: [macrsBook(2000)] }) });
|
||||||
|
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MQ4-${suffix}`, description: 'Mid-quarter Q4', category: 'Computer Equipment', acquisition_cost: 8000, acquired_date: '2029-11-15', in_service_date: '2029-11-15', useful_life_months: 60, books: [macrsBook(8000)] }) });
|
||||||
|
const mqReport = (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year: 2029 } }) })).report;
|
||||||
|
const mqRow = mqReport.rows.find((r) => r.asset_id === `MQ4-${suffix}`);
|
||||||
|
if (!mqRow) throw new Error('Mid-quarter test asset missing from the depreciation report');
|
||||||
|
if (Math.abs(mqRow.depreciation - 400) > 1) throw new Error(`Mid-quarter convention not auto-applied: expected ~400 (mid-quarter), got ${mqRow.depreciation}`);
|
||||||
|
|
||||||
|
// Verify the §179 limits were updated to the IRS Pub 946 (2026) figures.
|
||||||
|
const s179 = (await request('/api/section-179-limits', { headers: auth })).limits;
|
||||||
|
const y2026 = s179.find((l) => l.tax_year === 2026);
|
||||||
|
if (!y2026 || Number(y2026.base_limit) !== 2560000 || Number(y2026.phaseout_threshold) !== 4090000) {
|
||||||
|
throw new Error(`2026 §179 limit should be 2,560,000 / 4,090,000, got ${JSON.stringify(y2026)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- §280F passenger-auto cap + listed-property >50% ADS test (IRS Pub 946) ---
|
||||||
|
const macrsBook5 = (cost) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost });
|
||||||
|
// $90k luxury auto, 100% business, 2024 → year-1 MACRS would be 18,000 but §280F caps it to 12,400.
|
||||||
|
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `AUTO-${suffix}`, description: 'Luxury auto', category: 'Computer Equipment', acquisition_cost: 90000, acquired_date: '2024-06-01', in_service_date: '2024-06-01', useful_life_months: 60, business_use_percent: 100, passenger_auto: true, books: [macrsBook5(90000)] }) });
|
||||||
|
// Listed property at 40% business use → forced ADS straight-line (basis 10k×40% / 5yr × half-year = 400).
|
||||||
|
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `LIST-${suffix}`, description: 'Listed low-use', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-06-01', in_service_date: '2024-06-01', useful_life_months: 60, business_use_percent: 40, listed_property: true, books: [macrsBook5(10000)] }) });
|
||||||
|
const depr2024 = (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year: 2024 } }) })).report;
|
||||||
|
const autoRow = depr2024.rows.find((r) => r.asset_id === `AUTO-${suffix}`);
|
||||||
|
if (!autoRow || Math.abs(autoRow.depreciation - 12400) > 1) throw new Error(`§280F auto cap not applied: expected 12,400, got ${autoRow && autoRow.depreciation}`);
|
||||||
|
const listRow = depr2024.rows.find((r) => r.asset_id === `LIST-${suffix}`);
|
||||||
|
if (!listRow || Math.abs(listRow.depreciation - 400) > 1) throw new Error(`Listed ≤50% ADS not applied: expected 400, got ${listRow && listRow.depreciation}`);
|
||||||
|
if (!(await request('/api/section-280f-limits', { headers: auth })).limits.some((l) => l.pis_year === 2024)) throw new Error('§280F limits not seeded');
|
||||||
|
|
||||||
|
// ---- §179 taxable-income limitation + carryover ----------------------------
|
||||||
|
const s179Book = (amt) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_7', useful_life_months: 84, section_179_amount: amt, cost: amt });
|
||||||
|
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `S179A-${suffix}`, description: '179 A', category: 'Computer Equipment', acquisition_cost: 1500000, acquired_date: '2031-03-01', in_service_date: '2031-03-01', useful_life_months: 84, books: [s179Book(1500000)] }) });
|
||||||
|
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `S179B-${suffix}`, description: '179 B', category: 'Computer Equipment', acquisition_cost: 1500000, acquired_date: '2031-04-01', in_service_date: '2031-04-01', useful_life_months: 84, books: [s179Book(1500000)] }) });
|
||||||
|
// Elected 3,000,000; 2031 dollar limit 2,560,000; taxable income 1,000,000 → allowed 1,000,000, carry 2,000,000.
|
||||||
|
const limSaved = (await request('/api/section-179/limitation', { method: 'PUT', headers: aHeaders, body: JSON.stringify({ book: 'FEDERAL', year: 2031, taxable_income: 1000000 }) })).summary;
|
||||||
|
if (limSaved.elected !== 3000000) throw new Error(`§179 elected wrong: ${limSaved.elected}`);
|
||||||
|
if (limSaved.dollar_allowed !== 2560000) throw new Error(`§179 dollar limit wrong: ${limSaved.dollar_allowed}`);
|
||||||
|
if (limSaved.allowed_deduction !== 1000000) throw new Error(`§179 income limitation wrong: ${limSaved.allowed_deduction}`);
|
||||||
|
if (limSaved.carryover_to_next !== 2000000) throw new Error(`§179 carryover wrong: ${limSaved.carryover_to_next}`);
|
||||||
|
const lim2032 = (await request('/api/section-179/limitation?book=FEDERAL&year=2032', { headers: aHeaders })).summary;
|
||||||
|
if (lim2032.prior_carryover !== 2000000) throw new Error(`§179 carryover did not chain to next year: ${lim2032.prior_carryover}`);
|
||||||
|
|
||||||
console.log('Smoke test passed');
|
console.log('Smoke test passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,10 @@
|
|||||||
v-if="activeView === 'close'"
|
v-if="activeView === 'close'"
|
||||||
:token="token"
|
:token="token"
|
||||||
/>
|
/>
|
||||||
|
<Section179View
|
||||||
|
v-if="activeView === 'section-179'"
|
||||||
|
:token="token"
|
||||||
|
/>
|
||||||
<TemplatesView
|
<TemplatesView
|
||||||
v-if="activeView === 'templates'"
|
v-if="activeView === 'templates'"
|
||||||
:category-items="categoryItems"
|
:category-items="categoryItems"
|
||||||
@@ -315,6 +319,7 @@ import AssignmentsView from './views/AssignmentsView.vue';
|
|||||||
import BooksView from './views/BooksView.vue';
|
import BooksView from './views/BooksView.vue';
|
||||||
import GlJournalsView from './views/GlJournalsView.vue';
|
import GlJournalsView from './views/GlJournalsView.vue';
|
||||||
import CloseView from './views/CloseView.vue';
|
import CloseView from './views/CloseView.vue';
|
||||||
|
import Section179View from './views/Section179View.vue';
|
||||||
import ContactsView from './views/ContactsView.vue';
|
import ContactsView from './views/ContactsView.vue';
|
||||||
import AssetsView from './views/AssetsView.vue';
|
import AssetsView from './views/AssetsView.vue';
|
||||||
import DashboardView from './views/DashboardView.vue';
|
import DashboardView from './views/DashboardView.vue';
|
||||||
@@ -347,6 +352,7 @@ export default {
|
|||||||
BooksView,
|
BooksView,
|
||||||
GlJournalsView,
|
GlJournalsView,
|
||||||
CloseView,
|
CloseView,
|
||||||
|
Section179View,
|
||||||
ChangePasswordCard,
|
ChangePasswordCard,
|
||||||
ContactsView,
|
ContactsView,
|
||||||
DashboardView,
|
DashboardView,
|
||||||
@@ -518,6 +524,7 @@ export default {
|
|||||||
books: 'Configure books, assign tax rule sets, and set entry defaults',
|
books: 'Configure books, assign tax rule sets, and set entry defaults',
|
||||||
'gl-journals': 'General ledger rollup and editable journal entries per book',
|
'gl-journals': 'General ledger rollup and editable journal entries per book',
|
||||||
close: 'Guided month-end / year-end close: post, reconcile, and lock the period',
|
close: 'Guided month-end / year-end close: post, reconcile, and lock the period',
|
||||||
|
'section-179': 'Section 179 deduction limitation, taxable-income cap, and carryover',
|
||||||
templates: 'JSON-driven entry defaults and custom fields',
|
templates: 'JSON-driven entry defaults and custom fields',
|
||||||
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
|
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
|
||||||
admin: 'Users, configuration, and integrations',
|
admin: 'Users, configuration, and integrations',
|
||||||
@@ -543,6 +550,7 @@ export default {
|
|||||||
books: 'Books',
|
books: 'Books',
|
||||||
'gl-journals': 'GL and Journals',
|
'gl-journals': 'GL and Journals',
|
||||||
close: 'Period Close',
|
close: 'Period Close',
|
||||||
|
'section-179': 'Section 179',
|
||||||
templates: 'Templates',
|
templates: 'Templates',
|
||||||
'tax-rules': 'Tax rules',
|
'tax-rules': 'Tax rules',
|
||||||
admin: 'Configuration',
|
admin: 'Configuration',
|
||||||
@@ -611,6 +619,7 @@ export default {
|
|||||||
books: ['finance.manage'],
|
books: ['finance.manage'],
|
||||||
'gl-journals': ['finance.view', 'finance.manage'],
|
'gl-journals': ['finance.view', 'finance.manage'],
|
||||||
close: ['finance.close', 'finance.manage'],
|
close: ['finance.close', 'finance.manage'],
|
||||||
|
'section-179': ['finance.view', 'finance.manage'],
|
||||||
'tax-rules': ['finance.manage'],
|
'tax-rules': ['finance.manage'],
|
||||||
'admin-users': ['admin.users'],
|
'admin-users': ['admin.users'],
|
||||||
'admin-security': ['admin.security'],
|
'admin-security': ['admin.security'],
|
||||||
|
|||||||
@@ -53,7 +53,8 @@
|
|||||||
<v-select v-model="localAsset.new_or_used" :items="newUsedItems" item-title="title" item-value="value" label="New / used" />
|
<v-select v-model="localAsset.new_or_used" :items="newUsedItems" item-title="title" item-value="value" label="New / used" />
|
||||||
<v-text-field v-model.number="localAsset.business_use_percent" type="number" label="Business use %" />
|
<v-text-field v-model.number="localAsset.business_use_percent" type="number" label="Business use %" />
|
||||||
<v-text-field v-model.number="localAsset.investment_use_percent" type="number" label="Investment use %" />
|
<v-text-field v-model.number="localAsset.investment_use_percent" type="number" label="Investment use %" />
|
||||||
<v-switch v-model="localAsset.listed_property" label="Listed property (luxury auto limits)" color="primary" density="compact" hide-details class="full" />
|
<v-switch v-model="localAsset.listed_property" label="Listed property (≤50% business use → ADS straight-line)" color="primary" density="compact" hide-details class="full" />
|
||||||
|
<v-switch v-model="localAsset.passenger_auto" label="Passenger automobile (§280F annual depreciation caps)" color="primary" density="compact" hide-details class="full" />
|
||||||
|
|
||||||
<v-select
|
<v-select
|
||||||
v-model="localAsset.special_zone"
|
v-model="localAsset.special_zone"
|
||||||
@@ -895,6 +896,8 @@ export default {
|
|||||||
useful_life_months: Number(asset.useful_life_months || 0),
|
useful_life_months: Number(asset.useful_life_months || 0),
|
||||||
salvage_value: Number(asset.salvage_value || 0),
|
salvage_value: Number(asset.salvage_value || 0),
|
||||||
business_use_percent: Number(asset.business_use_percent || 0),
|
business_use_percent: Number(asset.business_use_percent || 0),
|
||||||
|
listed_property: Boolean(asset.listed_property),
|
||||||
|
passenger_auto: Boolean(asset.passenger_auto),
|
||||||
depreciation_method: asset.depreciation_method ?? null,
|
depreciation_method: asset.depreciation_method ?? null,
|
||||||
special_zone: asset.special_zone ?? null,
|
special_zone: asset.special_zone ?? null,
|
||||||
books: (asset.books || []).map((b) => ({
|
books: (asset.books || []).map((b) => ({
|
||||||
|
|||||||
124
src/components/Section280fLimitSettings.vue
Normal file
124
src/components/Section280fLimitSettings.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">§280F passenger-auto limits</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Annual luxury-automobile depreciation caps (IRS Rev. Proc., indexed yearly). When an asset is flagged as a
|
||||||
|
<strong>passenger automobile</strong>, the engine limits each year's depreciation to the applicable cap (reduced by
|
||||||
|
business-use %), using the higher first-year figure when bonus depreciation is claimed.
|
||||||
|
</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="pis_year" :page-size="10" has-actions empty-text="No §280F caps configured — passenger autos are uncapped.">
|
||||||
|
<template #cell-year1_no_bonus="{ row }">${{ Number(row.year1_no_bonus).toLocaleString() }}</template>
|
||||||
|
<template #cell-year1_bonus="{ row }">${{ Number(row.year1_bonus).toLocaleString() }}</template>
|
||||||
|
<template #cell-year2="{ row }">${{ Number(row.year2).toLocaleString() }}</template>
|
||||||
|
<template #cell-year3="{ row }">${{ Number(row.year3).toLocaleString() }}</template>
|
||||||
|
<template #cell-year4plus="{ row }">${{ Number(row.year4plus).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="520">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.exists ? `Edit ${form.pis_year}` : 'New §280F year' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model.number="form.pis_year" type="number" label="Placed-in-service year *" :disabled="form.exists" />
|
||||||
|
<v-text-field v-model.number="form.year1_no_bonus" type="number" prefix="$" label="Year 1 (no bonus)" />
|
||||||
|
<v-text-field v-model.number="form.year1_bonus" type="number" prefix="$" label="Year 1 (with bonus)" />
|
||||||
|
<v-text-field v-model.number="form.year2" type="number" prefix="$" label="Year 2" />
|
||||||
|
<v-text-field v-model.number="form.year3" type="number" prefix="$" label="Year 3" />
|
||||||
|
<v-text-field v-model.number="form.year4plus" type="number" prefix="$" label="Year 4 and later" />
|
||||||
|
</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.pis_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 caps</v-card-title>
|
||||||
|
<v-card-text><p>Remove the §280F caps for <strong>{{ deleteTarget?.pis_year }}</strong>? Autos placed in service that year fall back to the nearest earlier 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';
|
||||||
|
|
||||||
|
// §280F passenger-automobile annual depreciation caps editor (Admin → Application Configuration). Mirrors
|
||||||
|
// the §179 limits editor; CRUD against /api/section-280f-limits.
|
||||||
|
function blankForm() {
|
||||||
|
return { exists: false, pis_year: new Date().getFullYear(), year1_no_bonus: 0, year1_bonus: 0, year2: 0, year3: 0, year4plus: 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: 'pis_year', label: 'Year' },
|
||||||
|
{ key: 'year1_no_bonus', label: 'Y1 (no bonus)' },
|
||||||
|
{ key: 'year1_bonus', label: 'Y1 (bonus)' },
|
||||||
|
{ key: 'year2', label: 'Y2' },
|
||||||
|
{ key: 'year3', label: 'Y3' },
|
||||||
|
{ key: 'year4plus', label: 'Y4+' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() { this.load(); },
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) { return apiRequest(path, this.token, options); },
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try { this.limits = (await (await this.api('/api/section-280f-limits')).json()).limits; }
|
||||||
|
catch (error) { this.error = error.message; }
|
||||||
|
},
|
||||||
|
openNew() { this.form = blankForm(); this.dialogError = ''; this.dialog = true; },
|
||||||
|
openEdit(row) { this.form = { exists: true, ...row }; this.dialogError = ''; this.dialog = true; },
|
||||||
|
async save() {
|
||||||
|
if (!this.form.pis_year) return;
|
||||||
|
this.saving = true; this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.exists ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.exists ? `/api/section-280f-limits/${this.form.pis_year}` : '/api/section-280f-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-280f-limits/${target.pis_year}`, { method: 'DELETE' }); await this.load(); this.$emit('updated'); }
|
||||||
|
catch (error) { this.error = error.message; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -339,6 +339,36 @@
|
|||||||
+ $100,000 §179 increase, placed in service 2007–2008).
|
+ $100,000 §179 increase, placed in service 2007–2008).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3>Listed property & passenger automobiles (§280F)</h3>
|
||||||
|
<p>
|
||||||
|
Two switches on an asset’s Details tab drive the §280F rules (IRS Pub 946):
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Listed property</strong> — when such an asset is used <strong>50% or less</strong> for qualified
|
||||||
|
business (its <em>Business use %</em>), the engine automatically switches it to <strong>ADS straight-line</strong>
|
||||||
|
and disallows §179 and bonus depreciation, exactly as §280F requires. Above 50%, it depreciates normally.</li>
|
||||||
|
<li><strong>Passenger automobile</strong> — flags the asset for the annual <strong>luxury-auto depreciation caps</strong>.
|
||||||
|
Each year’s depreciation is limited to the §280F cap for the placed-in-service year (the higher first-year figure
|
||||||
|
applies when bonus is claimed), reduced by the business-use %. Any basis not recovered within the recovery period
|
||||||
|
continues at the final-year cap until the asset is fully depreciated.</li>
|
||||||
|
</ul>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
The yearly cap amounts are maintained in <strong>Admin → Application Configuration → §280F passenger-auto limits</strong>
|
||||||
|
(indexed annually by the IRS); like the §179 limits, they are editable data — no software update needed when the IRS
|
||||||
|
publishes new figures.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<h3>Section 179 limitation & carryover</h3>
|
||||||
|
<p>
|
||||||
|
While the engine caps <em>each asset’s</em> §179 at the annual dollar limit and investment phase-out, the
|
||||||
|
<strong>Financial → Section 179</strong> screen provides the <strong>return-level</strong> view: it totals the §179
|
||||||
|
elected across the company’s assets for a tax year and applies the <strong>annual dollar limit</strong> (reduced by the
|
||||||
|
phase-out once §179 property cost exceeds the threshold) <em>and</em> your <strong>business taxable-income limit</strong>
|
||||||
|
(§179 can’t create a loss). Enter your taxable income, and the worksheet shows the allowed deduction and the
|
||||||
|
<strong>carryover</strong> of any disallowed amount. Saving stores the carryover so the <strong>next year automatically
|
||||||
|
picks it up</strong> as additional available §179.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3>The PM (maintenance) book</h3>
|
<h3>The PM (maintenance) book</h3>
|
||||||
<p>
|
<p>
|
||||||
A dedicated <strong>PM book</strong> tracks actual maintenance <em>spend</em> per asset rather than depreciation. Its ledger
|
A dedicated <strong>PM book</strong> tracks actual maintenance <em>spend</em> per asset rather than depreciation. Its ledger
|
||||||
@@ -864,7 +894,7 @@
|
|||||||
<strong>Admin</strong> (admin role) is where the application is configured. It is split into sub-screens in the
|
<strong>Admin</strong> (admin role) is where the application is configured. It is split into sub-screens in the
|
||||||
navigation rail: <strong>Users & Teams</strong>, <strong>Password & Security</strong>,
|
navigation rail: <strong>Users & Teams</strong>, <strong>Password & Security</strong>,
|
||||||
<strong>Activity & Audit Trail</strong>, <strong>Application Configuration</strong> (companies, application
|
<strong>Activity & Audit Trail</strong>, <strong>Application Configuration</strong> (companies, application
|
||||||
settings, asset classes, depreciation zones, Section 179 limits, asset ID templates, asset categories,
|
settings, asset classes, depreciation zones, Section 179 limits, §280F passenger-auto limits, asset ID templates, asset categories,
|
||||||
asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and
|
asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and
|
||||||
<strong>Integrations</strong> (email, webhook, Microsoft Teams, ServiceNow, Workday). Each is described below.
|
<strong>Integrations</strong> (email, webhook, Microsoft Teams, ServiceNow, Workday). Each is described below.
|
||||||
</p>
|
</p>
|
||||||
@@ -1186,7 +1216,7 @@ export default {
|
|||||||
{ id: 'assets', title: 'Assets — creating & managing', icon: 'mdi-archive-search-outline', keywords: 'asset register create edit import export mass edit workbench files notes tasks warranty' },
|
{ id: 'assets', title: 'Assets — creating & managing', icon: 'mdi-archive-search-outline', keywords: 'asset register create edit import export mass edit workbench files notes tasks warranty' },
|
||||||
{ id: 'lifecycle', title: 'Asset lifecycle', icon: 'mdi-cash-remove', keywords: 'dispose disposal sale retire gain loss lease amortization impairment adjustment write down' },
|
{ id: 'lifecycle', title: 'Asset lifecycle', icon: 'mdi-cash-remove', keywords: 'dispose disposal sale retire gain loss lease amortization impairment adjustment write down' },
|
||||||
{ id: 'barcodes', title: 'Barcodes & scanning', icon: 'mdi-barcode-scan', keywords: 'barcode label print scan camera tag' },
|
{ id: 'barcodes', title: 'Barcodes & scanning', icon: 'mdi-barcode-scan', keywords: 'barcode label print scan camera tag' },
|
||||||
{ id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports journal entry entries import export csv excel conflict merge history zone section 179' },
|
{ id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports journal entry entries import export csv excel conflict merge history zone section 179 280f listed property passenger automobile luxury auto caps ads straight line mid-quarter limitation carryover taxable income macrs' },
|
||||||
{ id: 'close', title: 'Period close (month & year-end)', icon: 'mdi-calendar-lock', keywords: 'close month end year end period lock reconcile subledger gl roll forward retained earnings depreciation post reopen finalize wip capitalize disposal impairment audit' },
|
{ id: 'close', title: 'Period close (month & year-end)', icon: 'mdi-calendar-lock', keywords: 'close month end year end period lock reconcile subledger gl roll forward retained earnings depreciation post reopen finalize wip capitalize disposal impairment audit' },
|
||||||
{ id: 'taxrules', title: 'Tax rule sets', icon: 'mdi-scale-balance', keywords: 'tax rules jurisdiction method limits activate version' },
|
{ id: 'taxrules', title: 'Tax rule sets', icon: 'mdi-scale-balance', keywords: 'tax rules jurisdiction method limits activate version' },
|
||||||
{ id: 'templates', title: 'Templates', icon: 'mdi-form-select', keywords: 'template defaults custom fields data entry' },
|
{ id: 'templates', title: 'Templates', icon: 'mdi-form-select', keywords: 'template defaults custom fields data entry' },
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export const navItems = [
|
|||||||
{ key: 'books', label: 'Books', icon: 'mdi-book-open-variant' },
|
{ key: 'books', label: 'Books', icon: 'mdi-book-open-variant' },
|
||||||
{ key: 'gl-journals', label: 'GL and Journals', icon: 'mdi-book-account-outline' },
|
{ key: 'gl-journals', label: 'GL and Journals', icon: 'mdi-book-account-outline' },
|
||||||
{ key: 'close', label: 'Period Close', icon: 'mdi-calendar-lock' },
|
{ key: 'close', label: 'Period Close', icon: 'mdi-calendar-lock' },
|
||||||
|
{ key: 'section-179', label: 'Section 179', icon: 'mdi-cash-multiple' },
|
||||||
{ key: 'tax-rules', label: 'Tax rules', icon: 'mdi-scale-balance' }
|
{ key: 'tax-rules', label: 'Tax rules', icon: 'mdi-scale-balance' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -194,6 +194,8 @@
|
|||||||
|
|
||||||
<Section179LimitSettings :token="token" />
|
<Section179LimitSettings :token="token" />
|
||||||
|
|
||||||
|
<Section280fLimitSettings :token="token" />
|
||||||
|
|
||||||
<AssetIdTemplateSettings :token="token" />
|
<AssetIdTemplateSettings :token="token" />
|
||||||
|
|
||||||
<CategorySettings :token="token" @updated="$emit('categories-updated')" />
|
<CategorySettings :token="token" @updated="$emit('categories-updated')" />
|
||||||
@@ -323,6 +325,7 @@ 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 Section179LimitSettings from '../components/Section179LimitSettings.vue';
|
||||||
|
import Section280fLimitSettings from '../components/Section280fLimitSettings.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';
|
||||||
@@ -332,7 +335,7 @@ import TeamsSettings from '../components/TeamsSettings.vue';
|
|||||||
import WebhookSettings from '../components/WebhookSettings.vue';
|
import WebhookSettings from '../components/WebhookSettings.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { AppLogsSettings, AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, CompanySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
|
components: { AppLogsSettings, AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, CompanySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, Section280fLimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, default: '' },
|
token: { type: String, default: '' },
|
||||||
section: { type: String, default: 'admin-users' },
|
section: { type: String, default: 'admin-users' },
|
||||||
|
|||||||
109
src/views/Section179View.vue
Normal file
109
src/views/Section179View.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<h2 class="section-title mb-1">Section 179 — deduction limitation & carryover</h2>
|
||||||
|
<p class="quiet text-body-2 mb-3">
|
||||||
|
Return-level §179: the total §179 elected across this company's assets for a tax year, capped by the annual dollar
|
||||||
|
limit (reduced by the investment phase-out) and by your business taxable income. Any disallowed amount carries
|
||||||
|
forward to next year. The per-asset engine still caps each asset individually; this is the aggregate return view.
|
||||||
|
</p>
|
||||||
|
<div class="toolbar-row" style="flex-wrap:wrap;gap:12px">
|
||||||
|
<v-select v-model="book" :items="bookItems" label="Book" density="compact" hide-details style="max-width:200px" @update:model-value="load" />
|
||||||
|
<v-text-field v-model.number="year" type="number" label="Tax year" density="compact" hide-details style="max-width:130px" @update:model-value="load" />
|
||||||
|
<v-text-field v-model.number="taxableIncome" type="number" prefix="$" label="Business taxable income" density="compact" hide-details style="max-width:240px" hint="Leave blank to skip the income limit" persistent-hint />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save & carry forward</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card v-if="s" class="span-7 panel-pad">
|
||||||
|
<h3 class="section-title mb-3">Limitation worksheet — {{ s.book_code }} {{ s.tax_year }}</h3>
|
||||||
|
<table class="worksheet">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>§179 elected ({{ s.asset_count }} asset{{ s.asset_count === 1 ? '' : 's' }})</td><td class="r">{{ currency(s.elected) }}</td></tr>
|
||||||
|
<tr><td>Carryover from {{ s.tax_year - 1 }}</td><td class="r">{{ currency(s.prior_carryover) }}</td></tr>
|
||||||
|
<tr class="sub"><td>Available for deduction</td><td class="r">{{ currency(s.available) }}</td></tr>
|
||||||
|
<tr><td>Annual dollar limit{{ s.base_limit ? ` (base ${currency(s.base_limit)})` : '' }}</td><td class="r">{{ s.dollar_limit === null ? 'uncapped' : currency(s.dollar_limit) }}</td></tr>
|
||||||
|
<tr v-if="s.phaseout_reduction"><td class="quiet">— investment phase-out reduction (cost {{ currency(s.property_cost) }} over {{ currency(s.phaseout_threshold) }})</td><td class="r quiet">−{{ currency(s.phaseout_reduction) }}</td></tr>
|
||||||
|
<tr class="sub"><td>Allowed after dollar limit</td><td class="r">{{ currency(s.dollar_allowed) }}</td></tr>
|
||||||
|
<tr v-if="s.taxable_income !== null"><td>Business taxable income limit</td><td class="r">{{ currency(s.taxable_income) }}</td></tr>
|
||||||
|
<tr class="total"><td>Allowed §179 deduction</td><td class="r">{{ currency(s.allowed_deduction) }}</td></tr>
|
||||||
|
<tr class="carry"><td>Carryover to {{ s.tax_year + 1 }}</td><td class="r">{{ currency(s.carryover_to_next) }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<v-alert v-if="s.income_limited" type="info" density="compact" variant="tonal" class="mt-3">
|
||||||
|
The deduction is limited by taxable income; {{ currency(s.carryover_to_next) }} carries forward.
|
||||||
|
</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card v-if="s" class="span-5 panel-pad">
|
||||||
|
<h3 class="section-title mb-2">How it works</h3>
|
||||||
|
<ul class="quiet text-body-2">
|
||||||
|
<li>§179 elected = sum of each asset's §179 amount on the selected book, placed in service in the year.</li>
|
||||||
|
<li>The <strong>annual dollar limit</strong> ({{ year }}) is reduced dollar-for-dollar once §179 property cost exceeds the phase-out threshold.</li>
|
||||||
|
<li>The <strong>taxable-income limit</strong> caps the deduction at your business income; it can't create a loss.</li>
|
||||||
|
<li><strong>Carryover</strong> = available − allowed, and is added to next year's available amount when you save.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="quiet text-caption mt-2">Limit figures are maintained in Admin → Application Configuration → Section 179 limits.</p>
|
||||||
|
</v-card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { currency } from '../utils/format';
|
||||||
|
|
||||||
|
// Section 179 return-level limitation & carryover. Aggregates §179 elected for a (book, year), applies the
|
||||||
|
// annual dollar limit + phase-out and the business taxable-income limit, and persists the carryover so the
|
||||||
|
// next year picks it up. Backed by /api/section-179/limitation (GET summary, PUT save).
|
||||||
|
export default {
|
||||||
|
props: { token: { type: String, required: true } },
|
||||||
|
data() {
|
||||||
|
return { books: [], book: 'FEDERAL', year: new Date().getFullYear(), taxableIncome: null, s: null, saving: false, message: '', error: '' };
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookItems() { return this.books.map((b) => b.code); }
|
||||||
|
},
|
||||||
|
mounted() { this.loadBooks(); },
|
||||||
|
methods: {
|
||||||
|
currency,
|
||||||
|
async api(path, options = {}) { return apiRequest(path, this.token, options); },
|
||||||
|
async loadBooks() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/books')).json();
|
||||||
|
this.books = data.books;
|
||||||
|
const primary = this.books.find((b) => b.is_primary && b.code !== 'GAAP') || this.books.find((b) => b.code === 'FEDERAL') || this.books[0];
|
||||||
|
if (primary) this.book = primary.code;
|
||||||
|
} catch (error) { this.error = error.message; }
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ book: this.book, year: String(this.year) });
|
||||||
|
const data = await (await this.api(`/api/section-179/limitation?${q.toString()}`)).json();
|
||||||
|
this.s = data.summary;
|
||||||
|
if (this.taxableIncome === null && this.s.taxable_income !== null) this.taxableIncome = this.s.taxable_income;
|
||||||
|
} catch (error) { this.error = error.message; }
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true; this.message = ''; this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/section-179/limitation', { method: 'PUT', body: JSON.stringify({ book: this.book, year: this.year, taxable_income: this.taxableIncome }) })).json();
|
||||||
|
this.s = data.summary;
|
||||||
|
this.message = `Saved. Carryover to ${this.s.tax_year + 1}: ${currency(this.s.carryover_to_next)}.`;
|
||||||
|
} catch (error) { this.error = error.message; } finally { this.saving = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.worksheet { width: 100%; border-collapse: collapse; }
|
||||||
|
.worksheet td { padding: 6px 8px; border-bottom: 1px solid rgba(128, 128, 128, 0.14); }
|
||||||
|
.worksheet td.r { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.worksheet tr.sub td { font-weight: 600; border-top: 1px solid rgba(128, 128, 128, 0.3); }
|
||||||
|
.worksheet tr.total td { font-weight: 700; font-size: 1.05rem; border-top: 2px solid rgba(var(--v-theme-primary), 0.5); }
|
||||||
|
.worksheet tr.carry td { color: rgb(var(--v-theme-primary)); font-weight: 600; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user