diff --git a/server/data/irsAssetClasses.js b/server/data/irsAssetClasses.js index 275e174..0020c54 100644 --- a/server/data/irsAssetClasses.js +++ b/server/data/irsAssetClasses.js @@ -7,6 +7,12 @@ // IRS Table 1 — Alphabetical Listing of Commonly Used Assets. const COMMON = [ { description: 'Adding Machines', class_number: '00.13', gds: 5, ads: 6 }, + // Farm land improvements & orchards/groves (IRS Pub 225 / Pub 946 farm property). + { description: 'Water Wells (farm land improvement)', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Irrigation Systems (farm land improvement)', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Drainage Facilities (farm land improvement)', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Paved Lots / Farm Roads (land improvement)', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Orchards, Groves & Vineyards (fruit/nut trees & vines)', class_number: '00.0', gds: 10, ads: 20 }, { description: 'Agricultural Machinery and Equipment', class_number: '01.1', gds: 7, ads: 10 }, { description: 'Airplanes, Commercial', class_number: '45.0', gds: 7, ads: 12 }, { description: 'Airplanes, Noncommercial', class_number: '00.21', gds: 5, ads: 6 }, diff --git a/server/db.js b/server/db.js index f0d99f7..9b6a225 100644 --- a/server/db.js +++ b/server/db.js @@ -371,6 +371,18 @@ function initialize() { updated_at TEXT NOT NULL ); + -- Short tax years (less than 12 months) prorate that year's MACRS depreciation, per company. + CREATE TABLE IF NOT EXISTS short_tax_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER REFERENCES entities(id), + tax_year INTEGER NOT NULL, + months REAL NOT NULL DEFAULT 12, + note TEXT, + updated_by INTEGER REFERENCES users(id), + updated_at TEXT NOT NULL, + UNIQUE(entity_id, tax_year) + ); + -- §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, @@ -922,6 +934,12 @@ function migrate() { 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'); + // Farm-property recapture inputs (IRS Pub 225): §175 soil/water conservation deductions (§1252) and + // §126 excluded cost-sharing payments (§1255), with the computed recapture stored on disposals. + ensureColumn('assets', 'soil_water_deductions', 'REAL NOT NULL DEFAULT 0'); + ensureColumn('assets', 'cost_sharing_excluded', 'REAL NOT NULL DEFAULT 0'); + ensureColumn('disposals', 'section_1252_recapture', 'REAL NOT NULL DEFAULT 0'); + ensureColumn('disposals', 'section_1255_recapture', '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'); diff --git a/server/depreciation.js b/server/depreciation.js index fdf2317..b64bb86 100644 --- a/server/depreciation.js +++ b/server/depreciation.js @@ -6,7 +6,7 @@ salvage value, and conventions is included. */ -const { parseJson } = require('./db'); +const { one, parseJson } = require('./db'); const { buildDepreciationMethods } = require('./rule-catalog'); const { getZone } = require('./services/zones'); const { getLimit: getSection179Limit } = require('./services/section179Limits'); @@ -233,6 +233,17 @@ function listedAdsRequired(asset, book, methodRule) { return use > 0 && use <= 0.5; } +// ADS recovery period (in months) for an asset, from its IRS asset class when available; otherwise the +// asset's configured life. Used when ADS is forced (listed property ≤50% business use). +function adsRecoveryMonths(asset, book) { + const classNumber = asset.asset_class_number; + if (classNumber) { + const row = one('SELECT ads FROM asset_classes WHERE class_number = ? AND ads IS NOT NULL AND ads > 0 ORDER BY id LIMIT 1', [String(classNumber)]); + if (row && Number(row.ads) > 0) return Math.round(Number(row.ads) * 12); + } + return Number(book.useful_life_months || asset.useful_life_months || 60); +} + // §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). @@ -430,6 +441,18 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule } // Rate table depreciation: (basis * rate). The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods. The rate is determined by the method rule's rates array based on the year index, with an optional fallback to straight-line when no rates are provided. +// MACRS optional percentage tables (Pub 946 Appendix A). Applies the published half-year percentage to +// the depreciable basis when a literal table exists for the recovery period; otherwise (mid-quarter or an +// unsupported period) falls back to the equivalent declining-balance formula. +function macrsTable(asset, book, index, methodRule, convention) { + const years = Math.max(1, Math.round((methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60) / 12)); + const table = convention === 'half_year' ? require('./macrs-tables').gdsHalfYear(years) : null; + if (!table) { + return decliningBalance(asset, book, index, years, Number(methodRule.rateMultiplier || 2), methodRule); + } + return depreciableBasis(asset, book, methodRule) * (Number(table[index] || 0) / 100); +} + function rateTable(asset, book, index, methodRule) { console.log(`${moment().format()} 🕒 Calculating rate table depreciation for asset ${asset.id}, index ${index}`); const basis = depreciableBasis(asset, book, methodRule); @@ -461,8 +484,9 @@ function annualSchedule(asset, book, ruleSet, options = {}) { // 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)` }; + const adsMonths = adsRecoveryMonths(asset, book); + book = { ...book, section_179_amount: 0, bonus_percent: 0, useful_life_months: adsMonths }; + methodRule = { ...methodRule, formula: 'straight_line', lifeMonths: adsMonths, 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)) { @@ -493,7 +517,10 @@ function annualSchedule(asset, book, ruleSet, options = {}) { } console.log(`${moment().format()} 🕒 Evaluating method rule for asset ${asset.id}, year ${year}`); - if (methodRule.formula === 'rate_table') { + if (methodRule.formula === 'macrs_table') { + // MACRS optional percentage tables (literal Pub 946 Appendix A) with formula fallback. + depreciation += macrsTable(asset, book, index, methodRule, conventionFor(book, methodRule, asset)); + } else if (methodRule.formula === 'rate_table') { // Note: for the rate table formula, we look up the rate for the current year index and apply it to the depreciable basis; if no rates are defined but a fallback to straight-line is specified, we use the straight-line formula instead with the total years determined by the method rule's lifeMonths or the book's useful_life_months. This allows for flexible handling of cases where a rate table is desired but specific rates haven't been configured, while still providing a reasonable default calculation. console.log(`${moment().format()} 🕒 Using rate table formula for asset ${asset.id}, year ${year}`); depreciation += rateTable(asset, book, index, methodRule); @@ -529,6 +556,12 @@ function annualSchedule(asset, book, ruleSet, options = {}) { if (allowed <= 0 && remaining > 0) allowed = Math.min(cap280f, remaining); depreciation = allowed; } + // Short tax year: prorate the year's MACRS depreciation by (months ÷ 12) for the company (simplified + // method; default fraction 1 leaves normal years unchanged). Lazy-required to avoid a load cycle. + if (index >= 0 && asset.entity_id) { + const f = require('./services/shortTaxYear').fraction(asset.entity_id, year); + if (f !== 1) depreciation *= f; + } const cap = recoverableBasis(asset, book, methodRule); depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated))); accumulated += depreciation; diff --git a/server/macrs-tables.js b/server/macrs-tables.js new file mode 100644 index 0000000..e96db14 --- /dev/null +++ b/server/macrs-tables.js @@ -0,0 +1,21 @@ +// IRS Publication 946, Appendix A — GDS optional depreciation tables (percentage of unadjusted basis +// deducted each recovery year), half-year convention. 3/5/7/10-year property use 200% declining balance; +// 15/20-year property use 150% declining balance. These are the published, rounded percentages used for +// the "MACRS Tables" method (MT200); the formula method reproduces them mathematically. Mid-quarter and +// other conventions fall back to the formula in the engine. + +const GDS_HALF_YEAR = { + 3: [33.33, 44.45, 14.81, 7.41], + 5: [20.00, 32.00, 19.20, 11.52, 11.52, 5.76], + 7: [14.29, 24.49, 17.49, 12.49, 8.93, 8.92, 8.93, 4.46], + 10: [10.00, 18.00, 14.40, 11.52, 9.22, 7.37, 6.55, 6.55, 6.56, 6.55, 3.28], + 15: [5.00, 9.50, 8.55, 7.70, 6.93, 6.23, 5.90, 5.90, 5.91, 5.90, 5.91, 5.90, 5.91, 5.90, 5.91, 2.95], + 20: [3.750, 7.219, 6.677, 6.177, 5.713, 5.285, 4.888, 4.522, 4.462, 4.461, 4.462, 4.461, 4.462, 4.461, 4.462, 4.461, 4.462, 4.461, 4.462, 4.461, 2.231] +}; + +// Returns the published half-year percentage array for a recovery period (years), or null if unsupported. +function gdsHalfYear(years) { + return GDS_HALF_YEAR[years] || null; +} + +module.exports = { gdsHalfYear }; diff --git a/server/routes/disposals.js b/server/routes/disposals.js index c45a7e1..799228b 100644 --- a/server/routes/disposals.js +++ b/server/routes/disposals.js @@ -3,6 +3,7 @@ const { requireCapability } = require('../middleware/auth'); const { deleteAdjustment, previewDisposal, + previewSection280fRecapture, recordAdjustment, recordDisposal, reverseDisposal @@ -16,6 +17,13 @@ router.post('/assets/:id/disposal/preview', (req, res) => { return res.json({ preview }); }); +// §280F recapture preview if this listed/passenger asset's business use drops to ≤50% in the given year. +router.get('/assets/:id/section-280f-recapture', (req, res) => { + const result = previewSection280fRecapture(req.params.id, { book: req.query.book, year: req.query.year }); + if (!result) return res.status(404).json({ error: 'Asset not found' }); + return res.json({ recapture: result }); +}); + router.post('/assets/:id/disposals', requireCapability('finance.manage'), (req, res) => { const result = recordDisposal(req.params.id, req.body, req.user.id); if (!result) return res.status(404).json({ error: 'Asset not found' }); diff --git a/server/routes/reference.js b/server/routes/reference.js index edf0421..a9cc4dc 100644 --- a/server/routes/reference.js +++ b/server/routes/reference.js @@ -7,6 +7,7 @@ const assetClasses = require('../services/assetClasses'); const zones = require('../services/zones'); const section179Limits = require('../services/section179Limits'); const section280fLimits = require('../services/section280fLimits'); +const shortTaxYear = require('../services/shortTaxYear'); const router = express.Router(); @@ -96,6 +97,24 @@ router.delete('/section-280f-limits/:year', requireCapability('config.manage'), return res.status(204).end(); }); +// Short tax years (per company) — prorate that year's MACRS depreciation. +router.get('/short-tax-years', (req, res) => { + res.json({ years: shortTaxYear.list(req.companyId) }); +}); + +router.post('/short-tax-years', requireCapability('config.manage'), (req, res, next) => { + try { + res.status(201).json({ year: shortTaxYear.save(req.companyId, req.body, req.user.id) }); + } catch (error) { + next(error); + } +}); + +router.delete('/short-tax-years/:year', requireCapability('config.manage'), (req, res) => { + if (!shortTaxYear.remove(req.companyId, req.params.year, req.user.id)) return res.status(404).json({ error: '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/rule-catalog.js b/server/rule-catalog.js index 9297d07..28a863d 100644 --- a/server/rule-catalog.js +++ b/server/rule-catalog.js @@ -146,7 +146,7 @@ function build200Methods() { method('MF200', 'MACRS', 'MF200 — MACRS Formula, 200% DB (GDS)', 'macrs_declining_balance', { system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true }), - method('MT200', 'MACRS', 'MT200 — MACRS Tables, 200% DB (GDS)', 'macrs_declining_balance', { + method('MT200', 'MACRS', 'MT200 — MACRS Tables, 200% DB (GDS)', 'macrs_table', { system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true, usesTables: true }), method('MI200', 'MACRS', 'MI200 — MACRS 200% DB, Indian Reservation', 'macrs_declining_balance', { diff --git a/server/services/assets.js b/server/services/assets.js index 7bfab81..76b5f3e 100644 --- a/server/services/assets.js +++ b/server/services/assets.js @@ -32,6 +32,8 @@ const assetColumns = [ 'passenger_auto', 'business_use_percent', 'investment_use_percent', + 'soil_water_deductions', + 'cost_sharing_excluded', 'disposal_date', 'disposal_price', 'disposal_expense', @@ -64,6 +66,8 @@ function assetFromRow(row) { ...row, listed_property: Boolean(row.listed_property), passenger_auto: Boolean(row.passenger_auto), + soil_water_deductions: Number(row.soil_water_deductions || 0), + cost_sharing_excluded: Number(row.cost_sharing_excluded || 0), custom_fields: parseJson(row.custom_fields, {}), books: row.books ? parseJson(row.books, []) : undefined }; @@ -213,6 +217,8 @@ function normalizeAssetInput(body) { input.prior_depreciation = Number(input.prior_depreciation || 0); input.business_use_percent = Number(input.business_use_percent || 100); input.investment_use_percent = Number(input.investment_use_percent || 0); + input.soil_water_deductions = Number(input.soil_water_deductions || 0); + input.cost_sharing_excluded = Number(input.cost_sharing_excluded || 0); input.custom_fields = json(input.custom_fields || {}); input.listed_property = input.listed_property ? 1 : 0; input.passenger_auto = input.passenger_auto ? 1 : 0; diff --git a/server/services/disposals.js b/server/services/disposals.js index 85e4df8..4150690 100644 --- a/server/services/disposals.js +++ b/server/services/disposals.js @@ -38,11 +38,34 @@ function isRealProperty(asset, book) { return months >= 330 || /real|realty|1250|building/.test(pt); } +// Whole years a property was held (for the §1252 / §1255 applicable-percentage schedules). +function yearsHeld(asset, disposalDate) { + const acq = asset.acquired_date || asset.in_service_date; + if (!acq) return 0; + return Math.max(0, Math.floor((new Date(`${disposalDate}T00:00:00`) - new Date(`${acq}T00:00:00`)) / (365.25 * 86400000))); +} + +// §1252 applicable percentage (IRS Pub 225): 100% if farmland is disposed of within 5 years; then reduced +// 20 points per year for years 6–10; 0% after 10 years. +function section1252Percent(years) { + if (years <= 5) return 1; + return Math.max(0, 1 - 0.2 * (years - 5)); +} + +// §1255 applicable percentage: 100% if held under 10 years; reduced 10 points for each year (or part) the +// property was held beyond 10 years; 0% at 20 years. +function section1255Percent(years) { + if (years < 10) return 1; + return Math.max(0, 1 - 0.1 * (years - 9)); +} + // 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) { +// remaining gain up to straight-line taken is unrecaptured §1250 gain (a 25% capital bucket). Farm +// property adds §1252 (soil & water conservation, §175) and §1255 (cost-sharing, §126) ordinary recapture, +// each by an applicable percentage of the years held and limited to the remaining gain. +function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, accumulated, disposalDate) { const section = isRealProperty(asset, book) ? '1250' : '1245'; const gain = Math.max(0, gainLoss); let ordinary = 0; @@ -51,7 +74,6 @@ function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, a 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); @@ -60,11 +82,21 @@ function characterizeRecapture(asset, book, rules, year, proportion, gainLoss, a unrecaptured1250 = Math.min(money(gain - ordinary), slAccum); } } + // Farm-property recapture (§1252 / §1255), applied to the gain remaining after depreciation recapture. + const years = yearsHeld(asset, disposalDate); + let remaining = Math.max(0, gain - ordinary); + const r1252 = Math.min(remaining, money(section1252Percent(years) * Number(asset.soil_water_deductions || 0) * proportion)); + remaining = Math.max(0, remaining - r1252); + const r1255 = Math.min(remaining, money(section1255Percent(years) * Number(asset.cost_sharing_excluded || 0) * proportion)); + const totalOrdinary = money(ordinary + r1252 + r1255); + return { recapture_section: section, ordinary_recapture: money(ordinary), unrecaptured_1250_gain: money(unrecaptured1250), - section_1231_gain: money(gainLoss - ordinary) // negative for a §1231 loss + section_1252_recapture: money(r1252), + section_1255_recapture: money(r1255), + section_1231_gain: money(gainLoss - totalOrdinary) // negative for a §1231 loss }; } @@ -95,7 +127,7 @@ function computeDisposal(asset, input = {}) { // 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)); + const recapture = characterizeRecapture(asset, book, rules, year, proportion, gainLoss, money(accumulated + impairment), disposalDate); return { book_type: bookType, @@ -146,14 +178,15 @@ function recordDisposal(assetId, input, userId) { asset_id, disposal_date, method, property_type, partial, disposed_cost, disposal_price, disposal_expense, accumulated_depreciation, net_book_value, gain_loss, book_type, recapture_section, ordinary_recapture, section_1231_gain, - unrecaptured_1250_gain, notes, created_by, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + unrecaptured_1250_gain, section_1252_recapture, section_1255_recapture, 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, result.recapture_section, result.ordinary_recapture, result.section_1231_gain, - result.unrecaptured_1250_gain, input.notes || null, userId, timestamp + result.unrecaptured_1250_gain, result.section_1252_recapture, result.section_1255_recapture, + input.notes || null, userId, timestamp ] ); diff --git a/server/services/reportEngine.js b/server/services/reportEngine.js index 2911955..fe43187 100644 --- a/server/services/reportEngine.js +++ b/server/services/reportEngine.js @@ -296,16 +296,22 @@ function disposals(options) { disposal_date: row.disposal_date, proceeds: money(row.disposal_price - row.disposal_expense), net_book_value: money(row.net_book_value), - gain_loss: money(row.gain_loss) + gain_loss: money(row.gain_loss), + recapture_section: row.recapture_section ? `§${row.recapture_section}` : '', + ordinary_recapture: money(row.ordinary_recapture || 0), + farm_recapture: money((row.section_1252_recapture || 0) + (row.section_1255_recapture || 0)), + section_1231_gain: money(row.section_1231_gain || 0) })); return { columns: [ col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Type'), col('disposal_date', 'Date', 'date'), col('proceeds', 'Net proceeds', 'currency'), - col('net_book_value', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency') + col('net_book_value', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency'), + col('recapture_section', 'Recapture'), col('ordinary_recapture', 'Ordinary recapture', 'currency'), + col('farm_recapture', '§1252/§1255 (farm)', 'currency'), col('section_1231_gain', '§1231 gain / (loss)', 'currency') ], rows, - totals: sumBy(rows, ['proceeds', 'net_book_value', 'gain_loss']) + totals: sumBy(rows, ['proceeds', 'net_book_value', 'gain_loss', 'ordinary_recapture', 'farm_recapture', 'section_1231_gain']) }; } diff --git a/server/services/shortTaxYear.js b/server/services/shortTaxYear.js new file mode 100644 index 0000000..6eca0ca --- /dev/null +++ b/server/services/shortTaxYear.js @@ -0,0 +1,55 @@ +const { all, audit, now, one, run } = require('../db'); + +// Short tax years (IRS Pub 946): when a company's tax year is shorter than 12 months, that year's MACRS +// depreciation is prorated. This is the simplified proration — the year's deduction is multiplied by +// (months ÷ 12); it is most accurate for the placed-in-service (first) short year. Per company + year, +// cached for the engine and invalidated on edits. + +const cache = new Map(); // `${entityId}|${year}` -> fraction + +function clearCache() { + cache.clear(); +} + +// Proration fraction (0–1) for a company's tax year; 1 when the year is a normal 12-month year. +function fraction(entityId, year) { + if (!entityId || !year) return 1; + const key = `${entityId}|${Number(year)}`; + if (cache.has(key)) return cache.get(key); + const row = one('SELECT months FROM short_tax_years WHERE entity_id = ? AND tax_year = ?', [entityId, Number(year)]); + const months = row ? Math.max(0, Math.min(12, Number(row.months) || 12)) : 12; + const f = months / 12; + cache.set(key, f); + return f; +} + +function list(companyId) { + return all('SELECT * FROM short_tax_years WHERE entity_id = ? ORDER BY tax_year DESC', [companyId]); +} + +function save(companyId, body, userId) { + const taxYear = Math.trunc(Number(body.tax_year)) || 0; + if (!taxYear) throw Object.assign(new Error('A valid tax year is required'), { status: 400 }); + const months = Math.max(1, Math.min(12, Number(body.months) || 12)); + run( + `INSERT INTO short_tax_years (entity_id, tax_year, months, note, updated_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(entity_id, tax_year) DO UPDATE SET months = excluded.months, note = excluded.note, + updated_by = excluded.updated_by, updated_at = excluded.updated_at`, + [companyId, taxYear, months, body.note || null, userId, now()] + ); + clearCache(); + audit(userId, 'update', 'short_tax_year', `${companyId}:${taxYear}`, null, { tax_year: taxYear, months }); + return one('SELECT * FROM short_tax_years WHERE entity_id = ? AND tax_year = ?', [companyId, taxYear]); +} + +function remove(companyId, taxYear, userId) { + const before = one('SELECT * FROM short_tax_years WHERE entity_id = ? AND tax_year = ?', [companyId, Number(taxYear)]); + if (!before) return false; + run('DELETE FROM short_tax_years WHERE entity_id = ? AND tax_year = ?', [companyId, Number(taxYear)]); + clearCache(); + audit(userId, 'delete', 'short_tax_year', `${companyId}:${taxYear}`, before, null); + return true; +} + +module.exports = { clearCache, fraction, list, remove, save }; diff --git a/server/smoke-test.js b/server/smoke-test.js index 992ff79..e5ab325 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -2030,6 +2030,52 @@ async function main() { 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}`); + // ---- Depreciation recapture: §1245 on disposal + §280F conversion (Form 4797) --- + // Fully-depreciated equipment (cost 10k, placed 2018) sold for 12k in 2026 → gain 12k: §1245 ordinary + // recapture = depreciation taken (10k), the remaining 2k is §1231 capital gain. + const eq = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `EQ-${suffix}`, description: 'Equipment', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2018-01-01', in_service_date: '2018-01-01', useful_life_months: 60, books: [{ 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: 10000 }] }) })).asset; + const recapDisp = (await request(`/api/assets/${eq.id}/disposal/preview`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ book_type: 'FEDERAL', disposal_date: '2026-08-01', disposal_price: 12000 }) })).preview; + if (recapDisp.recapture_section !== '1245') throw new Error(`Recapture section wrong: ${disp.recapture_section}`); + if (recapDisp.ordinary_recapture !== 10000) throw new Error(`§1245 ordinary recapture wrong: expected 10,000, got ${recapDisp.ordinary_recapture}`); + if (recapDisp.section_1231_gain !== 2000) throw new Error(`§1231 gain wrong: expected 2,000, got ${recapDisp.section_1231_gain}`); + + // §280F conversion recapture: listed PC at 100% use (2024) converting to ≤50% in 2026 → excess of + // accelerated-over-ADS through 2025 = 5,200 − 3,000 = 2,200 ordinary income. + const pc = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `PC-${suffix}`, description: 'Listed PC', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-01-01', in_service_date: '2024-01-01', useful_life_months: 60, business_use_percent: 100, listed_property: true, books: [{ 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: 10000 }] }) })).asset; + const rec280 = (await request(`/api/assets/${pc.id}/section-280f-recapture?book=FEDERAL&year=2026`, { headers: aHeaders })).recapture; + if (!rec280.applies || rec280.recapture !== 2200) throw new Error(`§280F recapture wrong: ${JSON.stringify(rec280)}`); + + // Farm-property recapture (IRS Pub 225): §1252 soil/water (§175) + §1255 cost-sharing (§126). Farmland + // held 7 years with $20k §175 and $10k §126, sold at a $50k gain → §1252 = 60% × 20k = 12k; §1255 = 10k. + const farm = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `FARM-${suffix}`, description: 'Farmland', category: 'Computer Equipment', acquisition_cost: 100000, land_value: 100000, in_service_date: '2019-06-01', acquired_date: '2019-06-01', useful_life_months: 0, soil_water_deductions: 20000, cost_sharing_excluded: 10000, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', useful_life_months: 0, cost: 100000 }] }) })).asset; + const farmDisp = (await request(`/api/assets/${farm.id}/disposal/preview`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ book_type: 'FEDERAL', disposal_date: '2026-07-01', disposal_price: 150000 }) })).preview; + if (farmDisp.section_1252_recapture !== 12000) throw new Error(`§1252 recapture wrong: expected 12,000, got ${farmDisp.section_1252_recapture}`); + if (farmDisp.section_1255_recapture !== 10000) throw new Error(`§1255 recapture wrong: expected 10,000, got ${farmDisp.section_1255_recapture}`); + if (farmDisp.section_1231_gain !== 28000) throw new Error(`Farm §1231 gain wrong: expected 28,000, got ${farmDisp.section_1231_gain}`); + + // ---- Literal MACRS tables (MT200), true ADS recovery period, short tax year --- + const deprRow = async (assetId, year) => (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year } }) })).report.rows.find((r) => r.asset_id === assetId); + // #1 MT200 uses the literal Pub 946 Appendix-A percentages (5-year property year-1 = 20% = 2,000). + await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MT-${suffix}`, description: 'MT200', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-05-01', in_service_date: '2024-05-01', useful_life_months: 60, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MT200', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) }); + const mtRow = await deprRow(`MT-${suffix}`, 2024); + if (!mtRow || Math.abs(mtRow.depreciation - 2000) > 1) throw new Error(`MT200 literal table year-1 wrong: expected 2,000, got ${mtRow && mtRow.depreciation}`); + + // #3 Listed property ≤50% business use uses the ADS recovery period from its asset class (GDS 7 / ADS 10): + // basis 10k×40% = 4k, ADS 10-year straight-line, half-year → 200 (not the 7-year GDS life). + await request('/api/asset-classes', { method: 'POST', headers: auth, body: JSON.stringify({ class_number: `88.${suffix}`, description: 'ADS test', gds: 7, ads: 10 }) }); + await request('/api/asset-categories', { method: 'POST', headers: aHeaders, body: JSON.stringify({ name: `ADSCat-${suffix}`, asset_class_number: `88.${suffix}` }) }); + await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `ADS-${suffix}`, description: 'ADS', category: `ADSCat-${suffix}`, acquisition_cost: 10000, acquired_date: '2024-05-01', in_service_date: '2024-05-01', useful_life_months: 84, business_use_percent: 40, listed_property: true, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_7', convention: 'half_year', useful_life_months: 84, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) }); + const adsRow = await deprRow(`ADS-${suffix}`, 2024); + if (!adsRow || Math.abs(adsRow.depreciation - 200) > 1) throw new Error(`ADS recovery period not applied: expected 200 (10-yr ADS), got ${adsRow && adsRow.depreciation}`); + + // #2 Short tax year prorates that year's MACRS depreciation by months ÷ 12 (2,000 → 1,000 for 6 months). + await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `STY-${suffix}`, description: 'STY', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2033-01-01', in_service_date: '2033-01-01', useful_life_months: 60, books: [{ 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: 10000 }] }) }); + const styBaseline = await deprRow(`STY-${suffix}`, 2033); + if (!styBaseline || Math.abs(styBaseline.depreciation - 2000) > 1) throw new Error(`Short-year baseline wrong: ${styBaseline && styBaseline.depreciation}`); + await request('/api/short-tax-years', { method: 'POST', headers: aHeaders, body: JSON.stringify({ tax_year: 2033, months: 6 }) }); + const styShort = await deprRow(`STY-${suffix}`, 2033); + if (!styShort || Math.abs(styShort.depreciation - 1000) > 1) throw new Error(`Short tax year not prorated: expected 1,000, got ${styShort && styShort.depreciation}`); + console.log('Smoke test passed'); } diff --git a/src/components/AssetDrawer.vue b/src/components/AssetDrawer.vue index 62672ba..b5c4c13 100644 --- a/src/components/AssetDrawer.vue +++ b/src/components/AssetDrawer.vue @@ -55,6 +55,8 @@ + + {{ disposalPreview.gain_loss >= 0 ? 'Gain' : 'Loss' }} of {{ currency(Math.abs(disposalPreview.gain_loss)) }} +
+ Form 4797 §{{ disposalPreview.recapture_section }}: + {{ currency(disposalPreview.ordinary_recapture) }} ordinary recapture + · {{ currency(disposalPreview.section_1252_recapture) }} §1252 (soil/water) + · {{ currency(disposalPreview.section_1255_recapture) }} §1255 (cost-sharing) + · {{ currency(disposalPreview.section_1231_gain) }} §1231 gain + · {{ currency(disposalPreview.unrecaptured_1250_gain) }} unrecaptured §1250 (25%) +
diff --git a/src/components/ShortTaxYearSettings.vue b/src/components/ShortTaxYearSettings.vue new file mode 100644 index 0000000..44e5ffb --- /dev/null +++ b/src/components/ShortTaxYearSettings.vue @@ -0,0 +1,92 @@ + + + diff --git a/src/components/UserManual.vue b/src/components/UserManual.vue index 5c2c47c..286a485 100644 --- a/src/components/UserManual.vue +++ b/src/components/UserManual.vue @@ -298,10 +298,17 @@

