diff --git a/server/db.js b/server/db.js index ca6e320..f0d99f7 100644 --- a/server/db.js +++ b/server/db.js @@ -358,6 +358,32 @@ function initialize() { 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 ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -889,6 +915,17 @@ function migrate() { ensureColumn('gl_account_defaults', 'proceeds_account', 'TEXT'); ensureColumn('gl_account_defaults', 'retained_earnings_account', 'TEXT'); 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(); } @@ -1245,8 +1282,9 @@ function seed() { [2011, 500000, 2000000], [2012, 500000, 2000000], [2013, 500000, 2000000], [2014, 500000, 2000000], [2015, 500000, 2000000], [2016, 500000, 2010000], [2017, 510000, 2030000], [2018, 1000000, 2500000], [2019, 1020000, 2550000], [2020, 1040000, 2590000], [2021, 1050000, 2620000], - [2022, 1080000, 2700000], [2023, 1160000, 2890000], [2024, 1220000, 3050000], [2025, 1250000, 3130000], - [2026, 1250000, 3130000] + [2022, 1080000, 2700000], [2023, 1160000, 2890000], [2024, 1220000, 3050000], + // 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) { run( @@ -1255,6 +1293,22 @@ function seed() { [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')) { run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [ 'Finance Operations', diff --git a/server/depreciation.js b/server/depreciation.js index c9fee19..fdf2317 100644 --- a/server/depreciation.js +++ b/server/depreciation.js @@ -10,6 +10,7 @@ const { parseJson } = require('./db'); const { buildDepreciationMethods } = require('./rule-catalog'); const { getZone } = require('./services/zones'); const { getLimit: getSection179Limit } = require('./services/section179Limits'); +const { getLimit: getSection280fLimit } = require('./services/section280fLimits'); const moment = require('moment'); console.log(`${moment().format()} ✅ Depreciation module loaded with dependencies: db, rule-catalog, zones, section179Limits`); @@ -212,10 +213,75 @@ function totalDepreciableBasis(asset, book) { 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. -function conventionFor(book, methodRule) { - console.log(`${moment().format()} 🕒 Determining depreciation convention for book ${book.id}`); - return methodRule.defaultConvention || book.convention || 'none'; +// Is this MACRS personal property (eligible for the mid-quarter 40% test)? Real property uses the +// mid-month convention and is excluded; only otherwise-half-year MACRS property can switch to mid-quarter. +function isMidQuarterEligible(book, methodRule) { + 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). @@ -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}`); const basis = depreciableBasis(asset, book, methodRule); 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. @@ -332,7 +398,7 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule const salvage = salvageValue(asset, book, methodRule); let bookValue = start; let elapsed = 0; - const convention = conventionFor(book, methodRule); + const convention = conventionFor(book, methodRule, asset); 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. @@ -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. function annualSchedule(asset, book, ruleSet, options = {}) { 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. 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. @@ -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); depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated))); accumulated += depreciation; @@ -493,6 +575,11 @@ module.exports = { depreciableBasis, totalDepreciableBasis, getMethodRule, + isMidQuarterEligible, + midQuarterBasis, + monthFromDate, monthlyFromAnnual, - money + money, + section280fRecapture, + yearFromDate }; diff --git a/server/routes/books.js b/server/routes/books.js index 2b327df..6cac277 100644 --- a/server/routes/books.js +++ b/server/routes/books.js @@ -6,6 +6,7 @@ const { exportReport } = require('../services/reportExport'); const { rowsFromImport, extensionForUpload } = require('../services/importExport'); const glEntries = require('../services/glEntries'); const glAccounts = require('../services/glAccounts'); +const section179Limitation = require('../services/section179Limitation'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } }); const router = express.Router(); @@ -79,6 +80,19 @@ router.delete('/gl-accounts/:id', ledgerEditor, (req, res) => { 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). router.get('/gl-account-defaults', ledgerReader, (req, res) => { res.json({ defaults: glAccounts.getDefaults(req.companyId) }); diff --git a/server/routes/reference.js b/server/routes/reference.js index 305ced1..edf0421 100644 --- a/server/routes/reference.js +++ b/server/routes/reference.js @@ -6,6 +6,7 @@ const companies = require('../services/companies'); const assetClasses = require('../services/assetClasses'); const zones = require('../services/zones'); const section179Limits = require('../services/section179Limits'); +const section280fLimits = require('../services/section280fLimits'); const router = express.Router(); @@ -67,6 +68,34 @@ router.delete('/section-179-limits/:year', requireCapability('config.manage'), ( 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 // data used by the category picker; mutations require config.manage. router.get('/asset-classes', (req, res) => { diff --git a/server/services/assets.js b/server/services/assets.js index a05cabc..7bfab81 100644 --- a/server/services/assets.js +++ b/server/services/assets.js @@ -29,6 +29,7 @@ const assetColumns = [ 'condition', 'new_or_used', 'listed_property', + 'passenger_auto', 'business_use_percent', 'investment_use_percent', 'disposal_date', @@ -62,6 +63,7 @@ function assetFromRow(row) { return { ...row, listed_property: Boolean(row.listed_property), + passenger_auto: Boolean(row.passenger_auto), custom_fields: parseJson(row.custom_fields, {}), 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.custom_fields = json(input.custom_fields || {}); input.listed_property = input.listed_property ? 1 : 0; + input.passenger_auto = input.passenger_auto ? 1 : 0; return input; } @@ -267,6 +270,7 @@ function listAssets(query = {}) { } function createAsset(body, userId, companyId) { + require('./midQuarter').clearCache(); // asset set changed → recompute the mid-quarter determination const created = now(); const input = normalizeAssetInput(body); 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) { + require('./midQuarter').clearCache(); const before = hydrateAsset(id); if (!before) return null; 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) { + require('./midQuarter').clearCache(); const before = hydrateAsset(id); if (!before) return false; 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) { + require('./midQuarter').clearCache(); 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)); // Restrict to assets in the active company so a forged id list can't touch another company's data. diff --git a/server/services/closePeriods.js b/server/services/closePeriods.js index 27c7f92..1df878b 100644 --- a/server/services/closePeriods.js +++ b/server/services/closePeriods.js @@ -363,6 +363,7 @@ function buildLine(o) { // Capitalize a Construction-in-progress asset: place it in service and post Dr asset / Cr CWIP. function capitalizeWip(assetId, body, userId, companyId) { + require('./midQuarter').clearCache(); // status cip→in_service changes the placed-in-service set const glEntries = require('./glEntries'); const { getDefaults } = require('./glAccounts'); const asset = one('SELECT * FROM assets WHERE id = ? AND entity_id = ?', [assetId, companyId]); diff --git a/server/services/disposals.js b/server/services/disposals.js index 4338b48..85e4df8 100644 --- a/server/services/disposals.js +++ b/server/services/disposals.js @@ -1,5 +1,5 @@ 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 { 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 }; } +// 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 = {}) { const bookType = input.book_type || 'GAAP'; const book = bookFor(asset, bookType); @@ -55,6 +93,10 @@ function computeDisposal(asset, input = {}) { const realized = money(price - expense); 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 { book_type: bookType, disposal_date: disposalDate, @@ -69,7 +111,8 @@ function computeDisposal(asset, input = {}) { impairment, net_book_value: netBookValue, realized, - gain_loss: gainLoss + gain_loss: gainLoss, + ...recapture }; } @@ -79,7 +122,18 @@ function previewDisposal(assetId, 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) { + require('./midQuarter').clearCache(); const asset = hydrateAsset(assetId); if (!asset) return null; const result = computeDisposal(asset, input); @@ -91,13 +145,15 @@ function recordDisposal(assetId, input, userId) { `INSERT INTO disposals ( asset_id, disposal_date, method, property_type, partial, disposed_cost, disposal_price, disposal_expense, accumulated_depreciation, net_book_value, - gain_loss, book_type, notes, created_by, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + gain_loss, book_type, recapture_section, ordinary_recapture, section_1231_gain, + unrecaptured_1250_gain, notes, created_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ assetId, result.disposal_date, result.method, result.property_type, result.partial, result.disposed_cost, result.disposal_price, result.disposal_expense, 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) { + require('./midQuarter').clearCache(); const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]); if (!disposal) return null; guardSubledger(assetId, disposal.disposal_date); @@ -191,6 +248,7 @@ module.exports = { deleteAdjustment, netAdjustments, previewDisposal, + previewSection280fRecapture, recordAdjustment, recordDisposal, reverseDisposal diff --git a/server/services/midQuarter.js b/server/services/midQuarter.js new file mode 100644 index 0000000..1784d71 --- /dev/null +++ b/server/services/midQuarter.js @@ -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 }; diff --git a/server/services/section179Limitation.js b/server/services/section179Limitation.js new file mode 100644 index 0000000..527a89c --- /dev/null +++ b/server/services/section179Limitation.js @@ -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 }; diff --git a/server/services/section280fLimits.js b/server/services/section280fLimits.js new file mode 100644 index 0000000..82e8d5a --- /dev/null +++ b/server/services/section280fLimits.js @@ -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 }; diff --git a/server/smoke-test.js b/server/smoke-test.js index 0911e60..992ff79 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -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.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'); } diff --git a/src/App.vue b/src/App.vue index 3622924..a7e224c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -172,6 +172,10 @@ v-if="activeView === 'close'" :token="token" /> + - + + ({ diff --git a/src/components/Section280fLimitSettings.vue b/src/components/Section280fLimitSettings.vue new file mode 100644 index 0000000..e500cb4 --- /dev/null +++ b/src/components/Section280fLimitSettings.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/components/UserManual.vue b/src/components/UserManual.vue index d690df4..5c2c47c 100644 --- a/src/components/UserManual.vue +++ b/src/components/UserManual.vue @@ -339,6 +339,36 @@ + $100,000 §179 increase, placed in service 2007–2008).

+

Listed property & passenger automobiles (§280F)

+

+ Two switches on an asset’s Details tab drive the §280F rules (IRS Pub 946): +

+
    +
  • Listed property — when such an asset is used 50% or less for qualified + business (its Business use %), the engine automatically switches it to ADS straight-line + and disallows §179 and bonus depreciation, exactly as §280F requires. Above 50%, it depreciates normally.
  • +
  • Passenger automobile — flags the asset for the annual luxury-auto depreciation caps. + 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.
  • +
+ + The yearly cap amounts are maintained in Admin → Application Configuration → §280F passenger-auto limits + (indexed annually by the IRS); like the §179 limits, they are editable data — no software update needed when the IRS + publishes new figures. + + +

Section 179 limitation & carryover

+

+ While the engine caps each asset’s §179 at the annual dollar limit and investment phase-out, the + Financial → Section 179 screen provides the return-level view: it totals the §179 + elected across the company’s assets for a tax year and applies the annual dollar limit (reduced by the + phase-out once §179 property cost exceeds the threshold) and your business taxable-income limit + (§179 can’t create a loss). Enter your taxable income, and the worksheet shows the allowed deduction and the + carryover of any disallowed amount. Saving stores the carryover so the next year automatically + picks it up as additional available §179. +

+

The PM (maintenance) book

A dedicated PM book tracks actual maintenance spend per asset rather than depreciation. Its ledger @@ -864,7 +894,7 @@ Admin (admin role) is where the application is configured. It is split into sub-screens in the navigation rail: Users & Teams, Password & Security, Activity & Audit Trail, Application Configuration (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 Integrations (email, webhook, Microsoft Teams, ServiceNow, Workday). Each is described below.

@@ -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: '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: '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: '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' }, diff --git a/src/constants.js b/src/constants.js index 0181554..1ef7084 100644 --- a/src/constants.js +++ b/src/constants.js @@ -162,6 +162,7 @@ export const navItems = [ { key: 'books', label: 'Books', icon: 'mdi-book-open-variant' }, { key: 'gl-journals', label: 'GL and Journals', icon: 'mdi-book-account-outline' }, { 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' } ] }, diff --git a/src/views/AdminView.vue b/src/views/AdminView.vue index 42ec0c9..505b12e 100644 --- a/src/views/AdminView.vue +++ b/src/views/AdminView.vue @@ -194,6 +194,8 @@ + + @@ -323,6 +325,7 @@ import DataTable from '../components/DataTable.vue'; import DepartmentSettings from '../components/DepartmentSettings.vue'; import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue'; import Section179LimitSettings from '../components/Section179LimitSettings.vue'; +import Section280fLimitSettings from '../components/Section280fLimitSettings.vue'; import NotificationSettings from '../components/NotificationSettings.vue'; import PartCategorySettings from '../components/PartCategorySettings.vue'; import PmCategorySettings from '../components/PmCategorySettings.vue'; @@ -332,7 +335,7 @@ import TeamsSettings from '../components/TeamsSettings.vue'; import WebhookSettings from '../components/WebhookSettings.vue'; 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: { token: { type: String, default: '' }, section: { type: String, default: 'admin-users' }, diff --git a/src/views/Section179View.vue b/src/views/Section179View.vue new file mode 100644 index 0000000..fb403e4 --- /dev/null +++ b/src/views/Section179View.vue @@ -0,0 +1,109 @@ + + + + +