Each book’s depreciation is computed from the depreciation-critical fields: property type, placed-in-service date, acquisition value, depreciation method, estimated life, salvage value, Section 168 (bonus) allowance %, Section 179, and - business-use %. Supported 200% declining-balance methods include MF200 (MACRS formula), - MT200 (MACRS tables), and MI200 (MACRS, Indian-reservation) — which ignore salvage and - recover the full basis — and DB200, DH200 (half-year), and DD200 (full-year), - which honor salvage (depreciating only down to it). All switch to straight-line when that yields a larger deduction. + business-use %. Supported 200% declining-balance methods include MF200 (MACRS by formula), + MT200 (MACRS by the literal IRS Pub 946 Appendix-A percentage tables for half-year + 3/5/7/10/15/20-year property — mid-quarter falls back to the equivalent formula), and MI200 + (MACRS, Indian-reservation) — which ignore salvage and recover the full basis — and DB200, + DH200 (half-year), and DD200 (full-year), which honor salvage. All switch to + straight-line when that yields a larger deduction. +

+

+ Short tax years. If a company's tax year is shorter than 12 months (for example, the year it is + formed), define it in Admin → Application Configuration → Short tax years and that year's MACRS + depreciation is prorated by months ÷ 12 (the simplified method).

When you change a depreciation-critical field on an asset that already has depreciation, a prompt asks when to apply @@ -346,7 +353,8 @@
  • 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.
  • + (over the ADS recovery period from the asset's IRS class when set) 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 @@ -369,6 +377,52 @@ picks it up as additional available §179.

    +

    Depreciation recapture (Form 4797) & §280F recapture

    +

    + When you dispose of an asset at a gain, the system characterizes that gain for Form 4797: +

    +
      +
    • §1245 (equipment / personal property) — the gain is ordinary-income recapture + up to the total depreciation taken (including §179 and bonus); any gain beyond that is a §1231 + (capital) gain.
    • +
    • §1250 (real property) — ordinary recapture applies only to additional depreciation + (accelerated over straight-line); since modern MACRS real property is straight-line this is usually zero, and the + gain up to straight-line depreciation is shown as unrecaptured §1250 gain (the 25% bucket).
    • +
    +

    + The breakdown appears on the disposal preview (and is stored on the disposal and shown on the + Disposals report). A loss is not recaptured. Recapture is computed on the book you dispose against — use a tax book + (e.g. FEDERAL) for the tax characterization. +

    + + §280F recapture. If a listed-property or passenger-automobile asset's qualified business use drops to + 50% or less, the excess of the accelerated depreciation already claimed over what ADS straight-line + would have allowed must be recaptured as ordinary income. The asset's detail view can preview this + recapture for a chosen conversion year (excess of accelerated-over-ADS through the prior year). + + +

    Farm property (IRS Pub 225)

    +

    + Agricultural assets are handled through the same depreciation engine, with farm-specific support: +

    +
      +
    • Recovery periods. The IRS asset-class catalog includes the farm classes — agricultural + machinery (7-yr), farm buildings (20-yr), single-purpose agricultural/horticultural structures (10-yr), fences + and grain bins (7-yr), land improvements like wells/irrigation/drainage (15-yr), breeding/dairy/draft livestock + and horses (3/5/7-yr by type), and orchards & vineyards — so picking the right asset class sets the period.
    • +
    • Method. Farm property generally uses 150% declining balance (the + macrs_gds_150db_* methods); 3/5/7/10-year farm property placed in service after 2017 may use 200% DB, + while 15- and 20-year property stays at 150%. ADS straight-line is available (and required when you elect out of + the uniform-capitalization rules — choose an ADS method for that book).
    • +
    • §179, bonus & conventions apply to farm property (including single-purpose structures and + grain bins), with the half-year and mid-quarter conventions handled automatically.
    • +
    • Farm disposition recapture. Beyond §1245/§1250, disposing of farm property recaptures + §1252 (soil & water conservation deductions, §175) and §1255 (excluded + cost-sharing payments, §126) as ordinary income, by an applicable percentage based on years held. Enter those + amounts on the asset's Details tab (Soil & water conservation / Cost-sharing) and the disposal preview and + Disposals report show the §1252/§1255 recapture.
    • +
    +

    The PM (maintenance) book

    A dedicated PM book tracks actual maintenance spend per asset rather than depreciation. Its ledger @@ -894,7 +948,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, §280F passenger-auto limits, asset ID templates, asset categories, + settings, asset classes, depreciation zones, Section 179 limits, §280F passenger-auto limits, short tax years, 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.

    @@ -1216,7 +1270,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 280f listed property passenger automobile luxury auto caps ads straight line mid-quarter limitation carryover taxable income macrs' }, + { 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 recapture 1245 1250 1231 form 4797 ordinary gain disposal mt200 tables appendix a short tax year proration ads recovery period farm pub 225 agriculture livestock 1252 1255 soil water conservation cost sharing 150 declining balance' }, { 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/views/AdminView.vue b/src/views/AdminView.vue index 505b12e..c61826c 100644 --- a/src/views/AdminView.vue +++ b/src/views/AdminView.vue @@ -196,6 +196,8 @@ + + @@ -326,6 +328,7 @@ 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 ShortTaxYearSettings from '../components/ShortTaxYearSettings.vue'; import NotificationSettings from '../components/NotificationSettings.vue'; import PartCategorySettings from '../components/PartCategorySettings.vue'; import PmCategorySettings from '../components/PmCategorySettings.vue'; @@ -335,7 +338,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, Section280fLimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings }, + components: { AppLogsSettings, AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, CompanySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, Section280fLimitSettings, ShortTaxYearSettings, ServiceNowSettings, TeamsSettings, WebhookSettings }, props: { token: { type: String, default: '' }, section: { type: String, default: 'admin-users' },