Updated A LOT

This commit is contained in:
2026-06-05 04:15:24 -05:00
parent 8335f1af1b
commit 4072695432
92 changed files with 16139 additions and 570 deletions

View File

@@ -7,14 +7,22 @@ const { createCorsMiddleware } = require('./middleware/cors');
const { requireAuth } = require('./middleware/auth');
const adminRoutes = require('./routes/admin');
const alertRoutes = require('./routes/alerts');
const assetRoutes = require('./routes/assets');
const assignmentRoutes = require('./routes/assignments');
const authRoutes = require('./routes/auth');
const bookRoutes = require('./routes/books');
const contactRoutes = require('./routes/contacts');
const dashboardRoutes = require('./routes/dashboard');
const dataPortabilityRoutes = require('./routes/dataPortability');
const disposalRoutes = require('./routes/disposals');
const leaseRoutes = require('./routes/leases');
const partRoutes = require('./routes/parts');
const pmRoutes = require('./routes/pm');
const profileRoutes = require('./routes/profile');
const referenceRoutes = require('./routes/reference');
const reportRoutes = require('./routes/reports');
const serviceNowRoutes = require('./routes/servicenow');
const taxRuleRoutes = require('./routes/taxRules');
const templateRoutes = require('./routes/templates');
const workdayRoutes = require('./routes/workday');
@@ -36,10 +44,18 @@ function createApp() {
app.use('/api', dashboardRoutes);
app.use('/api', referenceRoutes);
app.use('/api', assetRoutes);
app.use('/api', alertRoutes);
app.use('/api', bookRoutes);
app.use('/api', contactRoutes);
app.use('/api', disposalRoutes);
app.use('/api', leaseRoutes);
app.use('/api', partRoutes);
app.use('/api', pmRoutes);
app.use('/api', assignmentRoutes);
app.use('/api', templateRoutes);
app.use('/api', taxRuleRoutes);
app.use('/api', reportRoutes);
app.use('/api', serviceNowRoutes);
app.use('/api', dataPortabilityRoutes);
app.use('/api', adminRoutes);
app.use('/api', workdayRoutes);

View File

@@ -0,0 +1,342 @@
// IRS Table 1 — Alphabetical Listing of Commonly Used Assets (MACRS asset classes with
// GDS and ADS recovery periods). Source: IRS Pub. 946 as published by Sage. Reference data
// used to pre-populate the Asset Class Number on asset categories. null = no class number /
// recovery period determined by another method (amortization, income forecasting, etc.).
// Verify against current tax law before relying on these for a filing.
// IRS Table 1 — Alphabetical Listing of Commonly Used Assets.
const COMMON = [
{ description: 'Adding Machines', class_number: '00.13', gds: 5, ads: 6 },
{ 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 },
{ description: 'Appliances in Rental Property', class_number: null, gds: 5, ads: 9 },
{ description: 'Automobiles', class_number: '00.22', gds: 5, ads: 5 },
{ description: 'Barges', class_number: '00.28', gds: 10, ads: 18 },
{ description: 'Billboards', class_number: '57.1', gds: 15, ads: 20 },
{ description: 'Boats', class_number: '00.28', gds: 10, ads: 18 },
{ description: 'Bookcases', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Books (Professional Library)', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Bridges', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Buildings, Residential Rental', class_number: null, gds: 27.5, ads: 30 },
{ description: 'Buildings, if not Residential Rental', class_number: null, gds: 39, ads: 40 },
{ description: 'Buildings, Car Wash', class_number: '57.1', gds: 15, ads: 20 },
{ description: 'Buildings, Farm (not single-purpose structures)', class_number: '01.3', gds: 20, ads: 25 },
{ description: 'Buildings, Service Station', class_number: '57.1', gds: 15, ads: 20 },
{ description: 'Buildings, Telephone Central Office', class_number: '48.11', gds: 20, ads: 45 },
{ description: 'Buses', class_number: '00.23', gds: 5, ads: 9 },
{ description: 'Calculators', class_number: '00.13', gds: 5, ads: 6 },
{ description: 'Canals', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Carpeting', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Cars', class_number: '00.22', gds: 5, ads: 5 },
{ description: 'Car Phones', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Car Wash Buildings', class_number: '57.1', gds: 15, ads: 20 },
{ description: 'Cash Registers', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Cattle, Breeding or Dairy', class_number: '01.21', gds: 5, ads: 7 },
{ description: 'Cell Phones', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Coin-Operated Dispensing Machines', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Communications Equipment (not included elsewhere)', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Computers', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Computer Peripheral Equipment', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Computer Software Amortized', class_number: null, gds: null, ads: null },
{ description: 'Concrete Ready Mix Trucks', class_number: '00.242', gds: 5, ads: 6 },
{ description: 'Construction Assets', class_number: '15.0', gds: 5, ads: 6 },
{ description: 'Copiers', class_number: '00.13', gds: 5, ads: 6 },
{ description: 'Copyrights Amortized', class_number: null, gds: null, ads: null },
{ description: 'Cotton Ginning Assets', class_number: '01.11', gds: 7, ads: 12 },
{ description: 'Covenants Not to Compete Amortized', class_number: null, gds: null, ads: null },
{ description: 'Customer Lists Amortized', class_number: null, gds: null, ads: null },
{ description: 'Data Entry Devices', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Desks', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Disk Drives', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Disk Files', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Disk Packs', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Docks', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Drainage Facilities', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Duplicating Equipment', class_number: '00.13', gds: 5, ads: 6 },
{ description: 'Elevators, if Residential Rental', class_number: null, gds: 27.5, ads: 30 },
{ description: 'Elevators, if not Residential Rental', class_number: null, gds: 39, ads: 40 },
{ description: 'Escalators, if Residential Rental', class_number: null, gds: 27.5, ads: 30 },
{ description: 'Escalators, if not Residential Rental', class_number: null, gds: 39, ads: 40 },
{ description: 'Farm Buildings (not single-purpose structures)', class_number: '01.3', gds: 20, ads: 25 },
{ description: 'Fax Machines', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Fences for Agriculture', class_number: '01.1', gds: 7, ads: 10 },
{ description: 'Fences not for Agriculture', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'File Cabinets', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Films', class_number: null, gds: null, ads: null },
{ description: 'Fixtures, Office', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Fixtures in Rental Property', class_number: null, gds: 5, ads: 9 },
{ description: 'Franchises Amortized', class_number: null, gds: null, ads: null },
{ description: 'Furniture, Office', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Furniture in Rental Property', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Goats, Breeding', class_number: '01.24', gds: 5, ads: 5 },
{ description: 'Goodwill Amortized', class_number: null, gds: null, ads: null },
{ description: 'Grain Bins', class_number: '01.1', gds: 7, ads: 10 },
{ description: 'Helicopters', class_number: '00.21', gds: 5, ads: 6 },
{ description: 'Hogs, Breeding', class_number: '01.23', gds: 3, ads: 3 },
{ description: 'Horses, Breeding or Work (12 years old or less)', class_number: '01.221', gds: 7, ads: 10 },
{ description: 'Horses, Breeding or Work (more than 12 years old)', class_number: '01.222', gds: 3, ads: 10 },
{ description: 'Horses, Race (more than 2 years old at placed in service date)', class_number: '01.223', gds: 3, ads: 12 },
{ description: 'Horses, not Race, Breeding, or Work (more than 12 years old)', class_number: '01.224', gds: 3, ads: 12 },
{ description: 'Horses, Other (not described in any other category)', class_number: '01.225', gds: 7, ads: 12 },
{ description: 'Hotels', class_number: null, gds: 39, ads: 40 },
{ description: 'Land Improvements (not in any other class)', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Landscaping Shrubbery', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Leasehold Improvements', class_number: null, gds: 39, ads: 40 },
{ description: 'Magnetic Tape Feeds', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Mobile Homes, if Residential Rental', class_number: null, gds: 27.5, ads: 40 },
{ description: 'Mobile Homes, if not Residential Rental', class_number: null, gds: 39, ads: 40 },
{ description: 'Motels', class_number: null, gds: 39, ads: 40 },
{ description: 'Motion Pictures', class_number: null, gds: null, ads: null },
{ description: 'Motorsports Entertainment Complex', class_number: null, gds: 7, ads: 40 },
{ description: 'Nonresidential Real Property', class_number: null, gds: 39, ads: 40 },
{ description: 'Office Furniture', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Optical Character Readers', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Ore Trucks', class_number: '00.242', gds: 5, ads: 6 },
{ description: 'Organization Costs Amortized', class_number: null, gds: null, ads: null },
{ description: 'Patents Amortized', class_number: null, gds: null, ads: null },
{ description: 'Plotters', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Printers', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Qualified Improvement Property', class_number: null, gds: 15, ads: 20 },
{ description: 'Qualified Restaurant Property', class_number: null, gds: null, ads: null },
{ description: 'Qualified Retail Property', class_number: null, gds: null, ads: null },
{ description: 'Radio Transmitting Towers', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Railroad Cars and Locomotives, Commercial', class_number: '40.1', gds: 7, ads: 14 },
{ description: 'Railroad Cars and Locomotives, if not Commercial', class_number: '00.25', gds: 7, ads: 15 },
{ description: 'Railroad Machinery and Equipment', class_number: '40.1', gds: 7, ads: 14 },
{ description: 'Railroad Structures and Improvements', class_number: '40.2', gds: 20, ads: 30 },
{ description: 'Railroad Track', class_number: '40.4', gds: 7, ads: 10 },
{ description: 'Recreational Vehicles (RV) (Unloaded Weight 13,000 lbs or More)', class_number: '00.242', gds: 5, ads: 6 },
{ description: 'Recreational Vehicles (RV) (Unloaded Weight Less than 13,000 lbs)', class_number: '00.241', gds: 5, ads: 5 },
{ description: 'Rent-to-Own Property', class_number: null, gds: 3, ads: 4 },
{ description: 'Rental Formal Wear', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Rental Tuxedos', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Research and Development', class_number: null, gds: 5, ads: null },
{ description: 'Residential Rental Real Property', class_number: null, gds: 27.5, ads: 30 },
{ description: 'Roads', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Safes', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Service Station Buildings', class_number: '57.1', gds: 15, ads: 20 },
{ description: 'Sewers, Municipal', class_number: '51.', gds: 25, ads: 50 },
{ description: 'Sewers, if not Municipal Sewers', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Sheep, Breeding', class_number: '01.24', gds: 5, ads: 5 },
{ description: 'Ships', class_number: '00.28', gds: 10, ads: 18 },
{ description: 'Shrubbery, Landscaping', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Sidewalks', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Single-Purpose Agricultural Structures', class_number: '01.4', gds: 10, ads: 15 },
{ description: 'Single-Purpose Horticultural Structures', class_number: '01.4', gds: 10, ads: 15 },
{ description: 'Slot Machines', class_number: '79.0', gds: 7, ads: 10 },
{ description: 'Smart Electric Meter', class_number: null, gds: 10, ads: 30 },
{ description: 'Smart Grid System', class_number: null, gds: 10, ads: 30 },
{ description: 'Sound Recordings', class_number: null, gds: null, ads: null },
{ description: 'Syndication Expenses Capitalized', class_number: null, gds: null, ads: null },
{ description: 'Tape Cassettes', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Tape Drives', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Taxis', class_number: '00.22', gds: 5, ads: 5 },
{ description: 'Telephones', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Telephone Central Office Buildings', class_number: '48.11', gds: 20, ads: 45 },
{ description: 'Telephone Central Office Equipment', class_number: '48.12', gds: 10, ads: 18 },
{ description: 'Telephone Distribution Plants', class_number: '48.14', gds: 15, ads: 24 },
{ description: 'Telephone Systems', class_number: '00.11', gds: 7, ads: 10 },
{ description: 'Teleprinters', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Television Transmitting Towers', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Terminals', class_number: '00.12', gds: 5, ads: 5 },
{ description: 'Tractor Units for Use Over the Road', class_number: '00.26', gds: 3, ads: 4 },
{ description: 'Trade Names Amortized', class_number: null, gds: null, ads: null },
{ description: 'Trademarks Amortized', class_number: null, gds: null, ads: null },
{ description: 'Trailer-Mounted Containers', class_number: '00.27', gds: 5, ads: 6 },
{ description: 'Trailers', class_number: '00.27', gds: 5, ads: 6 },
{ description: 'Trees Bearing Fruits or Nuts', class_number: null, gds: 10, ads: 20 },
{ description: 'Trucks, Heavy (13,000 lbs or more)', class_number: '00.242', gds: 5, ads: 6 },
{ description: 'Trucks, Light (less than 13,000 lbs)', class_number: '00.241', gds: 5, ads: 5 },
{ description: 'Tugs (not used in marine construction)', class_number: '00.28', gds: 10, ads: 18 },
{ description: 'Typewriters', class_number: '00.13', gds: 5, ads: 6 },
{ description: 'Vessels', class_number: '00.28', gds: 10, ads: 18 },
{ description: 'Videotapes', class_number: null, gds: null, ads: null },
{ description: 'Vines Bearing Fruit or Nuts', class_number: null, gds: 10, ads: 20 },
{ description: 'Waterways', class_number: '00.3', gds: 15, ads: 20 },
{ description: 'Wharves', class_number: '00.3', gds: 15, ads: 20 }
];
// IRS Table 2 — Property Categorized by the Type of Manufacturing Industry.
const INDUSTRY = [
{ description: 'Aerospace Products', class_number: '37.2', gds: 7, ads: 10 },
{ description: 'Airplanes (manufacturing)', class_number: '37.2', gds: 7, ads: 10 },
{ description: 'Amusement Machines', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Apparel (excluding rubber and leather)', class_number: '23.0', gds: 5, ads: 9 },
{ description: 'Appliances', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Art Supplies', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Athletic Goods', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Automobiles (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
{ description: 'Boat and Ship Building — Dry Docks and Land Improvements', class_number: '37.32', gds: 10, ads: 16 },
{ description: 'Boat and Ship Building — Machinery and Equipment', class_number: '37.31', gds: 7, ads: 12 },
{ description: 'Boat and Ship Building — Special Tools', class_number: '37.33', gds: 5, ads: 6.5 },
{ description: 'Boiler Systems for Steam and Chemical Recovery', class_number: '26.1', gds: 7, ads: 13 },
{ description: 'Brooms and Brushes', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Buses (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
{ description: 'Carpets', class_number: '22.3', gds: 5, ads: 9 },
{ description: 'Caskets', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Cement (not Concrete)', class_number: '32.2', gds: 15, ads: 20 },
{ description: 'Chemical and Steam Recovery Boiler Systems', class_number: '26.1', gds: 7, ads: 13 },
{ description: 'Chemicals and Allied Products', class_number: '28.0', gds: null, ads: null },
{ description: 'Clay Products', class_number: '32.3', gds: 7, ads: 15 },
{ description: 'Clocks and Watches', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Clothing (excluding rubber and leather)', class_number: '23.0', gds: 5, ads: 9 },
{ description: 'Computers and Peripheral Machines (manufacturing)', class_number: '36.0', gds: 5, ads: 6 },
{ description: 'Concrete and Concrete Products', class_number: '32.3', gds: 7, ads: 15 },
{ description: 'Construction Machinery', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Cooling Systems', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Dental Equipment', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Dental Supplies', class_number: '22.3', gds: 5, ads: 9 },
{ description: 'Dyeing Textile Products', class_number: '22.3', gds: 5, ads: 9 },
{ description: 'Electronic Components, Products, and Systems', class_number: '36.0', gds: 5, ads: 6 },
{ description: 'Farm Machinery (placed in service before 1/1/2018)', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Farm Machinery (placed in service after 12/31/2017)', class_number: '35.0', gds: 5, ads: 10 },
{ description: 'Floor Coverings — Hard Surface', class_number: '22.3', gds: 5, ads: 9 },
{ description: 'Food and Beverage — Other', class_number: '20.4', gds: null, ads: null },
{ description: 'Food and Beverage Special Handling Devices', class_number: '20.5', gds: 3, ads: 4 },
{ description: 'Foundry Products', class_number: '33.3', gds: 7, ads: 14 },
{ description: 'Furniture (wood)', class_number: '24.4', gds: 7, ads: 10 },
{ description: 'Gas — Coal Gasification', class_number: '49.223', gds: 10, ads: 18 },
{ description: 'Gas Production Plants', class_number: '49.23', gds: 7, ads: 14 },
{ description: 'Glass Products', class_number: '32.1', gds: 7, ads: 14 },
{ description: 'Glass Products — Special Tools', class_number: '32.11', gds: 3, ads: 2.5 },
{ description: 'Grain and Grain Mill Products', class_number: '20.1', gds: 10, ads: 17 },
{ description: 'Jewelry', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Knitted Goods', class_number: '22.1', gds: 5, ads: 7.5 },
{ description: 'Leather and Leather Products', class_number: '31.1', gds: 7, ads: 11 },
{ description: 'Lighting Fixtures', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Locomotives', class_number: '37.41', gds: 7, ads: 11.5 },
{ description: 'Machine Tools', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Machinery, Electrical', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Machinery, Nonelectrical', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Mechanical Products', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Medical Equipment', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Medical Supplies', class_number: '22.3', gds: 5, ads: 9 },
{ description: 'Metal Products (Fabricated)', class_number: '34.0', gds: 7, ads: 12 },
{ description: 'Metal Products (Fabricated) — Special Tools', class_number: '34.01', gds: 3, ads: 3 },
{ description: 'Metals, Primary Nonferrous', class_number: '33.2', gds: 7, ads: 14 },
{ description: 'Metals, Primary Nonferrous — Special Tools', class_number: '33.21', gds: 5, ads: 6.5 },
{ description: 'Mining Machinery', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Motion Picture Films and Tapes', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Motor Homes (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
{ description: 'Motor Vehicles (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
{ description: 'Motor Vehicles — Special Tools', class_number: '37.12', gds: 3, ads: 3 },
{ description: 'Musical Instruments', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Nonwoven Fabrics', class_number: '22.5', gds: 7, ads: 10 },
{ description: 'Nonwoven Products', class_number: '22.3', gds: 5, ads: 9 },
{ description: 'Office Supplies', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Ophthalmic Goods', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Paper', class_number: '26.1', gds: 7, ads: 13 },
{ description: 'Paper Products (converted)', class_number: '26.2', gds: 7, ads: 10 },
{ description: 'Paperboard Products', class_number: '26.2', gds: 7, ads: 10 },
{ description: 'Pencils and Pens', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Photographic Equipment', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Photographic Supplies', class_number: '28.0', gds: 5, ads: 9.5 },
{ description: 'Plastic Finished Products', class_number: '30.2', gds: 7, ads: 11 },
{ description: 'Plastic Finished Products — Special Tools', class_number: '30.21', gds: 3, ads: 3.5 },
{ description: 'Pollution Control Assets', class_number: '26.1', gds: 7, ads: 13 },
{ description: 'Pulp', class_number: '26.1', gds: 7, ads: 13 },
{ description: 'Pulp Products (converted)', class_number: '26.2', gds: 7, ads: 10 },
{ description: 'Railroad Cars', class_number: '37.42', gds: 7, ads: 12 },
{ description: 'Railroad Locomotives', class_number: '37.41', gds: 7, ads: 11.5 },
{ description: 'Rubber Products', class_number: '30.1', gds: 7, ads: 14 },
{ description: 'Rubber Products — Special Tools and Devices', class_number: '30.11', gds: 3, ads: 4 },
{ description: 'Semiconductor Manufacturing Equipment', class_number: '36.1', gds: 5, ads: 5 },
{ description: 'Ship and Boat Building — Dry Docks and Land Improvements', class_number: '37.32', gds: 10, ads: 16 },
{ description: 'Ship and Boat Building — Machinery and Equipment', class_number: '37.31', gds: 7, ads: 12 },
{ description: 'Ship and Boat Building — Special Tools', class_number: '37.33', gds: 5, ads: 6.5 },
{ description: 'Sporting Goods', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Steam Utility Production and Distribution', class_number: '49.4', gds: 20, ads: 28 },
{ description: 'Steel (Primary) Mill Products', class_number: '33.4', gds: 7, ads: 15 },
{ description: 'Stone Products', class_number: '32.3', gds: 7, ads: 15 },
{ description: 'Sugar and Sugar Products', class_number: '20.2', gds: 10, ads: 18 },
{ description: 'Television Films and Tapes', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Textile Products, Dyeing, Finishing, and Packaging', class_number: '22.3', gds: 5, ads: 9 },
{ description: 'Textured Yarns', class_number: '22.4', gds: 5, ads: 8 },
{ description: 'Thread', class_number: '22.2', gds: 7, ads: 11 },
{ description: 'Tires', class_number: '30.1', gds: 7, ads: 14 },
{ description: 'Tobacco and Tobacco Products', class_number: '21.0', gds: 7, ads: 15 },
{ description: 'Toys', class_number: '39.0', gds: 7, ads: 12 },
{ description: 'Trailers (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
{ description: 'Trucks (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
{ description: 'Vegetable Oils and Products', class_number: '20.3', gds: 10, ads: 18 },
{ description: 'Vending Machines', class_number: '35.0', gds: 7, ads: 10 },
{ description: 'Video Recorders', class_number: '36.0', gds: 5, ads: 6 },
{ description: 'Wood Products', class_number: '24.4', gds: 7, ads: 10 },
{ description: 'Woven Fabric', class_number: '22.2', gds: 7, ads: 11 },
{ description: 'Yarn', class_number: '22.2', gds: 7, ads: 11 }
];
// IRS Table 3 — Property Categorized by the Type of Business.
const BUSINESS = [
{ description: 'Agriculture', class_number: '01.1', gds: 7, ads: 10 },
{ description: 'Amusement and Theme Parks', class_number: '80.0', gds: 7, ads: 12.5 },
{ description: 'Barber Shop', class_number: '57.0', gds: 7, ads: 10 },
{ description: 'Beauty Shop', class_number: '57.0', gds: 7, ads: 10 },
{ description: 'Bed and Breakfasts', class_number: '57.0', gds: 7, ads: 10 },
{ description: 'Billiard Establishments', class_number: '79.0', gds: 7, ads: 10 },
{ description: 'Bookbinding', class_number: '27.0', gds: 7, ads: 11 },
{ description: 'Bowling Alleys', class_number: '79.0', gds: 7, ads: 10 },
{ description: 'Cable Television', class_number: '48.4+', gds: null, ads: null },
{ description: 'Casinos', class_number: '00.12', gds: 7, ads: 10 },
{ description: 'Communications (Telegraph, Ocean Cable, Satellite)', class_number: '48.3+', gds: null, ads: null },
{ description: 'Concert Halls', class_number: '79.0', gds: 7, ads: 10 },
{ description: 'Construction', class_number: '15.0', gds: 5, ads: 6 },
{ description: 'Distributive Trades and Services', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Electric Generation (industrial)', class_number: '00.4', gds: 15, ads: 22 },
{ description: 'Electric Utility Services', class_number: '49.1+', gds: null, ads: null },
{ description: 'Electrotyping', class_number: '27.0', gds: 7, ads: 11 },
{ description: 'Engraving', class_number: '27.0', gds: 7, ads: 11 },
{ description: 'Gas Deposits — Exploration and Production', class_number: '13.2', gds: 7, ads: 14 },
{ description: 'Gas and Oil — Offshore Drilling', class_number: '13.0', gds: 5, ads: 7.5 },
{ description: 'Gas and Oil — On-Shore Drilling', class_number: '13.1', gds: 5, ads: 6 },
{ description: 'Gas Utility Services', class_number: '49.2+', gds: null, ads: null },
{ description: 'Lithography', class_number: '27.0', gds: 7, ads: 11 },
{ description: 'Logging', class_number: '24.1+', gds: 5, ads: 6 },
{ description: 'Miniature Golf Courses', class_number: '79.0', gds: 7, ads: 10 },
{ description: 'Mining', class_number: '10.0', gds: 7, ads: 10 },
{ description: 'Motorsports Entertainment Complex', class_number: null, gds: 7, ads: 40 },
{ description: 'Oil and Gas — Offshore Drilling', class_number: '13.0', gds: 5, ads: 7.5 },
{ description: 'Oil and Gas — On-Shore Drilling', class_number: '13.1', gds: 5, ads: 6 },
{ description: 'Personal Services', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Petroleum — Exploration and Production', class_number: '13.2', gds: 7, ads: 14 },
{ description: 'Petroleum Refining', class_number: '13.3', gds: 10, ads: 16 },
{ description: 'Pipeline Transportation', class_number: '46.0', gds: 15, ads: 22 },
{ description: 'Printing', class_number: '27.0', gds: 7, ads: 11 },
{ description: 'Professional Services', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Publishing', class_number: '27.0', gds: 7, ads: 11 },
{ description: 'Radio Broadcasting (excluding transmitting towers)', class_number: '48.2', gds: 5, ads: 6 },
{ description: 'Recreation Services (excluding amusement parks)', class_number: '79.0', gds: 7, ads: 10 },
{ description: 'Research and Development (business activity)', class_number: null, gds: 5, ads: null },
{ description: 'Retail Trades and Services', class_number: '57.0', gds: 5, ads: 9 },
{ description: 'Steam Generation (industrial)', class_number: '00.4', gds: 15, ads: 22 },
{ description: 'Steam Utility Production and Distribution (business)', class_number: '49.4', gds: 20, ads: 28 },
{ description: 'Telephone Central Office Buildings (business)', class_number: '48.11', gds: 20, ads: 45 },
{ description: 'Telephone Central Office Switching', class_number: '48.12', gds: 10, ads: 18 },
{ description: 'Telephone Central Office Switching — Computer-Based', class_number: '48.121', gds: 5, ads: 9.5 },
{ description: 'Telephone Station Equipment', class_number: '48.13', gds: 7, ads: 10 },
{ description: 'Telephone Distribution Plants (business)', class_number: '48.14', gds: 15, ads: 24 },
{ description: 'Television Broadcasting (excluding transmitting towers)', class_number: '48.2', gds: 5, ads: 6 },
{ description: 'Theaters', class_number: '79.0', gds: 7, ads: 10 },
{ description: 'Theme and Amusement Parks', class_number: '80.0', gds: 7, ads: 12.5 },
{ description: 'Timber Cutting', class_number: '24.1+', gds: 5, ads: 6 },
{ description: 'Transport (Air) — Passengers and Freight', class_number: '45.0', gds: 7, ads: 12 },
{ description: 'Transport (Motor) — Freight', class_number: '42.0', gds: 5, ads: 8 },
{ description: 'Transport (Motor) — Passengers', class_number: '41.0', gds: 5, ads: 8 },
{ description: 'Transport (Pipeline)', class_number: '46.0', gds: 15, ads: 22 },
{ description: 'Transport (Water) — Passengers and Freight', class_number: '44.0', gds: 15, ads: 20 },
{ description: 'Typesetting', class_number: '27.0', gds: 7, ads: 11 },
{ description: 'Waste Reduction and Resource Recovery Plants', class_number: '49.5', gds: 7, ads: 10 },
{ description: 'Waste Water (Municipal) Treatment Plants', class_number: '50.0', gds: 15, ads: 24 },
{ description: 'Water Utility Services', class_number: '49.3', gds: 25, ads: 50 },
{ description: 'Wholesale Trades and Services', class_number: '57.0', gds: 5, ads: 9 }
];
// `table` marks the source list so the picker can label/group entries.
const IRS_ASSET_CLASSES = [
...COMMON.map((entry) => ({ ...entry, table: 'common' })),
...INDUSTRY.map((entry) => ({ ...entry, table: 'industry' })),
...BUSINESS.map((entry) => ({ ...entry, table: 'business' }))
];
module.exports = { IRS_ASSET_CLASSES };

View File

@@ -84,12 +84,24 @@ function initialize() {
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','finance','operations','viewer')),
role TEXT NOT NULL DEFAULT 'viewer',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS roles (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
capabilities_json TEXT NOT NULL DEFAULT '[]',
is_system INTEGER NOT NULL DEFAULT 0,
locked INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
preferences_json TEXT NOT NULL,
@@ -306,6 +318,8 @@ function initialize() {
client_secret TEXT,
workers_path TEXT NOT NULL DEFAULT '/workers',
enabled INTEGER NOT NULL DEFAULT 0,
sync_enabled INTEGER NOT NULL DEFAULT 0,
sync_interval_hours INTEGER NOT NULL DEFAULT 24,
last_sync_at TEXT,
last_sync_status TEXT,
created_at TEXT NOT NULL,
@@ -335,6 +349,309 @@ function initialize() {
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS disposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
disposal_date TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'sale',
property_type TEXT,
partial INTEGER NOT NULL DEFAULT 0,
disposed_cost REAL NOT NULL DEFAULT 0,
disposal_price REAL NOT NULL DEFAULT 0,
disposal_expense REAL NOT NULL DEFAULT 0,
accumulated_depreciation REAL NOT NULL DEFAULT 0,
net_book_value REAL NOT NULL DEFAULT 0,
gain_loss REAL NOT NULL DEFAULT 0,
book_type TEXT NOT NULL DEFAULT 'GAAP',
notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
adjustment_date TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'impairment',
amount REAL NOT NULL DEFAULT 0,
book_type TEXT NOT NULL DEFAULT 'GAAP',
reason TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
book_type TEXT NOT NULL DEFAULT 'tax',
enabled INTEGER NOT NULL DEFAULT 1,
is_primary INTEGER NOT NULL DEFAULT 0,
tax_rule_set_id INTEGER REFERENCES tax_rule_sets(id),
default_method TEXT,
default_convention TEXT,
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
reference_type TEXT NOT NULL,
reference_id INTEGER,
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
severity TEXT NOT NULL DEFAULT 'warning',
title TEXT NOT NULL,
message TEXT,
due_date TEXT,
status TEXT NOT NULL DEFAULT 'open',
notified_at TEXT,
acknowledged_by INTEGER REFERENCES users(id),
acknowledged_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(reference_type, reference_id, type)
);
CREATE TABLE IF NOT EXISTS pm_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
category TEXT,
frequency_value INTEGER NOT NULL DEFAULT 1,
frequency_unit TEXT NOT NULL DEFAULT 'months',
estimated_minutes INTEGER,
instructions TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pm_plan_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE,
step_order INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
details TEXT,
est_minutes INTEGER
);
CREATE TABLE IF NOT EXISTS pm_plan_components (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
part_number TEXT,
description TEXT,
supplier TEXT,
cost REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS asset_pm_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
plan_id INTEGER REFERENCES pm_plans(id),
frequency_value INTEGER NOT NULL DEFAULT 1,
frequency_unit TEXT NOT NULL DEFAULT 'months',
start_date TEXT,
end_date TEXT,
next_due_date TEXT,
last_completed_date TEXT,
assigned_to INTEGER REFERENCES users(id),
active INTEGER NOT NULL DEFAULT 1,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pm_completions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_pm_id INTEGER NOT NULL REFERENCES asset_pm_plans(id) ON DELETE CASCADE,
completed_at TEXT NOT NULL,
completed_by INTEGER REFERENCES users(id),
notes TEXT,
notes_json TEXT,
steps_json TEXT,
cost REAL NOT NULL DEFAULT 0,
components_json TEXT,
signature TEXT,
rating_safety INTEGER,
rating_physical INTEGER,
rating_operating INTEGER,
next_due_date TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pm_completion_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
completion_id INTEGER NOT NULL REFERENCES pm_completions(id) ON DELETE CASCADE,
name TEXT,
mime_type TEXT,
data_base64 TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL DEFAULT 'other',
first_name TEXT,
last_name TEXT,
organization TEXT,
email TEXT,
phone TEXT,
address_line1 TEXT,
address_line2 TEXT,
city TEXT,
state_region TEXT,
postal_code TEXT,
country TEXT,
notes TEXT,
status TEXT NOT NULL DEFAULT 'active',
employee_id TEXT,
tax_id TEXT,
department TEXT,
work_location TEXT,
manager_first_name TEXT,
manager_last_name TEXT,
manager_email TEXT,
start_date TEXT,
rating INTEGER,
credit_term TEXT,
source TEXT NOT NULL DEFAULT 'manual',
workday_worker_id TEXT UNIQUE,
source_payload TEXT,
last_synced_at TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS contact_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS parts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_number TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
category TEXT,
manufacturer TEXT,
manufacturer_part_number TEXT,
barcode TEXT,
unit_of_measure TEXT NOT NULL DEFAULT 'each',
unit_cost REAL NOT NULL DEFAULT 0,
reorder_point REAL NOT NULL DEFAULT 0,
reorder_quantity REAL NOT NULL DEFAULT 0,
measurements TEXT,
supplier_contact_id INTEGER REFERENCES contacts(id),
status TEXT NOT NULL DEFAULT 'active',
notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS part_stock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
location_contact_id INTEGER REFERENCES contacts(id),
aisle TEXT,
bin TEXT,
quantity REAL NOT NULL DEFAULT 0,
reserved REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS part_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
name TEXT,
mime_type TEXT,
data_base64 TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS part_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
stock_id INTEGER REFERENCES part_stock(id) ON DELETE SET NULL,
type TEXT NOT NULL DEFAULT 'receive',
quantity REAL NOT NULL DEFAULT 0,
unit_cost REAL,
asset_id INTEGER REFERENCES assets(id) ON DELETE SET NULL,
reference TEXT,
notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
name TEXT,
mime_type TEXT,
caption TEXT,
data_base64 TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pm_plan_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE,
name TEXT,
mime_type TEXT,
caption TEXT,
data_base64 TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_id_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
pattern TEXT NOT NULL,
next_number INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_number TEXT,
description TEXT NOT NULL,
gds REAL,
ads REAL,
source TEXT NOT NULL DEFAULT 'custom',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS depreciation_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
allowance_percent REAL NOT NULL DEFAULT 0,
pis_start TEXT,
pis_end TEXT,
real_property_end TEXT,
max_recovery_years INTEGER,
leasehold_life_years INTEGER,
notes TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER REFERENCES users(id),
@@ -347,11 +664,100 @@ function initialize() {
);
`);
migrate();
seed();
}
// Additive column migrations for databases created before these columns existed.
function ensureColumn(table, column, definition) {
const columns = all(`PRAGMA table_info(${table})`);
if (!columns.some((col) => col.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
function migrate() {
ensureColumn('pm_plan_steps', 'est_minutes', 'INTEGER');
ensureColumn('pm_completions', 'cost', 'REAL NOT NULL DEFAULT 0');
ensureColumn('pm_completions', 'components_json', 'TEXT');
ensureColumn('pm_completions', 'notes_json', 'TEXT');
ensureColumn('pm_completions', 'signature', 'TEXT');
ensureColumn('pm_completions', 'rating_safety', 'INTEGER');
ensureColumn('pm_completions', 'rating_physical', 'INTEGER');
ensureColumn('pm_completions', 'rating_operating', 'INTEGER');
ensureColumn('workday_connections', 'sync_enabled', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn('workday_connections', 'sync_interval_hours', 'INTEGER NOT NULL DEFAULT 24');
ensureColumn('alerts', 'external_ticket_id', 'TEXT');
ensureColumn('alerts', 'external_ticket_number', 'TEXT');
ensureColumn('alerts', 'external_ticket_url', 'TEXT');
ensureColumn('alerts', 'external_ticket_system', 'TEXT');
ensureColumn('assets', 'servicenow_sys_id', 'TEXT');
ensureColumn('assets', 'asset_class_number', 'TEXT');
ensureColumn('assets', 'special_zone', 'TEXT');
dropUsersRoleCheck();
}
// Custom roles require the legacy CHECK(role IN (...)) constraint on `users` to be removed.
// SQLite can't drop a column constraint in place, so rebuild the table when the old DDL is present.
function dropUsersRoleCheck() {
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'");
if (!ddl || !/CHECK\s*\(\s*role/i.test(ddl.sql)) return;
db.exec('PRAGMA foreign_keys=OFF');
db.exec(`
CREATE TABLE users_rebuild (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER REFERENCES teams(id),
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO users_rebuild (id, team_id, name, email, password_hash, role, status, created_at, updated_at)
SELECT id, team_id, name, email, password_hash, role, status, created_at, updated_at FROM users;
DROP TABLE users;
ALTER TABLE users_rebuild RENAME TO users;
`);
db.exec('PRAGMA foreign_keys=ON');
}
function seed() {
const createdAt = now();
// Built-in roles (capability sets). Insert missing ones; keep admin's wildcard in sync.
const { DEFAULT_ROLES } = require('./services/accessControl');
for (const role of DEFAULT_ROLES) {
run(
`INSERT INTO roles (key, name, description, capabilities_json, is_system, locked, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET is_system = excluded.is_system, locked = excluded.locked`,
[role.key, role.name, role.description, json(role.capabilities), role.is_system, role.locked, role.sort_order, createdAt, createdAt]
);
}
run("UPDATE roles SET capabilities_json = ? WHERE key = 'admin'", [json(['*'])]);
// Asset class catalog: seed from the IRS reference tables once; afterwards it is
// fully user-managed (edits/deletes persist and are not re-seeded).
if (!one('SELECT id FROM asset_classes LIMIT 1')) {
const { IRS_ASSET_CLASSES } = require('./data/irsAssetClasses');
for (const item of IRS_ASSET_CLASSES) {
run(
'INSERT INTO asset_classes (class_number, description, gds, ads, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[item.class_number || null, item.description, item.gds ?? null, item.ads ?? null, item.table || 'common', createdAt, createdAt]
);
}
}
// Special depreciation zones (e.g. New York Liberty Zone). Seeded once, then user-managed.
run(
`INSERT OR IGNORE INTO depreciation_zones
(code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, notes, enabled, created_at, updated_at)
VALUES ('ny_liberty', 'New York Liberty Zone', 30, '2001-09-11', '2006-12-31', '2009-12-31', 20, 5,
'30% special depreciation allowance for qualified NY Liberty Zone property; qualified leasehold improvements use a 5-year life; increased Section 179. Verify current tax law before filing.', 1, ?, ?)`,
[createdAt, createdAt]
);
if (!one('SELECT id FROM teams LIMIT 1')) {
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
'Finance Operations',
@@ -391,7 +797,14 @@ function seed() {
['category', 'Computer Equipment', 'COMP'],
['category', 'Office Furniture', 'FURN'],
['category', 'Vehicles', 'AUTO'],
['category', 'Leasehold Improvements', 'LHI']
['category', 'Leasehold Improvements', 'LHI'],
['part_category', 'Electrical', null],
['part_category', 'Plumbing', null],
['part_category', 'Structural Component', null],
['part_category', 'Electronic Component', null],
['part_category', 'Fire Safety', null],
['part_category', 'Mechanical Safety', null],
['part_category', 'Other', null]
];
for (const item of refs) {
run('INSERT OR IGNORE INTO reference_values (type, name, code, metadata) VALUES (?, ?, ?, ?)', [
@@ -466,12 +879,68 @@ function seed() {
);
}
if (!one('SELECT id FROM books LIMIT 1')) {
const federalRule = one("SELECT id FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1");
const ruleId = federalRule ? federalRule.id : null;
const bookDefs = [
['GAAP', 'GAAP / Financial', 'Primary book for financial statements', 'financial', 1, 1, 'straight_line', 'half_year'],
['FEDERAL', 'Federal Tax', 'US federal income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
['STATE', 'State Tax', 'State income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
['AMT', 'Alternative Minimum Tax', 'AMT depreciation adjustments', 'tax', 1, 0, 'macrs_gds_150db_5', 'half_year'],
['ACE', 'Adjusted Current Earnings', 'ACE depreciation', 'tax', 1, 0, 'macrs_ads_sl_5', 'half_year'],
['USER', 'User-defined', 'Custom internal reporting book', 'internal', 0, 0, 'straight_line', 'half_year']
];
bookDefs.forEach((def, index) => {
run(
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[def[0], def[1], def[2], def[3], def[4], def[5], ruleId, def[6], def[7], index, createdAt, createdAt]
);
});
}
// The PM book tracks actual preventative-maintenance spend per asset (not depreciation).
run(
`INSERT OR IGNORE INTO books (code, name, description, book_type, enabled, is_primary, sort_order, created_at, updated_at)
VALUES ('PM', 'PM / Maintenance', 'Actual preventative-maintenance spend per asset', 'maintenance', 1, 0, 6, ?, ?)`,
[createdAt, createdAt]
);
const settings = {
server_fqdn: process.env.MIXEDASSETS_SERVER_FQDN || 'localhost',
cors_allowed_domains: process.env.MIXEDASSETS_CORS_ORIGINS || 'http://localhost:5173',
database_driver: 'node:sqlite',
database_path: DB_PATH,
default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER'
default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER',
alerts_enabled: 'false',
alerts_lead_days: '30',
alerts_recipients: '',
smtp_host: '',
smtp_port: '587',
smtp_secure: 'false',
smtp_user: '',
smtp_password: '',
smtp_from: 'MixedAssets <no-reply@mixedassets.local>',
webhook_enabled: 'false',
webhook_url: '',
webhook_secret: '',
servicenow_instance_url: '',
servicenow_username: '',
servicenow_password: '',
servicenow_incident_table: 'incident',
servicenow_assignment_group: '',
servicenow_caller_id: '',
servicenow_ticket_enabled: 'false',
servicenow_auto_ticket: 'false',
servicenow_cmdb_table: 'cmdb_ci_hardware',
servicenow_cmdb_query: '',
servicenow_cmdb_limit: '200',
servicenow_cmdb_map: '',
servicenow_last_sync_at: '',
servicenow_last_sync_status: '',
pm_default_frequency_value: '3',
pm_default_frequency_unit: 'months',
pm_lead_days: '7'
};
for (const [key, value] of Object.entries(settings)) {
run('INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)', [key, String(value), createdAt]);

View File

@@ -1,4 +1,14 @@
const { parseJson } = require('./db');
const { buildDepreciationMethods } = require('./rule-catalog');
const { getZone } = require('./services/zones');
// Built-in method catalog, used as a fallback so codes like MF200/DB200 always resolve
// even if a particular rule set hasn't been re-expanded to include them.
let methodCatalog = null;
function catalog() {
if (!methodCatalog) methodCatalog = buildDepreciationMethods();
return methodCatalog;
}
function money(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
@@ -19,25 +29,59 @@ function parseMaybeJson(value, fallback = null) {
return value ?? fallback;
}
function totalDepreciableBasis(asset, book) {
function businessUseFactor(asset, book) {
return Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
}
// Cost basis before salvage: acquisition value less land, scaled by business-use %.
function grossBasis(asset, book) {
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
const land = Number(asset.land_value || 0);
const salvage = Number(asset.salvage_value || 0);
const businessUse = Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
return Math.max(0, (cost - land - salvage) * businessUse);
return Math.max(0, (cost - land) * businessUseFactor(asset, book));
}
// Salvage reduces what standard methods recover, but MACRS ignores it (ignoreSalvage flag).
function salvageValue(asset, book, methodRule) {
if (methodRule && methodRule.ignoreSalvage) return 0;
return Math.max(0, Number(asset.salvage_value || 0) * businessUseFactor(asset, book));
}
function section179Amount(asset, book) {
return Math.min(totalDepreciableBasis(asset, book), Number(book.section_179_amount || 0));
return Math.min(grossBasis(asset, book), Number(book.section_179_amount || 0));
}
// Special-zone §168 allowance (e.g. New York Liberty Zone): applies to the federal tax book
// when the asset is flagged for a zone and its placed-in-service date is within the window.
// Returns the allowance % (used as the §168 bonus) or 0.
function zoneAllowance(asset, book) {
if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') return 0;
const zone = getZone(asset.special_zone);
if (!zone || !Number(zone.allowance_percent)) return 0;
const pis = asset.in_service_date || asset.acquired_date || '';
if (pis && zone.pis_start && pis < zone.pis_start) return 0;
if (pis && zone.pis_end && pis > zone.pis_end) return 0;
return Number(zone.allowance_percent);
}
// Section 168 (bonus) allowance, applied after Section 179.
function bonusAmount(asset, book) {
const after179 = Math.max(0, totalDepreciableBasis(asset, book) - section179Amount(asset, book));
const after179 = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
return after179 * (Number(book.bonus_percent || 0) / 100);
}
function depreciableBasis(asset, book) {
return Math.max(0, totalDepreciableBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
// Total amount recoverable over the asset's life (the schedule cap): cost basis less salvage.
function recoverableBasis(asset, book, methodRule) {
return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, methodRule));
}
// Amount a straight-line / SYD method depreciates: net of Section 179, bonus, and salvage.
function depreciableBasis(asset, book, methodRule) {
return Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book) - salvageValue(asset, book, methodRule));
}
// Cost basis net of salvage (no 179/bonus) — retained for reports and disposals.
function totalDepreciableBasis(asset, book) {
return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, null));
}
function conventionFor(book, methodRule) {
@@ -83,31 +127,35 @@ function fractionForIndex(index, convention, asset, totalYears) {
}
function straightLine(asset, book, index, totalYears, methodRule) {
const basis = depreciableBasis(asset, book);
const basis = depreciableBasis(asset, book, methodRule);
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
}
function sumOfYearsDigits(asset, book, index, totalYears) {
const basis = depreciableBasis(asset, book);
function sumOfYearsDigits(asset, book, index, totalYears, methodRule) {
const basis = depreciableBasis(asset, book, methodRule);
const denominator = (totalYears * (totalYears + 1)) / 2;
return basis * ((totalYears - index) / denominator);
}
function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) {
const basis = depreciableBasis(asset, book);
let bookValue = basis;
// The declining-balance rate applies to the full (post-179/bonus) book value; depreciation
// is floored at salvage. MACRS methods set salvage to 0 via ignoreSalvage.
const start = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
const salvage = salvageValue(asset, book, methodRule);
let bookValue = start;
let elapsed = 0;
const convention = conventionFor(book, methodRule);
for (let i = 0; i <= index; i += 1) {
const fraction = fractionForIndex(i, convention, asset, totalYears);
if (fraction <= 0 || bookValue <= 0) return 0;
const recoverable = Math.max(0, bookValue - salvage);
if (fraction <= 0 || recoverable <= 0) return 0;
const dbAmount = bookValue * (multiplier / totalYears) * fraction;
const remainingLife = Math.max(0.0001, totalYears - elapsed);
const slAmount = (bookValue / remainingLife) * fraction;
const slAmount = (recoverable / remainingLife) * fraction;
const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
const amount = Math.min(bookValue, selected);
const amount = Math.min(recoverable, selected);
if (i === index) return amount;
bookValue -= amount;
@@ -117,7 +165,7 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule
}
function rateTable(asset, book, index, methodRule) {
const basis = depreciableBasis(asset, book);
const basis = depreciableBasis(asset, book, methodRule);
const rates = methodRule.rates || [];
if (!rates.length && methodRule.fallbackFormula === 'straight_line') {
return straightLine(asset, book, index, Math.max(1, Math.ceil((methodRule.lifeMonths || book.useful_life_months || 60) / 12)), methodRule);
@@ -127,15 +175,18 @@ function rateTable(asset, book, index, methodRule) {
function getMethodRule(ruleSet, code) {
const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {};
return (rules.methods || []).find((method) => method.code === code) || {
code,
formula: code,
label: code
};
return (rules.methods || []).find((method) => method.code === code)
|| catalog().find((method) => method.code === code)
|| { code, formula: code, label: code };
}
function annualSchedule(asset, book, ruleSet, options = {}) {
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
if (!(Number(book.bonus_percent) > 0)) {
const allowance = zoneAllowance(asset, book);
if (allowance > 0) book = { ...book, bonus_percent: allowance };
}
const usefulLifeMonths = Number(methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60);
const totalYears = Math.max(1, Math.ceil(usefulLifeMonths / 12));
const placedInServiceYear = yearFromDate(asset.in_service_date || asset.acquired_date);
@@ -156,7 +207,7 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
if (methodRule.formula === 'rate_table') {
depreciation += rateTable(asset, book, index, methodRule);
} else if (methodRule.formula === 'sum_of_years_digits') {
depreciation += sumOfYearsDigits(asset, book, index, totalYears);
depreciation += sumOfYearsDigits(asset, book, index, totalYears, methodRule);
} else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') {
depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule);
} else if (methodRule.formula === 'acrs_alternate_straight_line') {
@@ -168,8 +219,8 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
}
}
const basis = totalDepreciableBasis(asset, book);
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, basis - accumulated)));
const cap = recoverableBasis(asset, book, methodRule);
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated)));
accumulated += depreciation;
if (year >= startYear) {

View File

@@ -1,4 +1,6 @@
const { createApp } = require('./app');
const { runAlertCycle } = require('./services/alerts');
const { runScheduledSync } = require('./services/workday');
const PORT = Number(process.env.PORT || process.env.MIXEDASSETS_PORT || 3000);
const app = createApp();
@@ -6,3 +8,21 @@ const app = createApp();
app.listen(PORT, () => {
console.log(`MixedAssets API listening on http://localhost:${PORT}`);
});
// Periodically scan for due/expiring items and email digests. These are no-ops
// unless alerts are enabled and SMTP is configured. Runs after boot, then twice daily.
function scheduleAlertCycle() {
const tick = () => runAlertCycle(null).catch((error) => console.error('Alert cycle failed:', error.message));
setTimeout(tick, 30_000).unref();
setInterval(tick, 12 * 60 * 60 * 1000).unref();
}
// Hourly check for a due automatic Workday sync (respects the configured interval).
function scheduleWorkdaySync() {
const tick = () => runScheduledSync().catch((error) => console.error('Workday sync failed:', error.message));
setTimeout(tick, 60_000).unref();
setInterval(tick, 60 * 60 * 1000).unref();
}
scheduleAlertCycle();
scheduleWorkdaySync();

View File

@@ -41,9 +41,24 @@ function requireRole(...roles) {
};
}
// Capability-based guard: the user's role must grant at least one of the listed capabilities.
// Required lazily to avoid a load-time cycle (roles -> db -> ...).
function requireCapability(...capabilities) {
return (req, res, next) => {
const { capabilitiesForRole } = require('../services/roles');
const { capabilitySetIncludes } = require('../services/accessControl');
const granted = capabilitiesForRole(req.user.role);
if (!capabilities.some((cap) => capabilitySetIncludes(granted, cap))) {
return res.status(403).json({ error: 'Insufficient access' });
}
return next();
};
}
module.exports = {
publicUser,
requireAuth,
requireCapability,
requireRole,
tokenFor
};

View File

@@ -1,8 +1,9 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { all, audit, now, one, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const { isValidRole, roleCapabilities, rolePermissions, roles } = require('../services/accessControl');
const { requireCapability } = require('../middleware/auth');
const { CAPABILITIES } = require('../services/accessControl');
const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles');
const router = express.Router();
@@ -17,15 +18,17 @@ function publicAdminUser(id) {
}
function validateRole(role) {
if (!isValidRole(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
if (!roleExists(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
}
router.get('/settings', requireRole('admin'), (req, res) => {
router.get('/settings', requireCapability('admin.settings'), (req, res) => {
const rows = all('SELECT * FROM app_settings ORDER BY key');
res.json({ settings: Object.fromEntries(rows.map((row) => [row.key, row.value])) });
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
delete settings.smtp_password; // managed (masked) via the notifications settings endpoint
res.json({ settings });
});
router.put('/settings', requireRole('admin'), (req, res) => {
router.put('/settings', requireCapability('admin.settings'), (req, res) => {
for (const [key, value] of Object.entries(req.body.settings || req.body)) {
run('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [
key,
@@ -37,11 +40,11 @@ router.put('/settings', requireRole('admin'), (req, res) => {
res.json({ settings: Object.fromEntries(all('SELECT * FROM app_settings ORDER BY key').map((row) => [row.key, row.value])) });
});
router.get('/users', requireRole('admin'), (req, res) => {
router.get('/users', requireCapability('admin.users'), (req, res) => {
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
});
router.post('/users', requireRole('admin'), (req, res) => {
router.post('/users', requireCapability('admin.users'), (req, res) => {
validateRole(req.body.role || 'viewer');
if (req.body.status && !['active', 'inactive'].includes(req.body.status)) return res.status(400).json({ error: 'Invalid status' });
const created = now();
@@ -62,7 +65,7 @@ router.post('/users', requireRole('admin'), (req, res) => {
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
});
router.put('/users/:id', requireRole('admin'), (req, res) => {
router.put('/users/:id', requireCapability('admin.users'), (req, res) => {
const before = publicAdminUser(req.params.id);
if (!before) return res.status(404).json({ error: 'User not found' });
const nextRole = req.body.role || before.role;
@@ -110,11 +113,11 @@ router.put('/users/:id', requireRole('admin'), (req, res) => {
return res.json({ user });
});
router.get('/teams', requireRole('admin'), (req, res) => {
router.get('/teams', requireCapability('admin.users'), (req, res) => {
res.json({ teams: all('SELECT * FROM teams ORDER BY name') });
});
router.post('/teams', requireRole('admin'), (req, res) => {
router.post('/teams', requireCapability('admin.users'), (req, res) => {
const result = run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
req.body.name,
req.body.description || null,
@@ -124,8 +127,58 @@ router.post('/teams', requireRole('admin'), (req, res) => {
res.status(201).json({ team: one('SELECT * FROM teams WHERE id = ?', [result.lastInsertRowid]) });
});
router.get('/access-control', requireRole('admin'), (req, res) => {
res.json({ roles, rolePermissions, roleCapabilities });
router.put('/teams/:id', requireCapability('admin.users'), (req, res) => {
const before = one('SELECT * FROM teams WHERE id = ?', [req.params.id]);
if (!before) return res.status(404).json({ error: 'Team not found' });
run('UPDATE teams SET name = ?, description = ? WHERE id = ?', [
req.body.name || before.name,
req.body.description ?? before.description,
req.params.id
]);
audit(req.user.id, 'update', 'team', req.params.id, before, req.body);
return res.json({ team: one('SELECT * FROM teams WHERE id = ?', [req.params.id]) });
});
router.delete('/teams/:id', requireCapability('admin.users'), (req, res) => {
const before = one('SELECT * FROM teams WHERE id = ?', [req.params.id]);
if (!before) return res.status(404).json({ error: 'Team not found' });
const memberCount = one('SELECT COUNT(*) AS count FROM users WHERE team_id = ?', [req.params.id]).count;
if (memberCount) {
return res.status(400).json({ error: `Reassign the ${memberCount} user(s) on this team before deleting it` });
}
run('DELETE FROM teams WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'team', req.params.id, before, null);
return res.status(204).end();
});
router.get('/access-control', requireCapability('admin.users'), (req, res) => {
res.json({ roles: listRoles(), capabilities: CAPABILITIES });
});
// ---- Roles (capability sets, custom roles allowed) --------------------------
router.get('/roles', requireCapability('admin.roles'), (req, res) => {
res.json({ roles: listRoles(), capabilities: CAPABILITIES });
});
router.post('/roles', requireCapability('admin.roles'), (req, res, next) => {
try {
res.status(201).json({ role: createRole(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/roles/:key', requireCapability('admin.roles'), (req, res) => {
const role = updateRole(req.params.key, req.body, req.user.id);
if (!role) return res.status(404).json({ error: 'Role not found' });
return res.json({ role });
});
router.delete('/roles/:key', requireCapability('admin.roles'), (req, res) => {
const result = deleteRole(req.params.key, req.user.id);
if (!result.ok) return res.status(result.status).json({ error: result.error });
return res.status(204).end();
});
module.exports = router;

73
server/routes/alerts.js Normal file
View File

@@ -0,0 +1,73 @@
const express = require('express');
const { requireCapability } = require('../middleware/auth');
const { listAlerts, runAlertCycle, scanAlerts, setStatus, summary } = require('../services/alerts');
const { publicConfig, saveConfig, sendTestEmail } = require('../services/notifications');
const { sendTestWebhook } = require('../services/webhooks');
const { submitTicket } = require('../services/servicenow');
const router = express.Router();
router.get('/alerts', (req, res) => {
res.json({ alerts: listAlerts(req.query), summary: summary() });
});
router.post('/alerts/scan', requireCapability('alerts.manage'), (req, res) => {
const result = scanAlerts(req.user.id);
res.json({ created: result.created.length, openCount: result.openCount, alerts: listAlerts({}), summary: summary() });
});
router.post('/alerts/run', requireCapability('admin.integrations'), async (req, res, next) => {
try {
res.json(await runAlertCycle(req.user.id));
} catch (error) {
next(error);
}
});
router.put('/alerts/:id/acknowledge', requireCapability('alerts.manage'), (req, res) => {
const alert = setStatus(req.params.id, 'acknowledged', req.user.id);
if (!alert) return res.status(404).json({ error: 'Alert not found' });
return res.json({ alert });
});
router.put('/alerts/:id/dismiss', requireCapability('alerts.manage'), (req, res) => {
const alert = setStatus(req.params.id, 'dismissed', req.user.id);
if (!alert) return res.status(404).json({ error: 'Alert not found' });
return res.json({ alert });
});
router.post('/alerts/:id/ticket', requireCapability('alerts.manage'), async (req, res, next) => {
try {
const ticket = await submitTicket(req.params.id, req.user.id);
if (!ticket) return res.status(404).json({ error: 'Alert not found' });
return res.json({ ticket });
} catch (error) {
next(error);
}
});
router.get('/notifications/settings', requireCapability('admin.integrations'), (req, res) => {
res.json({ settings: publicConfig() });
});
router.put('/notifications/settings', requireCapability('admin.integrations'), (req, res) => {
res.json({ settings: saveConfig(req.body, req.user.id) });
});
router.post('/notifications/test', requireCapability('admin.integrations'), async (req, res, next) => {
try {
res.json(await sendTestEmail(req.body.to, req.user.id));
} catch (error) {
next(error);
}
});
router.post('/notifications/webhook-test', requireCapability('admin.integrations'), async (req, res, next) => {
try {
res.json(await sendTestWebhook(req.user.id));
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -2,16 +2,20 @@ const express = require('express');
const multer = require('multer');
const { audit, now, one, run } = require('../db');
const { annualSchedule } = require('../depreciation');
const { requireRole } = require('../middleware/auth');
const { requireCapability } = require('../middleware/auth');
const {
addAssetPhoto,
createAsset,
deleteAsset,
deleteAssetPhoto,
hydrateAsset,
listAssets,
lookupAssetByCode,
massUpdateAssets,
updateAsset
updateAsset,
updateAssetPhoto
} = require('../services/assets');
const { activeRuleSet } = require('../services/reports');
const { ruleSetForBook } = require('../services/ruleSets');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
@@ -20,28 +24,36 @@ router.get('/assets', (req, res) => {
res.json({ assets: listAssets(req.query) });
});
router.post('/assets', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.post('/assets', requireCapability('assets.edit'), (req, res) => {
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
});
router.get('/assets/lookup', (req, res) => {
const code = String(req.query.code || '').trim();
if (!code) return res.status(400).json({ error: 'Scan code is required' });
const asset = lookupAssetByCode(code);
if (!asset) return res.status(404).json({ error: `No asset matches "${code}"` });
return res.json({ asset });
});
router.get('/assets/:id', (req, res) => {
const asset = hydrateAsset(req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
return res.json({ asset });
});
router.put('/assets/:id', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.put('/assets/:id', requireCapability('assets.edit'), (req, res) => {
const asset = updateAsset(req.params.id, req.body, req.user.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
return res.json({ asset });
});
router.delete('/assets/:id', requireRole('admin', 'finance'), (req, res) => {
router.delete('/assets/:id', requireCapability('assets.delete'), (req, res) => {
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
return res.status(204).end();
});
router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) => {
router.post('/assets/mass-update', requireCapability('assets.bulk'), (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id);
if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
@@ -51,16 +63,15 @@ router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) =
router.get('/assets/:id/depreciation', (req, res) => {
const asset = hydrateAsset(req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
const rules = activeRuleSet();
const startYear = Number(req.query.startYear || new Date().getFullYear());
const endYear = Number(req.query.endYear || startYear + 5);
const schedules = asset.books
.filter((book) => book.active)
.flatMap((book) => annualSchedule(asset, book, rules, { startYear, endYear }));
.flatMap((book) => annualSchedule(asset, book, ruleSetForBook(book.book_type), { startYear, endYear }));
res.json({ assetId: asset.asset_id, schedules });
});
router.post('/assets/:id/files', upload.single('file'), requireRole('admin', 'finance', 'operations'), (req, res) => {
router.post('/assets/:id/files', upload.single('file'), requireCapability('assets.edit'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'File is required' });
const result = run(
'INSERT INTO asset_files (asset_id, file_name, mime_type, size, content_base64, uploaded_by, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
@@ -78,14 +89,32 @@ router.get('/assets/:id/files/:fileId', (req, res) => {
return res.send(Buffer.from(file.content_base64, 'base64'));
});
router.delete('/assets/:id/files/:fileId', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.delete('/assets/:id/files/:fileId', requireCapability('assets.edit'), (req, res) => {
const result = run('DELETE FROM asset_files WHERE id = ? AND asset_id = ?', [req.params.fileId, req.params.id]);
if (!result.changes) return res.status(404).json({ error: 'File not found' });
audit(req.user.id, 'delete', 'asset_file', req.params.fileId, null, { asset_id: req.params.id });
return res.status(204).end();
});
router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operations'), (req, res) => {
// Identification photos (base64 data URLs, with optional captions)
router.post('/assets/:id/photos', requireCapability('assets.edit'), (req, res) => {
const photo = addAssetPhoto(req.params.id, req.body, req.user.id);
if (!photo) return res.status(404).json({ error: 'Asset not found' });
return res.status(201).json({ photo });
});
router.put('/assets/:id/photos/:photoId', requireCapability('assets.edit'), (req, res) => {
const photo = updateAssetPhoto(req.params.id, req.params.photoId, req.body, req.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found' });
return res.json({ photo });
});
router.delete('/assets/:id/photos/:photoId', requireCapability('assets.edit'), (req, res) => {
if (!deleteAssetPhoto(req.params.id, req.params.photoId, req.user.id)) return res.status(404).json({ error: 'Photo not found' });
return res.status(204).end();
});
router.post('/assets/:id/warranties', requireCapability('assets.edit'), (req, res) => {
const b = req.body;
const result = run(
`INSERT INTO warranties (
@@ -107,14 +136,14 @@ router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operation
});
});
router.delete('/assets/:id/warranties/:warrantyId', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.delete('/assets/:id/warranties/:warrantyId', requireCapability('assets.edit'), (req, res) => {
const result = run('DELETE FROM warranties WHERE id = ? AND asset_id = ?', [req.params.warrantyId, req.params.id]);
if (!result.changes) return res.status(404).json({ error: 'Warranty not found' });
audit(req.user.id, 'delete', 'warranty', req.params.warrantyId, null, { asset_id: req.params.id });
return res.status(204).end();
});
router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.post('/assets/:id/tasks', requireCapability('assets.edit'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO tasks (asset_id, title, type, status, due_date, assigned_to, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
@@ -124,7 +153,7 @@ router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'),
res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) });
});
router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.put('/assets/:id/tasks/:taskId', requireCapability('assets.edit'), (req, res) => {
const before = one('SELECT * FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]);
if (!before) return res.status(404).json({ error: 'Task not found' });
run(
@@ -143,14 +172,14 @@ router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operati
return res.json({ task: one('SELECT * FROM tasks WHERE id = ?', [req.params.taskId]) });
});
router.delete('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.delete('/assets/:id/tasks/:taskId', requireCapability('assets.edit'), (req, res) => {
const result = run('DELETE FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]);
if (!result.changes) return res.status(404).json({ error: 'Task not found' });
audit(req.user.id, 'delete', 'task', req.params.taskId, null, { asset_id: req.params.id });
return res.status(204).end();
});
router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.post('/assets/:id/notes', requireCapability('assets.edit'), (req, res) => {
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
const result = run(
'INSERT INTO asset_notes (asset_id, body, created_by, created_at) VALUES (?, ?, ?, ?)',
@@ -165,7 +194,7 @@ router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'),
});
});
router.delete('/assets/:id/notes/:noteId', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.delete('/assets/:id/notes/:noteId', requireCapability('assets.edit'), (req, res) => {
const result = run('DELETE FROM asset_notes WHERE id = ? AND asset_id = ?', [req.params.noteId, req.params.id]);
if (!result.changes) return res.status(404).json({ error: 'Note not found' });
audit(req.user.id, 'delete', 'asset_note', req.params.noteId, null, { asset_id: req.params.id });

View File

@@ -1,5 +1,5 @@
const express = require('express');
const { requireRole } = require('../middleware/auth');
const { requireCapability } = require('../middleware/auth');
const {
assignAsset,
assignmentRows,
@@ -15,7 +15,7 @@ router.get('/employees', (req, res) => {
res.json({ employees: listEmployees(req.query) });
});
router.post('/employees', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.post('/employees', requireCapability('assets.assign'), (req, res) => {
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) });
});
@@ -30,7 +30,7 @@ router.get('/assignments', (req, res) => {
});
});
router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req, res, next) => {
router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => {
try {
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
} catch (error) {
@@ -38,7 +38,7 @@ router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req,
}
});
router.put('/assignments/:id/release', requireRole('admin', 'finance', 'operations'), (req, res) => {
router.put('/assignments/:id/release', requireCapability('assets.assign'), (req, res) => {
const assignment = releaseAssignment(req.params.id, req.body, req.user.id);
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
return res.json({ assignment });

View File

@@ -2,6 +2,7 @@ const express = require('express');
const bcrypt = require('bcryptjs');
const { audit, one } = require('../db');
const { publicUser, tokenFor } = require('../middleware/auth');
const { capabilitiesForRole } = require('../services/roles');
const router = express.Router();
@@ -20,7 +21,7 @@ router.post('/auth/login', (req, res) => {
return res.status(401).json({ error: 'Invalid email or password' });
}
audit(user.id, 'login', 'user', user.id, null, { email: user.email });
return res.json({ token: tokenFor(user), user: publicUser(user) });
return res.json({ token: tokenFor(user), user: publicUser(user), capabilities: capabilitiesForRole(user.role) });
});
module.exports = router;

46
server/routes/books.js Normal file
View File

@@ -0,0 +1,46 @@
const express = require('express');
const { requireCapability } = require('../middleware/auth');
const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook } = require('../services/books');
const { exportReport } = require('../services/reportExport');
const router = express.Router();
router.get('/books', (req, res) => {
res.json(listBooks());
});
router.post('/books', requireCapability('finance.manage'), (req, res) => {
res.status(201).json({ book: createBook(req.body, req.user.id) });
});
router.put('/books/:id', requireCapability('finance.manage'), (req, res) => {
const book = updateBook(req.params.id, req.body, req.user.id);
if (!book) return res.status(404).json({ error: 'Book not found' });
return res.json({ book });
});
router.delete('/books/:id', requireCapability('finance.manage'), (req, res) => {
if (!deleteBook(req.params.id, req.user.id)) return res.status(404).json({ error: 'Book not found' });
return res.status(204).end();
});
router.get('/books/:code/ledger', (req, res) => {
const year = Number(req.query.year) || new Date().getFullYear();
const ledger = bookLedger(req.params.code, year);
if (!ledger) return res.status(404).json({ error: 'Book not found' });
return res.json({ ledger });
});
router.post('/books/:code/ledger/export', async (req, res) => {
const year = Number(req.body.year) || new Date().getFullYear();
const report = ledgerReport(req.params.code, year);
if (!report) return res.status(404).json({ error: 'Book not found' });
const format = String(req.body.format || 'csv').toLowerCase();
const output = await exportReport(report, format);
const extension = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf' }[format] || 'txt';
res.setHeader('Content-Type', output.contentType);
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.params.code}-ledger-${year}.${extension}"`);
return res.send(output.body);
});
module.exports = router;

53
server/routes/contacts.js Normal file
View File

@@ -0,0 +1,53 @@
const express = require('express');
const { requireCapability } = require('../middleware/auth');
const {
addNote,
createContact,
deleteContact,
deleteNote,
getContact,
listContacts,
updateContact
} = require('../services/contacts');
const router = express.Router();
const editor = requireCapability('contacts.manage');
router.get('/contacts', (req, res) => {
res.json({ contacts: listContacts(req.query) });
});
router.get('/contacts/:id', (req, res) => {
const contact = getContact(req.params.id);
if (!contact) return res.status(404).json({ error: 'Contact not found' });
return res.json({ contact });
});
router.post('/contacts', editor, (req, res) => {
res.status(201).json({ contact: createContact(req.body, req.user.id) });
});
router.put('/contacts/:id', editor, (req, res) => {
const contact = updateContact(req.params.id, req.body, req.user.id);
if (!contact) return res.status(404).json({ error: 'Contact not found' });
return res.json({ contact });
});
router.delete('/contacts/:id', requireCapability('contacts.manage'), (req, res) => {
if (!deleteContact(req.params.id, req.user.id)) return res.status(404).json({ error: 'Contact not found' });
return res.status(204).end();
});
router.post('/contacts/:id/notes', editor, (req, res) => {
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
const note = addNote(req.params.id, req.body.body, req.user.id);
if (!note) return res.status(404).json({ error: 'Contact not found' });
return res.status(201).json({ note });
});
router.delete('/contacts/:id/notes/:noteId', editor, (req, res) => {
if (!deleteNote(req.params.id, req.params.noteId, req.user.id)) return res.status(404).json({ error: 'Note not found' });
return res.status(204).end();
});
module.exports = router;

View File

@@ -1,7 +1,7 @@
const express = require('express');
const multer = require('multer');
const { audit } = require('../db');
const { requireRole } = require('../middleware/auth');
const { requireCapability } = require('../middleware/auth');
const { upsertImportedAssets } = require('../services/assets');
const { assetExport, extensionForUpload, rowsFromImport } = require('../services/importExport');
@@ -16,7 +16,7 @@ router.get('/export/assets', async (req, res) => {
return res.send(output.body);
});
router.post('/import/assets', requireRole('admin', 'finance'), upload.single('file'), async (req, res) => {
router.post('/import/assets', requireCapability('assets.bulk'), upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'File is required' });
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
const imported = upsertImportedAssets(rows, req.user.id);

View File

@@ -0,0 +1,44 @@
const express = require('express');
const { requireCapability } = require('../middleware/auth');
const {
deleteAdjustment,
previewDisposal,
recordAdjustment,
recordDisposal,
reverseDisposal
} = require('../services/disposals');
const router = express.Router();
router.post('/assets/:id/disposal/preview', (req, res) => {
const preview = previewDisposal(req.params.id, req.body);
if (!preview) return res.status(404).json({ error: 'Asset not found' });
return res.json({ preview });
});
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' });
return res.status(201).json(result);
});
router.delete('/assets/:id/disposals/:disposalId', requireCapability('finance.manage'), (req, res) => {
const asset = reverseDisposal(req.params.id, req.params.disposalId, req.user.id);
if (!asset) return res.status(404).json({ error: 'Disposal not found' });
return res.json({ asset });
});
router.post('/assets/:id/adjustments', requireCapability('finance.manage'), (req, res) => {
const adjustment = recordAdjustment(req.params.id, req.body, req.user.id);
if (!adjustment) return res.status(404).json({ error: 'Asset not found' });
return res.status(201).json({ adjustment });
});
router.delete('/assets/:id/adjustments/:adjustmentId', requireCapability('finance.manage'), (req, res) => {
if (!deleteAdjustment(req.params.id, req.params.adjustmentId, req.user.id)) {
return res.status(404).json({ error: 'Adjustment not found' });
}
return res.status(204).end();
});
module.exports = router;

39
server/routes/leases.js Normal file
View File

@@ -0,0 +1,39 @@
const express = require('express');
const { requireCapability } = require('../middleware/auth');
const {
createLease,
deleteLease,
getLease,
leaseSchedule,
listLeases,
updateLease
} = require('../services/leases');
const router = express.Router();
router.get('/leases', (req, res) => {
res.json({ leases: listLeases(req.query) });
});
router.post('/leases', requireCapability('finance.manage'), (req, res) => {
res.status(201).json({ lease: createLease(req.body, req.user.id) });
});
router.put('/leases/:id', requireCapability('finance.manage'), (req, res) => {
const lease = updateLease(req.params.id, req.body, req.user.id);
if (!lease) return res.status(404).json({ error: 'Lease not found' });
return res.json({ lease });
});
router.delete('/leases/:id', requireCapability('finance.manage'), (req, res) => {
if (!deleteLease(req.params.id, req.user.id)) return res.status(404).json({ error: 'Lease not found' });
return res.status(204).end();
});
router.get('/leases/:id/schedule', (req, res) => {
const lease = getLease(req.params.id);
if (!lease) return res.status(404).json({ error: 'Lease not found' });
return res.json({ lease, schedule: leaseSchedule(lease) });
});
module.exports = router;

77
server/routes/parts.js Normal file
View File

@@ -0,0 +1,77 @@
const express = require('express');
const { requireCapability } = require('../middleware/auth');
const {
addPhoto,
createPart,
deletePart,
deletePhoto,
deleteStock,
getPart,
listParts,
recordTransaction,
saveStock,
updatePart
} = require('../services/parts');
const router = express.Router();
const editor = requireCapability('parts.manage');
router.get('/parts', (req, res) => {
res.json({ parts: listParts(req.query) });
});
router.get('/parts/:id', (req, res) => {
const part = getPart(req.params.id);
if (!part) return res.status(404).json({ error: 'Part not found' });
return res.json({ part });
});
router.post('/parts', editor, (req, res) => {
res.status(201).json({ part: createPart(req.body, req.user.id) });
});
router.put('/parts/:id', editor, (req, res) => {
const part = updatePart(req.params.id, req.body, req.user.id);
if (!part) return res.status(404).json({ error: 'Part not found' });
return res.json({ part });
});
router.delete('/parts/:id', requireCapability('parts.manage'), (req, res) => {
if (!deletePart(req.params.id, req.user.id)) return res.status(404).json({ error: 'Part not found' });
return res.status(204).end();
});
// Stock locations (aisle/bin + on-hand/reserved per location)
router.post('/parts/:id/stock', editor, (req, res) => {
const part = saveStock(req.params.id, req.body, req.user.id);
if (!part) return res.status(404).json({ error: 'Part or stock location not found' });
return res.status(201).json({ part });
});
router.delete('/parts/:id/stock/:stockId', editor, (req, res) => {
const part = deleteStock(req.params.id, req.params.stockId, req.user.id);
if (!part) return res.status(404).json({ error: 'Stock location not found' });
return res.json({ part });
});
// Stock movements (receive/issue/adjust/reserve/unreserve)
router.post('/parts/:id/transactions', editor, (req, res) => {
const part = recordTransaction(req.params.id, req.body, req.user.id);
if (!part) return res.status(404).json({ error: 'Part not found' });
return res.status(201).json({ part });
});
// Photos
router.post('/parts/:id/photos', editor, (req, res) => {
const part = addPhoto(req.params.id, req.body, req.user.id);
if (!part) return res.status(404).json({ error: 'Part not found' });
return res.status(201).json({ part });
});
router.delete('/parts/:id/photos/:photoId', editor, (req, res) => {
const part = deletePhoto(req.params.id, req.params.photoId, req.user.id);
if (!part) return res.status(404).json({ error: 'Photo not found' });
return res.json({ part });
});
module.exports = router;

96
server/routes/pm.js Normal file
View File

@@ -0,0 +1,96 @@
const express = require('express');
const { requireCapability } = require('../middleware/auth');
const {
assetPmRows,
attachPlan,
completePm,
createPlan,
deletePlan,
detachAssetPm,
getCompletion,
getPlan,
getSettings,
listCompletions,
listPlans,
saveSettings,
updateAssetPm,
updatePlan
} = require('../services/pm');
const router = express.Router();
const planEditor = requireCapability('pm.manage'); // create/edit PM plan templates & defaults
const assetPm = requireCapability('pm.complete'); // attach to assets & complete services
// PM plan templates
router.get('/pm-plans', (req, res) => {
res.json({ plans: listPlans() });
});
router.get('/pm-plans/:id', (req, res) => {
const plan = getPlan(req.params.id);
if (!plan) return res.status(404).json({ error: 'PM plan not found' });
return res.json({ plan });
});
router.post('/pm-plans', planEditor, (req, res) => {
res.status(201).json({ plan: createPlan(req.body, req.user.id) });
});
router.put('/pm-plans/:id', planEditor, (req, res) => {
const plan = updatePlan(req.params.id, req.body, req.user.id);
if (!plan) return res.status(404).json({ error: 'PM plan not found' });
return res.json({ plan });
});
router.delete('/pm-plans/:id', planEditor, (req, res) => {
if (!deletePlan(req.params.id, req.user.id)) return res.status(404).json({ error: 'PM plan not found' });
return res.status(204).end();
});
// PM defaults (readable by any authenticated user; pm.manage can change)
router.get('/pm-settings', (req, res) => {
res.json({ settings: getSettings() });
});
router.put('/pm-settings', planEditor, (req, res) => {
res.json({ settings: saveSettings(req.body, req.user.id) });
});
// Completed PM forms
router.get('/pm-completions', (req, res) => {
res.json({ completions: listCompletions(req.query) });
});
router.get('/pm-completions/:id', (req, res) => {
const completion = getCompletion(req.params.id);
if (!completion) return res.status(404).json({ error: 'Completion not found' });
return res.json({ completion });
});
// Per-asset PM schedules
router.get('/assets/:id/pm', (req, res) => {
res.json({ pm: assetPmRows(req.params.id) });
});
router.post('/assets/:id/pm', assetPm, (req, res) => {
res.status(201).json({ pm: attachPlan(req.params.id, req.body, req.user.id) });
});
router.put('/assets/:id/pm/:apId', assetPm, (req, res) => {
const pm = updateAssetPm(req.params.id, req.params.apId, req.body, req.user.id);
if (!pm) return res.status(404).json({ error: 'PM schedule not found' });
return res.json({ pm });
});
router.delete('/assets/:id/pm/:apId', assetPm, (req, res) => {
if (!detachAssetPm(req.params.id, req.params.apId, req.user.id)) return res.status(404).json({ error: 'PM schedule not found' });
return res.status(204).end();
});
router.post('/assets/:id/pm/:apId/complete', assetPm, (req, res) => {
const pm = completePm(req.params.id, req.params.apId, req.body, req.user.id);
if (!pm) return res.status(404).json({ error: 'PM schedule not found' });
return res.json({ pm });
});
module.exports = router;

View File

@@ -1,14 +1,75 @@
const express = require('express');
const { all, audit, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const { all, audit, json, now, one, parseJson, run } = require('../db');
const { requireCapability } = require('../middleware/auth');
const { formatAssetId } = require('../services/assets');
const assetClasses = require('../services/assetClasses');
const zones = require('../services/zones');
const router = express.Router();
// Special depreciation zones (e.g. New York Liberty Zone). GET is reference data used by the
// asset form; mutations require config.manage.
router.get('/depreciation-zones', (req, res) => {
res.json({ zones: zones.list() });
});
router.post('/depreciation-zones', requireCapability('config.manage'), (req, res, next) => {
try {
res.status(201).json({ zone: zones.createZone(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/depreciation-zones/:code', requireCapability('config.manage'), (req, res, next) => {
try {
const zone = zones.updateZone(req.params.code, req.body, req.user.id);
if (!zone) return res.status(404).json({ error: 'Zone not found' });
return res.json({ zone });
} catch (error) {
return next(error);
}
});
router.delete('/depreciation-zones/:code', requireCapability('config.manage'), (req, res) => {
if (!zones.deleteZone(req.params.code, req.user.id)) return res.status(404).json({ error: 'Zone 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) => {
res.json({ assetClasses: assetClasses.list() });
});
router.post('/asset-classes', requireCapability('config.manage'), (req, res, next) => {
try {
res.status(201).json({ assetClass: assetClasses.createClass(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/asset-classes/:id', requireCapability('config.manage'), (req, res, next) => {
try {
const assetClass = assetClasses.updateClass(req.params.id, req.body, req.user.id);
if (!assetClass) return res.status(404).json({ error: 'Asset class not found' });
return res.json({ assetClass });
} catch (error) {
return next(error);
}
});
router.delete('/asset-classes/:id', requireCapability('config.manage'), (req, res) => {
if (!assetClasses.deleteClass(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset class not found' });
return res.status(204).end();
});
router.get('/entities', (req, res) => {
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
});
router.post('/entities', requireRole('admin', 'finance'), (req, res) => {
router.post('/entities', requireCapability('config.manage'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
@@ -31,4 +92,325 @@ router.get('/references', (req, res) => {
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
});
// ---- Asset categories (managed reference_values of type 'category') ----------
// Asset category is stored on each asset by name, so a rename cascades to assets.
function categoryRow(row) {
const meta = parseJson(row.metadata, {});
const templateId = meta.id_template_id || null;
const template = templateId ? one('SELECT name, pattern FROM asset_id_templates WHERE id = ?', [templateId]) : null;
return {
id: row.id,
name: row.name,
code: row.code,
asset_class_number: meta.asset_class_number || null,
id_template_id: template ? templateId : null,
id_template_name: template ? template.name : null,
id_template_pattern: template ? template.pattern : null,
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [row.name]).c
};
}
const categoryEditor = requireCapability('config.manage');
router.get('/asset-categories', (req, res) => {
const rows = all("SELECT * FROM reference_values WHERE type = 'category' ORDER BY name");
res.json({ categories: rows.map(categoryRow) });
});
router.post('/asset-categories', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Category name is required' });
if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
return res.status(400).json({ error: 'That category already exists' });
}
const result = run(
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('category', ?, ?, ?)",
[name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_number: req.body.asset_class_number || null })]
);
audit(req.user.id, 'create', 'asset_category', result.lastInsertRowid, null, { name });
return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
});
router.put('/asset-categories/:id', categoryEditor, (req, res) => {
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]);
if (!before) return res.status(404).json({ error: 'Category not found' });
const name = String(req.body.name ?? before.name).trim();
if (!name) return res.status(400).json({ error: 'Category name is required' });
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
return res.status(400).json({ error: 'That category already exists' });
}
const meta = parseJson(before.metadata, {});
if (req.body.id_template_id !== undefined) meta.id_template_id = req.body.id_template_id || null;
if (req.body.asset_class_number !== undefined) meta.asset_class_number = req.body.asset_class_number || null;
run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]);
let renamed = 0;
if (name !== before.name) {
renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
}
// The class number is shared by every asset in the category — cascade any change.
if (req.body.asset_class_number !== undefined) {
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ?', [meta.asset_class_number, now(), name]);
}
audit(req.user.id, 'update', 'asset_category', req.params.id, before, { name, renamed_assets: renamed });
return res.json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
});
router.delete('/asset-categories/:id', categoryEditor, (req, res) => {
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]);
if (!before) return res.status(404).json({ error: 'Category not found' });
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [before.name]).c;
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'asset_category', req.params.id, before, { asset_count: affected });
return res.json({ deleted: true, asset_count: affected });
});
// ---- Asset departments (managed reference_values of type 'department') -------
// Department is stored on each asset by name, so a rename cascades to assets.
function departmentRow(row) {
return {
id: row.id,
name: row.name,
code: row.code,
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [row.name]).c
};
}
router.get('/asset-departments', (req, res) => {
const rows = all("SELECT * FROM reference_values WHERE type = 'department' ORDER BY name");
res.json({ departments: rows.map(departmentRow) });
});
router.post('/asset-departments', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Department name is required' });
if (one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) {
return res.status(400).json({ error: 'That department already exists' });
}
const result = run(
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('department', ?, ?, '{}')",
[name, req.body.code || null]
);
audit(req.user.id, 'create', 'asset_department', result.lastInsertRowid, null, { name });
return res.status(201).json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
});
router.put('/asset-departments/:id', categoryEditor, (req, res) => {
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]);
if (!before) return res.status(404).json({ error: 'Department not found' });
const name = String(req.body.name ?? before.name).trim();
if (!name) return res.status(400).json({ error: 'Department name is required' });
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) {
return res.status(400).json({ error: 'That department already exists' });
}
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
let renamed = 0;
if (name !== before.name) {
renamed = run('UPDATE assets SET department = ?, updated_at = ? WHERE department = ?', [name, now(), before.name]).changes || 0;
}
audit(req.user.id, 'update', 'asset_department', req.params.id, before, { name, renamed_assets: renamed });
return res.json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
});
router.delete('/asset-departments/:id', categoryEditor, (req, res) => {
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]);
if (!before) return res.status(404).json({ error: 'Department not found' });
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [before.name]).c;
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'asset_department', req.params.id, before, { asset_count: affected });
return res.json({ deleted: true, asset_count: affected });
});
// ---- PM categories (managed reference_values of type 'pm_category') ----------
// Separate from asset categories. Stored on each PM plan by name, so a rename cascades.
function pmCategoryRow(row) {
return {
id: row.id,
name: row.name,
code: row.code,
plan_count: one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [row.name]).c
};
}
router.get('/pm-categories', (req, res) => {
const rows = all("SELECT * FROM reference_values WHERE type = 'pm_category' ORDER BY name");
res.json({ categories: rows.map(pmCategoryRow) });
});
router.post('/pm-categories', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Category name is required' });
if (one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) {
return res.status(400).json({ error: 'That PM category already exists' });
}
const result = run(
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('pm_category', ?, ?, '{}')",
[name, req.body.code || null]
);
audit(req.user.id, 'create', 'pm_category', result.lastInsertRowid, null, { name });
return res.status(201).json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
});
router.put('/pm-categories/:id', categoryEditor, (req, res) => {
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]);
if (!before) return res.status(404).json({ error: 'PM category not found' });
const name = String(req.body.name ?? before.name).trim();
if (!name) return res.status(400).json({ error: 'Category name is required' });
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) {
return res.status(400).json({ error: 'That PM category already exists' });
}
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
let renamed = 0;
if (name !== before.name) {
renamed = run('UPDATE pm_plans SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
}
audit(req.user.id, 'update', 'pm_category', req.params.id, before, { name, renamed_plans: renamed });
return res.json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_plans: renamed });
});
router.delete('/pm-categories/:id', categoryEditor, (req, res) => {
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]);
if (!before) return res.status(404).json({ error: 'PM category not found' });
const affected = one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [before.name]).c;
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'pm_category', req.params.id, before, { plan_count: affected });
return res.json({ deleted: true, plan_count: affected });
});
// ---- Part categories (managed reference_values of type 'part_category') ------
// Separate from asset/PM categories. Stored on each part by name, so a rename cascades.
function partCategoryRow(row) {
return {
id: row.id,
name: row.name,
code: row.code,
part_count: one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [row.name]).c
};
}
router.get('/part-categories', (req, res) => {
const rows = all("SELECT * FROM reference_values WHERE type = 'part_category' ORDER BY name");
res.json({ categories: rows.map(partCategoryRow) });
});
router.post('/part-categories', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Category name is required' });
if (one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) {
return res.status(400).json({ error: 'That part category already exists' });
}
const result = run(
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('part_category', ?, ?, '{}')",
[name, req.body.code || null]
);
audit(req.user.id, 'create', 'part_category', result.lastInsertRowid, null, { name });
return res.status(201).json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
});
router.put('/part-categories/:id', categoryEditor, (req, res) => {
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
if (!before) return res.status(404).json({ error: 'Part category not found' });
const name = String(req.body.name ?? before.name).trim();
if (!name) return res.status(400).json({ error: 'Category name is required' });
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) {
return res.status(400).json({ error: 'That part category already exists' });
}
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
let renamed = 0;
if (name !== before.name) {
renamed = run('UPDATE parts SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
}
audit(req.user.id, 'update', 'part_category', req.params.id, before, { name, renamed_parts: renamed });
return res.json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_parts: renamed });
});
router.delete('/part-categories/:id', categoryEditor, (req, res) => {
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
if (!before) return res.status(404).json({ error: 'Part category not found' });
const affected = one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [before.name]).c;
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'part_category', req.params.id, before, { part_count: affected });
return res.json({ deleted: true, part_count: affected });
});
// ---- Asset ID templates -----------------------------------------------------
// Naming patterns (e.g. 'DT-######') used to auto-assign asset tags. The first run of
// '#' is replaced with a zero-padded, auto-incrementing number. Assigned to categories.
function idTemplateCategoryCount(templateId) {
return all("SELECT metadata FROM reference_values WHERE type = 'category'")
.filter((row) => parseJson(row.metadata, {}).id_template_id === templateId).length;
}
function idTemplateRow(row) {
return {
id: row.id,
name: row.name,
pattern: row.pattern,
next_number: row.next_number,
preview: formatAssetId(row.pattern, row.next_number),
category_count: idTemplateCategoryCount(row.id)
};
}
router.get('/asset-id-templates', (req, res) => {
res.json({ templates: all('SELECT * FROM asset_id_templates ORDER BY name').map(idTemplateRow) });
});
router.post('/asset-id-templates', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim();
const pattern = String(req.body.pattern || '').trim();
if (!name) return res.status(400).json({ error: 'A template name is required' });
if (!pattern.includes('#')) return res.status(400).json({ error: 'Pattern must include at least one # for the number' });
const next = Math.max(1, Number(req.body.next_number) || 1);
const ts = now();
const result = run(
'INSERT INTO asset_id_templates (name, pattern, next_number, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
[name, pattern, next, ts, ts]
);
audit(req.user.id, 'create', 'asset_id_template', result.lastInsertRowid, null, { name, pattern });
return res.status(201).json({ template: idTemplateRow(one('SELECT * FROM asset_id_templates WHERE id = ?', [result.lastInsertRowid])) });
});
router.put('/asset-id-templates/:id', categoryEditor, (req, res) => {
const before = one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id]);
if (!before) return res.status(404).json({ error: 'Template not found' });
const pattern = req.body.pattern !== undefined ? String(req.body.pattern).trim() : before.pattern;
if (!pattern.includes('#')) return res.status(400).json({ error: 'Pattern must include at least one # for the number' });
run(
'UPDATE asset_id_templates SET name = ?, pattern = ?, next_number = ?, updated_at = ? WHERE id = ?',
[
req.body.name !== undefined ? String(req.body.name).trim() || before.name : before.name,
pattern,
req.body.next_number !== undefined ? Math.max(1, Number(req.body.next_number) || 1) : before.next_number,
now(),
req.params.id
]
);
audit(req.user.id, 'update', 'asset_id_template', req.params.id, before, req.body);
return res.json({ template: idTemplateRow(one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id])) });
});
router.delete('/asset-id-templates/:id', categoryEditor, (req, res) => {
const before = one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id]);
if (!before) return res.status(404).json({ error: 'Template not found' });
const id = Number(req.params.id);
// Unassign from any categories that referenced it.
let unassigned = 0;
for (const row of all("SELECT id, metadata FROM reference_values WHERE type = 'category'")) {
const meta = parseJson(row.metadata, {});
if (meta.id_template_id === id) {
meta.id_template_id = null;
run('UPDATE reference_values SET metadata = ? WHERE id = ?', [json(meta), row.id]);
unassigned += 1;
}
}
run('DELETE FROM asset_id_templates WHERE id = ?', [id]);
audit(req.user.id, 'delete', 'asset_id_template', id, before, { unassigned_categories: unassigned });
return res.json({ deleted: true, category_count: unassigned });
});
module.exports = router;

View File

@@ -1,12 +1,61 @@
const express = require('express');
const { all, audit, json, now, one, parseJson, run } = require('../db');
const { requireCapability } = require('../middleware/auth');
const {
depreciationReport,
monthlyDepreciationReport,
writeDepreciationPdf
} = require('../services/reports');
const { catalog, runReport } = require('../services/reportEngine');
const { exportReport } = require('../services/reportExport');
const router = express.Router();
const EXPORT_EXTENSIONS = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf', json: 'json' };
router.get('/reports/catalog', (req, res) => {
res.json(catalog());
});
router.post('/reports/run', (req, res) => {
res.json({ report: runReport(req.body.type, req.body.options || {}) });
});
router.post('/reports/export', async (req, res) => {
const format = String(req.body.format || 'csv').toLowerCase();
const report = runReport(req.body.type, req.body.options || {});
const output = await exportReport(report, format);
const extension = EXPORT_EXTENSIONS[format] || 'txt';
res.setHeader('Content-Type', output.contentType);
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.body.type}.${extension}"`);
res.send(output.body);
});
router.get('/saved-reports', (req, res) => {
const reports = all(
`SELECT s.*, u.name AS created_by_name FROM saved_reports s
LEFT JOIN users u ON u.id = s.created_by ORDER BY s.name`
).map((row) => ({ ...row, options_json: parseJson(row.options_json, {}) }));
res.json({ reports });
});
router.post('/saved-reports', requireCapability('reports.save'), (req, res) => {
const result = run(
'INSERT INTO saved_reports (name, report_type, options_json, created_by, created_at) VALUES (?, ?, ?, ?, ?)',
[req.body.name || 'Saved report', req.body.report_type, json(req.body.options || {}), req.user.id, now()]
);
audit(req.user.id, 'create', 'saved_report', result.lastInsertRowid, null, req.body);
const saved = one('SELECT * FROM saved_reports WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ report: { ...saved, options_json: parseJson(saved.options_json, {}) } });
});
router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res) => {
const result = run('DELETE FROM saved_reports WHERE id = ?', [req.params.id]);
if (!result.changes) return res.status(404).json({ error: 'Saved report not found' });
audit(req.user.id, 'delete', 'saved_report', req.params.id, null, null);
return res.status(204).end();
});
router.get('/reports/depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP';

View File

@@ -0,0 +1,31 @@
const express = require('express');
const { requireCapability } = require('../middleware/auth');
const { publicConfig, saveConfig, syncCmdb, testConnection } = require('../services/servicenow');
const router = express.Router();
router.get('/servicenow/settings', requireCapability('admin.integrations'), (req, res) => {
res.json({ settings: publicConfig() });
});
router.put('/servicenow/settings', requireCapability('admin.integrations'), (req, res) => {
res.json({ settings: saveConfig(req.body, req.user.id) });
});
router.post('/servicenow/test', requireCapability('admin.integrations'), async (req, res, next) => {
try {
res.json(await testConnection(req.user.id));
} catch (error) {
next(error);
}
});
router.post('/servicenow/cmdb-sync', requireCapability('assets.bulk'), async (req, res, next) => {
try {
res.json(await syncCmdb(req.user.id));
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -1,34 +1,120 @@
const express = require('express');
const { all, audit, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const { all, audit, now, one, parseJson, run, tx } = require('../db');
const { requireCapability } = require('../middleware/auth');
const { expandRuleSet } = require('../rule-catalog');
const router = express.Router();
function coerceRules(value) {
if (value === undefined || value === null) return {};
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
const error = new Error('rules_json is not valid JSON');
error.status = 400;
throw error;
}
}
if (typeof value !== 'object') {
const error = new Error('rules_json must be a JSON object');
error.status = 400;
throw error;
}
return value;
}
function serialize(row) {
return { ...row, active: Boolean(row.active), rules_json: parseJson(row.rules_json, {}) };
}
router.get('/tax-rules', (req, res) => {
const ruleSets = all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map((row) => ({
...row,
active: Boolean(row.active),
rules_json: parseJson(row.rules_json, {})
}));
const ruleSets = all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map(serialize);
res.json({ ruleSets });
});
router.post('/tax-rules', requireRole('admin', 'finance'), (req, res) => {
const result = run(
'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
req.body.jurisdiction,
req.body.name,
req.body.effective_date,
req.body.version,
JSON.stringify(req.body.rules_json || req.body.rules || {}),
req.body.active === false ? 0 : 1,
req.body.source_note || null,
now()
]
);
audit(req.user.id, 'create', 'tax_rule_set', result.lastInsertRowid, null, req.body);
res.status(201).json({ ruleSet: one('SELECT * FROM tax_rule_sets WHERE id = ?', [result.lastInsertRowid]) });
router.post('/tax-rules', requireCapability('finance.manage'), (req, res, next) => {
try {
const rules = coerceRules(req.body.rules_json ?? req.body.rules);
const result = run(
'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
req.body.jurisdiction || rules.jurisdiction || 'US-FED',
req.body.name || rules.name || 'Untitled rule set',
req.body.effective_date || rules.effectiveDate || rules.effective_date || new Date().toISOString().slice(0, 10),
req.body.version || rules.version || '1.0',
JSON.stringify(rules),
req.body.active === false ? 0 : 1,
req.body.source_note || rules.sourceNote || null,
now()
]
);
audit(req.user.id, 'create', 'tax_rule_set', result.lastInsertRowid, null, { ...req.body, rules_json: '[stored]' });
res.status(201).json({ ruleSet: serialize(one('SELECT * FROM tax_rule_sets WHERE id = ?', [result.lastInsertRowid])) });
} catch (error) {
next(error);
}
});
router.put('/tax-rules/:id', requireCapability('finance.manage'), (req, res, next) => {
try {
const before = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
if (!before) return res.status(404).json({ error: 'Rule set not found' });
const rules = req.body.rules_json !== undefined || req.body.rules !== undefined
? coerceRules(req.body.rules_json ?? req.body.rules)
: parseJson(before.rules_json, {});
run(
`UPDATE tax_rule_sets SET
jurisdiction = ?, name = ?, effective_date = ?, version = ?, rules_json = ?, active = ?, source_note = ?
WHERE id = ?`,
[
req.body.jurisdiction ?? before.jurisdiction,
req.body.name ?? before.name,
req.body.effective_date ?? before.effective_date,
req.body.version ?? before.version,
JSON.stringify(rules),
req.body.active === undefined ? before.active : (req.body.active ? 1 : 0),
req.body.source_note ?? before.source_note,
req.params.id
]
);
audit(req.user.id, 'update', 'tax_rule_set', req.params.id, { ...before, rules_json: '[stored]' }, { ...req.body, rules_json: '[stored]' });
return res.json({ ruleSet: serialize(one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id])) });
} catch (error) {
if (/UNIQUE/.test(error.message || '')) {
return res.status(400).json({ error: 'A rule set with this jurisdiction and version already exists' });
}
return next(error);
}
});
router.delete('/tax-rules/:id', requireCapability('finance.manage'), (req, res) => {
const before = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
if (!before) return res.status(404).json({ error: 'Rule set not found' });
run('DELETE FROM tax_rule_sets WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'tax_rule_set', req.params.id, { ...before, rules_json: '[stored]' }, null);
return res.status(204).end();
});
router.post('/tax-rules/:id/activate', requireCapability('finance.manage'), (req, res) => {
const target = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
if (!target) return res.status(404).json({ error: 'Rule set not found' });
tx(() => {
run('UPDATE tax_rule_sets SET active = 0 WHERE jurisdiction = ? AND id != ?', [target.jurisdiction, target.id]);
run('UPDATE tax_rule_sets SET active = 1 WHERE id = ?', [target.id]);
});
audit(req.user.id, 'activate', 'tax_rule_set', target.id, null, { jurisdiction: target.jurisdiction });
return res.json({ ruleSets: all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map(serialize) });
});
// Merge the generated 68-method depreciation catalog into a rule set (works on unsaved drafts too).
router.post('/tax-rules/expand', requireCapability('finance.manage'), (req, res, next) => {
try {
const rules = coerceRules(req.body.rules_json ?? req.body.rules);
return res.json({ rules_json: expandRuleSet(rules) });
} catch (error) {
return next(error);
}
});
module.exports = router;

View File

@@ -1,11 +1,14 @@
const express = require('express');
const { all, audit, json, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const { requireCapability } = require('../middleware/auth');
const router = express.Router();
router.get('/templates', (req, res) => {
const templates = all('SELECT * FROM asset_templates ORDER BY name').map((row) => ({
const templates = all(
`SELECT t.*, (SELECT COUNT(*) FROM assets a WHERE a.template_id = t.id) AS asset_count
FROM asset_templates t ORDER BY t.name`
).map((row) => ({
...row,
defaults: parseJson(row.defaults, {}),
custom_fields: parseJson(row.custom_fields, [])
@@ -13,7 +16,7 @@ router.get('/templates', (req, res) => {
res.json({ templates });
});
router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
router.post('/templates', requireCapability('config.manage'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
@@ -23,4 +26,34 @@ router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
});
router.put('/templates/:id', requireCapability('config.manage'), (req, res) => {
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
if (!before) return res.status(404).json({ error: 'Template not found' });
run(
'UPDATE asset_templates SET name = ?, description = ?, defaults = ?, custom_fields = ?, updated_at = ? WHERE id = ?',
[
req.body.name ?? before.name,
req.body.description ?? before.description,
json(req.body.defaults || {}),
json(req.body.custom_fields || []),
now(),
req.params.id
]
);
audit(req.user.id, 'update', 'asset_template', req.params.id, before, req.body);
return res.json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]) });
});
// Deleting a template unlinks it from any assets (their stored field values are kept)
// and removes it from the picker for future assets.
router.delete('/templates/:id', requireCapability('config.manage'), (req, res) => {
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
if (!before) return res.status(404).json({ error: 'Template not found' });
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE template_id = ?', [req.params.id]).c;
run('UPDATE assets SET template_id = NULL, updated_at = ? WHERE template_id = ?', [now(), req.params.id]);
run('DELETE FROM asset_templates WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'asset_template', req.params.id, before, { unlinked_assets: affected });
return res.json({ deleted: true, unlinked_assets: affected });
});
module.exports = router;

View File

@@ -1,5 +1,5 @@
const express = require('express');
const { requireRole } = require('../middleware/auth');
const { requireCapability } = require('../middleware/auth');
const {
getConnection,
importWorkersFromPayload,
@@ -9,15 +9,15 @@ const {
const router = express.Router();
router.get('/workday/connection', requireRole('admin'), (req, res) => {
router.get('/workday/connection', requireCapability('admin.integrations'), (req, res) => {
res.json({ connection: getConnection() });
});
router.put('/workday/connection', requireRole('admin'), (req, res) => {
router.put('/workday/connection', requireCapability('admin.integrations'), (req, res) => {
res.json({ connection: saveConnection(req.body, req.user.id) });
});
router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next) => {
router.post('/workday/sync-workers', requireCapability('admin.integrations'), async (req, res, next) => {
try {
res.json(await syncWorkers(req.user.id));
} catch (error) {
@@ -25,7 +25,7 @@ router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next
}
});
router.post('/workday/import-workers', requireRole('admin'), (req, res) => {
router.post('/workday/import-workers', requireCapability('admin.integrations'), (req, res) => {
res.json(importWorkersFromPayload(req.body.workers || req.body, req.user.id));
});

View File

@@ -111,8 +111,35 @@ function buildSpecialMethods() {
];
}
// Sage-style 200% declining-balance method codes. The MACRS family (MF/MT/MI) ignores
// salvage value and recovers the full basis; the standard family (DB/DH/DD) honors salvage
// (depreciates only down to it). All use a 200% rate and switch to straight-line.
function build200Methods() {
return [
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', {
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', {
system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true, indianReservation: true
}),
method('DB200', 'Declining Balance', 'DB200 — 200% Declining Balance to SL (uses book convention)', 'declining_balance', {
rateMultiplier: 2, switchToStraightLine: true
}),
method('DH200', 'Declining Balance', 'DH200 — 200% Declining Balance to SL, Half-Year', 'declining_balance', {
rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year'
}),
method('DD200', 'Declining Balance', 'DD200 — 200% Declining Balance to SL, Full-Year', 'declining_balance', {
rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'none'
})
];
}
function buildDepreciationMethods() {
return [
...build200Methods(),
...buildMacrsMethods(),
...buildAcrsMethods(),
...buildGaapMethods(),

View File

@@ -1,47 +1,81 @@
const roles = ['admin', 'finance', 'operations', 'viewer'];
// Capability catalog — the granular permissions the application understands. Routes are
// guarded by these keys (see middleware/auth requireCapability); roles are sets of them.
// This is pure data (no DB) so it can be required anywhere without circular dependencies.
const rolePermissions = {
admin: [
'Manage users, teams, roles, settings, and integrations',
'Create, edit, delete, import, export, and assign assets',
'Manage templates, tax rules, reports, and Workday sync'
],
finance: [
'Create, edit, delete, import, and export assets',
'Manage templates, tax rules, reports, and depreciation workflows',
'Assign and release assets'
],
operations: [
'Create and edit assets',
'Assign and release assets',
'Manage asset tasks, files, employees, and custody details'
],
viewer: [
'View assets, assignments, employees, templates, tax rules, and reports',
'No create, update, delete, import, export, or integration access'
]
};
const roleCapabilities = [
{ key: 'view_assets', label: 'View assets', roles: ['admin', 'finance', 'operations', 'viewer'] },
{ key: 'edit_assets', label: 'Create/edit assets', roles: ['admin', 'finance', 'operations'] },
{ key: 'delete_assets', label: 'Delete assets', roles: ['admin', 'finance'] },
{ key: 'import_export', label: 'Import/export data', roles: ['admin', 'finance'] },
{ key: 'assign_assets', label: 'Assign assets', roles: ['admin', 'finance', 'operations'] },
{ key: 'templates', label: 'Manage templates', roles: ['admin', 'finance'] },
{ key: 'tax_rules', label: 'Manage tax rules', roles: ['admin', 'finance'] },
{ key: 'workday', label: 'Manage Workday connector', roles: ['admin'] },
{ key: 'users', label: 'Manage users and teams', roles: ['admin'] },
{ key: 'settings', label: 'Manage app settings', roles: ['admin'] }
const CAPABILITIES = [
// Assets
{ key: 'assets.view', label: 'View assets', group: 'Assets' },
{ key: 'assets.edit', label: 'Create & edit assets (files, photos, warranties, tasks, notes)', group: 'Assets' },
{ key: 'assets.delete', label: 'Delete assets', group: 'Assets' },
{ key: 'assets.bulk', label: 'Import, export, mass-update & CMDB sync', group: 'Assets' },
{ key: 'assets.assign', label: 'Assign assets & manage employees', group: 'Assets' },
// Maintenance
{ key: 'pm.view', label: 'View maintenance', group: 'Maintenance' },
{ key: 'pm.manage', label: 'Create & edit PM plans and defaults', group: 'Maintenance' },
{ key: 'pm.complete', label: 'Attach & complete PM on assets', group: 'Maintenance' },
{ key: 'parts.manage', label: 'Manage parts inventory', group: 'Maintenance' },
{ key: 'alerts.manage', label: 'Work alerts (scan, acknowledge, dismiss, ticket)', group: 'Maintenance' },
// Contacts
{ key: 'contacts.manage', label: 'Manage contacts & CRM', group: 'Contacts' },
// Finance
{ key: 'finance.view', label: 'View reports, books & tax rules', group: 'Finance' },
{ key: 'finance.manage', label: 'Manage books, tax rules, disposals & leases', group: 'Finance' },
{ key: 'reports.save', label: 'Save & delete reports', group: 'Finance' },
// Configuration
{ key: 'config.manage', label: 'Manage templates, categories & ID templates', group: 'Configuration' },
// Administration
{ key: 'admin.users', label: 'Manage users & teams', group: 'Administration' },
{ key: 'admin.roles', label: 'Manage roles', group: 'Administration' },
{ key: 'admin.settings', label: 'Manage application settings', group: 'Administration' },
{ key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' }
];
function isValidRole(role) {
return roles.includes(role);
const CAPABILITY_KEYS = CAPABILITIES.map((c) => c.key);
// '*' is a wildcard granting every capability (used by the admin role and future-proofs new ones).
const WILDCARD = '*';
const FINANCE_CAPS = [
'assets.view', 'assets.edit', 'assets.delete', 'assets.bulk', 'assets.assign',
'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage',
'contacts.manage', 'finance.view', 'finance.manage', 'reports.save', 'config.manage'
];
const OPERATIONS_CAPS = [
'assets.view', 'assets.edit', 'assets.assign',
'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage',
'contacts.manage', 'reports.save'
];
// Built-in roles. is_system roles cannot be deleted (admin/built-ins) but their
// capabilities can be edited, except admin which always keeps the wildcard.
const DEFAULT_ROLES = [
{ key: 'admin', name: 'Administrator', description: 'Full system access, including users, roles, settings, and integrations.', capabilities: [WILDCARD], is_system: 1, locked: 1, sort_order: 0 },
{ key: 'finance', name: 'Finance', description: 'Full asset, maintenance, and financial management; no system administration.', capabilities: FINANCE_CAPS, is_system: 1, locked: 0, sort_order: 1 },
{ key: 'operations', name: 'Operations', description: 'Day-to-day asset, maintenance, parts, contacts, and alert work.', capabilities: OPERATIONS_CAPS, is_system: 1, locked: 0, sort_order: 2 },
{ key: 'pm_admin', name: 'PM Admin', description: 'Manages preventative maintenance: plans, defaults, completion, parts, and alerts.', capabilities: ['assets.view', 'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage', 'reports.save'], is_system: 1, locked: 0, sort_order: 3 },
{ key: 'pm_user', name: 'PM User', description: 'Completes preventative maintenance and views assets, parts, and alerts.', capabilities: ['assets.view', 'pm.view', 'pm.complete', 'alerts.manage'], is_system: 1, locked: 0, sort_order: 4 },
{ key: 'viewer', name: 'Viewer', description: 'Read-only access to assets, maintenance, and reports.', capabilities: ['assets.view', 'pm.view', 'finance.view'], is_system: 1, locked: 0, sort_order: 5 }
];
// Does a capability set satisfy a required capability? Honors the wildcard.
function capabilitySetIncludes(capabilities, required) {
if (!Array.isArray(capabilities)) return false;
return capabilities.includes(WILDCARD) || capabilities.includes(required);
}
// Normalize an arbitrary capability list to known keys (admin/wildcard preserved).
function sanitizeCapabilities(list) {
if (Array.isArray(list) && list.includes(WILDCARD)) return [WILDCARD];
const set = new Set((Array.isArray(list) ? list : []).filter((key) => CAPABILITY_KEYS.includes(key)));
return CAPABILITY_KEYS.filter((key) => set.has(key));
}
module.exports = {
isValidRole,
roleCapabilities,
rolePermissions,
roles
CAPABILITIES,
CAPABILITY_KEYS,
DEFAULT_ROLES,
WILDCARD,
capabilitySetIncludes,
sanitizeCapabilities
};

View File

@@ -0,0 +1,45 @@
/*
* Tests for the capability catalog and default roles used by the RBAC system.
* Roles are stored in the database and editable at runtime; this validates the
* static catalog/defaults and the pure capability-matching helpers.
*/
const accessControl = require('./accessControl');
describe('access control', () => {
test('capability catalog is well-formed', () => {
const { CAPABILITIES, CAPABILITY_KEYS } = accessControl;
expect(CAPABILITIES.length).toBeGreaterThan(0);
for (const cap of CAPABILITIES) {
expect(typeof cap.key).toBe('string');
expect(typeof cap.label).toBe('string');
expect(typeof cap.group).toBe('string');
}
// Keys are unique.
expect(new Set(CAPABILITY_KEYS).size).toBe(CAPABILITY_KEYS.length);
});
test('default roles include the built-ins and PM roles', () => {
const keys = accessControl.DEFAULT_ROLES.map((r) => r.key);
for (const key of ['admin', 'finance', 'operations', 'pm_admin', 'pm_user', 'viewer']) {
expect(keys).toContain(key);
}
const admin = accessControl.DEFAULT_ROLES.find((r) => r.key === 'admin');
expect(admin.capabilities).toEqual(['*']);
const pmUser = accessControl.DEFAULT_ROLES.find((r) => r.key === 'pm_user');
expect(pmUser.capabilities).toContain('pm.complete');
expect(pmUser.capabilities).not.toContain('pm.manage');
});
test('capabilitySetIncludes honors the wildcard', () => {
expect(accessControl.capabilitySetIncludes(['*'], 'anything')).toBe(true);
expect(accessControl.capabilitySetIncludes(['assets.view'], 'assets.view')).toBe(true);
expect(accessControl.capabilitySetIncludes(['assets.view'], 'assets.delete')).toBe(false);
});
test('sanitizeCapabilities drops unknown keys and collapses wildcard', () => {
expect(accessControl.sanitizeCapabilities(['assets.view', 'made.up'])).toEqual(['assets.view']);
expect(accessControl.sanitizeCapabilities(['assets.view', '*'])).toEqual(['*']);
});
});

213
server/services/alerts.js Normal file
View File

@@ -0,0 +1,213 @@
const { all, audit, now, one, run } = require('../db');
const { getConfig, sendMail } = require('./notifications');
const { deliverAlerts } = require('./webhooks');
const servicenow = require('./servicenow');
const { pmAlertCandidates } = require('./pm');
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function daysUntil(dateStr) {
return Math.round((new Date(`${dateStr}T00:00:00`) - new Date(`${todayStr()}T00:00:00`)) / 86400000);
}
function mk(type, referenceType, referenceId, assetId, severity, title, message, dueDate) {
return { type, reference_type: referenceType, reference_id: referenceId, asset_id: assetId, severity, title, message, due_date: dueDate };
}
// Compute the current set of due/overdue/expiring items that warrant an alert.
function candidates(leadDays) {
const today = todayStr();
const horizon = new Date(Date.now() + leadDays * 86400000).toISOString().slice(0, 10);
const list = [];
const tasks = all(
`SELECT t.*, a.asset_id AS asset_code FROM tasks t LEFT JOIN assets a ON a.id = t.asset_id
WHERE t.status != 'complete' AND t.due_date IS NOT NULL`
);
for (const task of tasks) {
const where = task.asset_code ? ` on ${task.asset_code}` : '';
if (task.due_date < today) {
list.push(mk('maintenance_overdue', 'task', task.id, task.asset_id, 'critical', `Overdue: ${task.title}`, `"${task.title}"${where} was due ${task.due_date}.`, task.due_date));
} else if (task.due_date <= horizon) {
list.push(mk('maintenance_due', 'task', task.id, task.asset_id, 'warning', `Upcoming: ${task.title}`, `"${task.title}"${where} is due ${task.due_date}.`, task.due_date));
}
}
const warranties = all(
`SELECT w.*, a.asset_id AS asset_code FROM warranties w LEFT JOIN assets a ON a.id = w.asset_id
WHERE w.expiration_date IS NOT NULL`
);
for (const w of warranties) {
const label = w.provider || 'Warranty';
const where = w.asset_code ? ` on ${w.asset_code}` : '';
if (w.expiration_date < today) {
list.push(mk('warranty_expired', 'warranty', w.id, w.asset_id, 'critical', 'Warranty expired', `${label}${where} expired ${w.expiration_date}.`, w.expiration_date));
} else if (w.expiration_date <= horizon) {
const sev = daysUntil(w.expiration_date) <= 7 ? 'critical' : 'warning';
list.push(mk('warranty_expiring', 'warranty', w.id, w.asset_id, sev, 'Warranty expiring', `${label}${where} expires ${w.expiration_date}.`, w.expiration_date));
}
}
const leases = all(
`SELECT l.*, a.asset_id AS asset_code FROM leases l LEFT JOIN assets a ON a.id = l.asset_id
WHERE l.end_date IS NOT NULL`
);
for (const l of leases) {
if (l.end_date < today || l.end_date > horizon) continue;
const where = l.asset_code ? ` on ${l.asset_code}` : '';
const sev = daysUntil(l.end_date) <= 7 ? 'critical' : 'warning';
list.push(mk('lease_expiring', 'lease', l.id, l.asset_id, sev, 'Lease ending', `Lease with ${l.lessor || 'lessor'}${where} ends ${l.end_date}.`, l.end_date));
}
list.push(...pmAlertCandidates());
return list;
}
// Reconcile computed candidates against stored alerts: create new, refresh existing,
// resolve ones that no longer apply, and respect dismissals.
function scanAlerts(userId) {
const cfg = getConfig();
const ts = now();
const cands = candidates(cfg.leadDays);
const seen = new Set();
const created = [];
for (const c of cands) {
seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`);
const existing = one('SELECT * FROM alerts WHERE reference_type = ? AND reference_id = ? AND type = ?', [c.reference_type, c.reference_id, c.type]);
if (existing) {
if (existing.status === 'dismissed') continue;
run(
`UPDATE alerts SET asset_id = ?, severity = ?, title = ?, message = ?, due_date = ?,
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ?
WHERE id = ?`,
[c.asset_id, c.severity, c.title, c.message, c.due_date, ts, existing.id]
);
} else {
const result = run(
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, severity, title, message, due_date, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
[c.type, c.reference_type, c.reference_id, c.asset_id, c.severity, c.title, c.message, c.due_date, ts, ts]
);
created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid]));
}
}
for (const alert of all("SELECT * FROM alerts WHERE status IN ('open', 'acknowledged')")) {
if (!seen.has(`${alert.reference_type}:${alert.reference_id}:${alert.type}`)) {
run("UPDATE alerts SET status = 'resolved', updated_at = ? WHERE id = ?", [ts, alert.id]);
}
}
if (userId) audit(userId, 'scan', 'alerts', null, null, { created: created.length });
return { created, openCount: one("SELECT COUNT(*) AS c FROM alerts WHERE status = 'open'").c };
}
function listAlerts(query = {}) {
return all(
`SELECT al.*, a.asset_id AS asset_code, a.description AS asset_description, u.name AS acknowledged_by_name
FROM alerts al
LEFT JOIN assets a ON a.id = al.asset_id
LEFT JOIN users u ON u.id = al.acknowledged_by
WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?)
ORDER BY CASE al.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, al.due_date IS NULL, al.due_date`,
[query.status || null, query.status || null, query.type || null, query.type || null]
);
}
function summary() {
const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity");
const open = rows.filter((r) => r.status === 'open');
return {
open: open.reduce((s, r) => s + r.count, 0),
critical: open.filter((r) => r.severity === 'critical').reduce((s, r) => s + r.count, 0),
acknowledged: rows.filter((r) => r.status === 'acknowledged').reduce((s, r) => s + r.count, 0)
};
}
function setStatus(id, status, userId) {
const before = one('SELECT * FROM alerts WHERE id = ?', [id]);
if (!before) return null;
run(
'UPDATE alerts SET status = ?, acknowledged_by = ?, acknowledged_at = ?, updated_at = ? WHERE id = ?',
[
status,
status === 'acknowledged' ? userId : before.acknowledged_by,
status === 'acknowledged' ? now() : before.acknowledged_at,
now(),
id
]
);
audit(userId, status, 'alert', id, before, { status });
return one('SELECT * FROM alerts WHERE id = ?', [id]);
}
function digestHtml(alerts) {
const rows = alerts.map((a) =>
`<tr><td style="padding:6px 10px;color:${a.severity === 'critical' ? '#b3261e' : '#a56a00'};font-weight:700">${a.severity.toUpperCase()}</td>` +
`<td style="padding:6px 10px">${a.title}</td><td style="padding:6px 10px">${a.message || ''}</td>` +
`<td style="padding:6px 10px">${a.due_date || ''}</td></tr>`
).join('');
return `<h2>MixedAssets alerts</h2><p>${alerts.length} item(s) need attention.</p>` +
`<table style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px">` +
`<thead><tr><th align="left" style="padding:6px 10px">Severity</th><th align="left" style="padding:6px 10px">Alert</th>` +
`<th align="left" style="padding:6px 10px">Detail</th><th align="left" style="padding:6px 10px">Due</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function digestText(alerts) {
return `MixedAssets alerts (${alerts.length}):\n` +
alerts.map((a) => `- [${a.severity}] ${a.title}${a.message || ''} ${a.due_date ? `(due ${a.due_date})` : ''}`).join('\n');
}
// Scan, then notify on newly raised, not-yet-notified open alerts: an email digest
// (when SMTP is enabled) and/or a per-alert JSON webhook POST (when enabled). An alert
// is marked notified once any channel has handled it, so it is delivered at most once.
async function runAlertCycle(userId) {
const { created } = scanAlerts(userId);
const cfg = getConfig();
const result = { created: created.length, emailed: false, webhooked: false };
const snCfg = servicenow.getConfig();
const emailReady = cfg.enabled && cfg.host && cfg.recipients.length;
const webhookReady = cfg.webhookEnabled && cfg.webhookUrl;
const ticketReady = snCfg.ticketEnabled && snCfg.autoTicket && snCfg.instanceUrl;
if (!emailReady && !webhookReady && !ticketReady) return result;
const pending = all(
`SELECT al.*, a.asset_id AS asset_code
FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id
WHERE al.status = 'open' AND al.notified_at IS NULL`
);
if (!pending.length) return result;
if (emailReady) {
await sendMail({
subject: `MixedAssets: ${pending.length} new alert(s)`,
html: digestHtml(pending),
text: digestText(pending)
});
result.emailed = true;
result.count = pending.length;
}
if (webhookReady) {
const delivery = await deliverAlerts(pending);
result.webhooked = delivery.attempted;
result.delivered = delivery.delivered;
result.failed = delivery.failed;
}
if (ticketReady) {
const tickets = await servicenow.autoTicketAlerts(pending, userId);
result.ticketed = tickets.created;
}
const ts = now();
for (const alert of pending) run('UPDATE alerts SET notified_at = ? WHERE id = ?', [ts, alert.id]);
return result;
}
module.exports = { listAlerts, runAlertCycle, scanAlerts, setStatus, summary };

View File

@@ -0,0 +1,30 @@
let alerts = require('./alerts')
describe('alerts', () => {
test('creates and lists alerts correctly', () => {
const c1 = {
type: 'test_alert',
reference_type: 'asset',
reference_id: 1,
severity: 'warning',
title: 'Test Alert 1',
message: 'This is a test alert for asset 1'
};
const c2 = {
type: 'test_alert',
reference_type: 'asset',
reference_id: 2,
severity: 'critical',
title: 'Test Alert 2',
message: 'This is a test alert for asset 2'
};
const result = alerts.scan([c1, c2], 1);
expect(result.created.length).toBe(2);
expect(result.openCount).toBeGreaterThanOrEqual(2);
const list = alerts.listAlerts({ status: 'open', type: 'test_alert' });
expect(list.length).toBeGreaterThanOrEqual(2);
expect(list.some(a => a.reference_id === 1 && a.severity === 'warning')).toBe(true);
expect(list.some(a => a.reference_id === 2 && a.severity === 'critical')).toBe(true);
});
});

View File

@@ -0,0 +1,93 @@
const { all, audit, now, one, parseJson, run } = require('../db');
const SOURCES = ['common', 'industry', 'business', 'custom'];
// How many asset categories reference a given class number (categories store it in metadata).
function categoryUsage() {
const counts = {};
for (const row of all("SELECT metadata FROM reference_values WHERE type = 'category'")) {
const num = parseJson(row.metadata, {}).asset_class_number;
if (num) counts[num] = (counts[num] || 0) + 1;
}
return counts;
}
function fromRow(row, usage) {
if (!row) return null;
return {
id: row.id,
class_number: row.class_number,
description: row.description,
gds: row.gds,
ads: row.ads,
source: row.source,
table: row.source, // back-compat alias used by the category picker
category_count: row.class_number ? (usage[row.class_number] || 0) : 0
};
}
function list() {
const usage = categoryUsage();
return all('SELECT * FROM asset_classes ORDER BY description').map((row) => fromRow(row, usage));
}
function getClass(id) {
return fromRow(one('SELECT * FROM asset_classes WHERE id = ?', [id]), categoryUsage());
}
function normalize(body) {
const num = (value) => {
if (value === '' || value === null || value === undefined) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
};
return {
class_number: body.class_number ? String(body.class_number).trim() : null,
description: String(body.description || '').trim(),
gds: num(body.gds),
ads: num(body.ads),
source: SOURCES.includes(body.source) ? body.source : 'custom'
};
}
function createClass(body, userId) {
const input = normalize(body);
if (!input.description) throw Object.assign(new Error('A description is required'), { status: 400 });
const ts = now();
const result = run(
'INSERT INTO asset_classes (class_number, description, gds, ads, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[input.class_number, input.description, input.gds, input.ads, input.source, ts, ts]
);
audit(userId, 'create', 'asset_class', result.lastInsertRowid, null, input);
return getClass(result.lastInsertRowid);
}
function updateClass(id, body, userId) {
const before = one('SELECT * FROM asset_classes WHERE id = ?', [id]);
if (!before) return null;
const input = normalize({ ...before, ...body });
if (!input.description) throw Object.assign(new Error('A description is required'), { status: 400 });
run(
'UPDATE asset_classes SET class_number = ?, description = ?, gds = ?, ads = ?, source = ?, updated_at = ? WHERE id = ?',
[input.class_number, input.description, input.gds, input.ads, input.source, now(), id]
);
audit(userId, 'update', 'asset_class', id, before, input);
return getClass(id);
}
function deleteClass(id, userId) {
const before = one('SELECT * FROM asset_classes WHERE id = ?', [id]);
if (!before) return false;
run('DELETE FROM asset_classes WHERE id = ?', [id]);
audit(userId, 'delete', 'asset_class', id, before, null);
return true;
}
module.exports = {
SOURCES,
createClass,
deleteClass,
getClass,
list,
updateClass
};

View File

@@ -1,4 +1,5 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
const { assetPmRows } = require('./pm');
const assetColumns = [
'asset_id',
@@ -35,12 +36,24 @@ const assetColumns = [
'disposal_expense',
'disposal_type',
'property_type',
'special_zone',
'notes',
'custom_fields'
];
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
// Depreciation books currently enabled in the registry (excludes the maintenance/PM book).
function enabledBookCodes() {
try {
const rows = all("SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' ORDER BY sort_order, id");
if (rows.length) return rows.map((row) => row.code);
} catch {
// books table may not exist on a very old database
}
return defaultBooks;
}
function assetFromRow(row) {
if (!row) return null;
return {
@@ -51,6 +64,36 @@ function assetFromRow(row) {
};
}
function assetPhotos(id) {
return all('SELECT id, name, mime_type, caption, data_base64 FROM asset_photos WHERE asset_id = ? ORDER BY sort_order, id', [id])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, caption: p.caption, data: p.data_base64 }));
}
function addAssetPhoto(assetId, body, userId) {
if (!one('SELECT id FROM assets WHERE id = ?', [assetId])) return null;
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
const result = run(
'INSERT INTO asset_photos (asset_id, name, mime_type, caption, data_base64, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[assetId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.caption || null, body.data, userId, now()]
);
audit(userId, 'create', 'asset_photo', result.lastInsertRowid, null, { asset_id: assetId, name: body.name });
return one('SELECT id, name, mime_type, caption FROM asset_photos WHERE id = ?', [result.lastInsertRowid]);
}
function updateAssetPhoto(assetId, photoId, body, userId) {
const result = run('UPDATE asset_photos SET caption = ? WHERE id = ? AND asset_id = ?', [body.caption || null, photoId, assetId]);
if (!result.changes) return null;
audit(userId, 'update', 'asset_photo', photoId, null, { asset_id: assetId });
return one('SELECT id, name, mime_type, caption FROM asset_photos WHERE id = ?', [photoId]);
}
function deleteAssetPhoto(assetId, photoId, userId) {
const result = run('DELETE FROM asset_photos WHERE id = ? AND asset_id = ?', [photoId, assetId]);
if (!result.changes) return false;
audit(userId, 'delete', 'asset_photo', photoId, null, { asset_id: assetId });
return true;
}
function hydrateAsset(id) {
const asset = assetFromRow(one('SELECT * FROM assets WHERE id = ?', [id]));
if (!asset) return null;
@@ -60,9 +103,13 @@ function hydrateAsset(id) {
manual_depreciation: parseJson(book.manual_depreciation, {})
}));
asset.leases = all('SELECT * FROM leases WHERE asset_id = ? ORDER BY start_date DESC', [id]);
asset.disposals = all('SELECT * FROM disposals WHERE asset_id = ? ORDER BY disposal_date DESC, id DESC', [id]);
asset.adjustments = all('SELECT * FROM asset_adjustments WHERE asset_id = ? ORDER BY adjustment_date DESC, id DESC', [id]);
asset.pm = assetPmRows(id);
asset.tasks = all('SELECT * FROM tasks WHERE asset_id = ? ORDER BY due_date IS NULL, due_date', [id]);
asset.warranties = all('SELECT id, provider, phone, start_date, expiration_date, warranty_limit, actual_usage, coverage_details, document_name, created_at FROM warranties WHERE asset_id = ?', [id]);
asset.files = all('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE asset_id = ?', [id]);
asset.photos = assetPhotos(id);
asset.notes_log = all(
`SELECT n.id, n.body, n.created_at, u.name AS author
FROM asset_notes n
@@ -74,12 +121,75 @@ function hydrateAsset(id) {
return asset;
}
function lookupAssetByCode(code) {
const value = String(code || '').trim();
if (!value) return null;
const row = one(
`SELECT id FROM assets
WHERE barcode_value = ? COLLATE NOCASE
OR asset_id = ? COLLATE NOCASE
OR serial_number = ? COLLATE NOCASE
ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id
LIMIT 1`,
[value, value, value, value]
);
return row ? hydrateAsset(row.id) : null;
}
function nextAssetId() {
const latest = one("SELECT asset_id FROM assets WHERE asset_id LIKE 'MA-%' ORDER BY id DESC LIMIT 1");
const number = latest ? Number(String(latest.asset_id).replace('MA-', '')) + 1 : 1;
return `MA-${String(number).padStart(5, '0')}`;
}
// Replace the first run of '#' in a pattern with the zero-padded number.
// e.g. formatAssetId('DT-######', 42) -> 'DT-000042'
function formatAssetId(pattern, n) {
return String(pattern).replace(/#+/, (hashes) => String(n).padStart(hashes.length, '0'));
}
// If the asset's category has an assigned ID template, generate the next unique tag from it
// and advance the template's counter. Returns null when the category has no template.
function nextAssetIdFromCategory(categoryName) {
if (!categoryName) return null;
let templateId = null;
try {
const category = one("SELECT metadata FROM reference_values WHERE type = 'category' AND name = ?", [categoryName]);
templateId = category ? parseJson(category.metadata, {}).id_template_id : null;
} catch {
return null;
}
if (!templateId) return null;
const template = one('SELECT * FROM asset_id_templates WHERE id = ?', [templateId]);
if (!template || !/#/.test(template.pattern || '')) return null;
let n = Number(template.next_number) || 1;
let candidate = formatAssetId(template.pattern, n);
while (one('SELECT id FROM assets WHERE asset_id = ?', [candidate])) {
n += 1;
candidate = formatAssetId(template.pattern, n);
}
run('UPDATE asset_id_templates SET next_number = ?, updated_at = ? WHERE id = ?', [n + 1, now(), templateId]);
return candidate;
}
// Resolve the asset tag for a new asset: an explicit value wins; otherwise use the
// category's ID template, falling back to the default MA-##### sequence.
function resolveNewAssetId(input) {
if (input.asset_id) return input.asset_id;
return nextAssetIdFromCategory(input.category) || nextAssetId();
}
// The Asset Class Number is a property of the category — every asset in the category shares it.
function assetClassNumberForCategory(categoryName) {
if (!categoryName) return null;
try {
const category = one("SELECT metadata FROM reference_values WHERE type = 'category' AND name = ?", [categoryName]);
return category ? (parseJson(category.metadata, {}).asset_class_number || null) : null;
} catch {
return null;
}
}
function normalizeAssetInput(body) {
const input = {};
for (const column of assetColumns) {
@@ -88,7 +198,7 @@ function normalizeAssetInput(body) {
input.description = input.description || body.name || 'Untitled asset';
input.category = input.category || 'Uncategorized';
input.status = input.status || 'in_service';
input.asset_id = input.asset_id || nextAssetId();
input.asset_id = input.asset_id || null; // resolved by the caller (template or default sequence)
input.entity_id = input.entity_id || one('SELECT id FROM entities ORDER BY id LIMIT 1')?.id;
input.acquisition_cost = Number(input.acquisition_cost || 0);
input.useful_life_months = Number(input.useful_life_months || 60);
@@ -103,7 +213,7 @@ function normalizeAssetInput(body) {
}
function createBooks(assetId, asset, books = []) {
const selectedBooks = books.length ? books : defaultBooks.map((book) => ({ book_type: book }));
const selectedBooks = books.length ? books : enabledBookCodes().map((book) => ({ book_type: book }));
for (const book of selectedBooks) {
run(
`INSERT OR REPLACE INTO asset_books (
@@ -156,12 +266,14 @@ function createAsset(body, userId) {
const created = now();
const input = normalizeAssetInput(body);
const result = tx(() => {
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
const insert = run(
`INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at)
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
);
createBooks(insert.lastInsertRowid, input, body.books || []);
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]);
return insert;
});
const asset = hydrateAsset(result.lastInsertRowid);
@@ -174,6 +286,10 @@ function updateAsset(id, body, userId) {
if (!before) return null;
const input = normalizeAssetInput({ ...before, ...body });
// When depreciation-critical fields change, the caller chooses when the change applies.
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
// 'going_forward' keeps depreciation already recorded (the stored prior depreciation).
if (body.depreciation_apply === 'placed_in_service') input.prior_depreciation = 0;
const updated = now();
tx(() => {
run(
@@ -181,6 +297,7 @@ function updateAsset(id, body, userId) {
[...assetColumns.map((column) => input[column] ?? null), updated, id]
);
if (Array.isArray(body.books)) createBooks(id, input, body.books);
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]);
});
const asset = hydrateAsset(id);
@@ -231,11 +348,13 @@ function upsertImportedAssets(rows, userId) {
tx(() => {
for (const row of rows) {
const input = normalizeAssetInput(row);
if (!input.asset_id) input.asset_id = resolveNewAssetId(input);
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
const classNumber = assetClassNumberForCategory(input.category);
if (existing) {
run(
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), now(), existing.id]
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, asset_class_number = ?, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), classNumber, now(), existing.id]
);
} else {
const result = run(
@@ -244,6 +363,7 @@ function upsertImportedAssets(rows, userId) {
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
);
createBooks(result.lastInsertRowid, input, []);
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]);
}
imported += 1;
}
@@ -252,6 +372,7 @@ function upsertImportedAssets(rows, userId) {
}
module.exports = {
addAssetPhoto,
assetColumns,
assetExportRows,
assetFromRow,
@@ -259,8 +380,12 @@ module.exports = {
createBooks,
defaultBooks,
deleteAsset,
deleteAssetPhoto,
formatAssetId,
hydrateAsset,
updateAssetPhoto,
listAssets,
lookupAssetByCode,
massUpdateAssets,
normalizeAssetInput,
upsertImportedAssets,

View File

@@ -0,0 +1,10 @@
let assets = require('./assets')
describe('assets', () => {
test('looks up asset by code correctly', () => {
const asset = assets.lookupAssetByCode('TESTCODE123');
expect(asset).toBeDefined();
expect(asset.asset_id).toBe(1);
expect(asset.code).toBe('TESTCODE123');
});
});

View File

@@ -0,0 +1,41 @@
let assignments = require('./assignments')
describe('assignments', () => {
test('assigns asset to employee correctly', () => {
const body = {
asset_id: 1,
employee_id: 1,
department: 'IT',
location: 'Headquarters',
notes: 'Assigned for testing'
};
const userId = 1;
const assignment = assignments.assignAsset(body, userId);
expect(assignment).toBeDefined();
expect(assignment.asset_id).toBe(body.asset_id);
expect(assignment.employee_id).toBe(body.employee_id);
expect(assignment.department).toBe(body.department);
expect(assignment.location).toBe(body.location);
expect(assignment.status).toBe('assigned');
});
test('releases asset assignment correctly', () => {
const body = {
release_reason: 'No longer needed',
notes: 'Released after testing'
};
const userId = 1;
const assignment = assignments.assignAsset({
asset_id: 1,
employee_id: 1,
department: 'IT',
location: 'Headquarters'
}, userId);
const released = assignments.releaseAssignment(assignment.id, body, userId);
expect(released).toBeDefined();
expect(released.id).toBe(assignment.id);
expect(released.status).toBe('released');
expect(released.release_reason).toBe(body.release_reason);
expect(released.notes).toBe(body.notes);
});
});

221
server/services/books.js Normal file
View File

@@ -0,0 +1,221 @@
const { all, audit, now, one, run } = require('../db');
const { money } = require('../depreciation');
const { ruleSetForBook } = require('./ruleSets');
const { computeYear } = require('./reportEngine');
const { assetFromRow } = require('./assets');
const { pmBookLedger } = require('./pm');
const DEFAULT_ACCOUNTS = {
asset: '1500',
accumulated: '1590',
expense: '6400'
};
function bookFromRow(row) {
return {
...row,
enabled: Boolean(row.enabled),
is_primary: Boolean(row.is_primary)
};
}
function listBooks() {
const books = all(
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version,
(SELECT COUNT(*) FROM asset_books ab WHERE ab.book_type = b.code AND ab.active = 1) AS asset_count
FROM books b
LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id
ORDER BY b.sort_order, b.id`
).map(bookFromRow);
const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name')
.map((row) => ({ ...row, active: Boolean(row.active) }));
return { books, ruleSets };
}
function getBook(code) {
return bookFromRow(one('SELECT * FROM books WHERE code = ? OR id = ?', [code, code]) || null) || null;
}
function createBook(body, userId) {
const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 20);
if (!code) throw Object.assign(new Error('Book code is required'), { status: 400 });
if (one('SELECT id FROM books WHERE code = ?', [code])) {
throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 });
}
const ts = now();
const next = one('SELECT MAX(sort_order) AS max FROM books');
const result = run(
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
[
code,
body.name || code,
body.description || null,
['financial', 'tax', 'internal'].includes(body.book_type) ? body.book_type : 'internal',
body.enabled === false ? 0 : 1,
body.tax_rule_set_id || null,
body.default_method || 'straight_line',
body.default_convention || 'half_year',
Number(body.fiscal_year_end_month) || 12,
(next?.max || 0) + 1,
ts, ts
]
);
audit(userId, 'create', 'book', result.lastInsertRowid, null, { code, name: body.name });
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
}
function deleteBook(id, userId) {
const before = one('SELECT * FROM books WHERE id = ?', [id]);
if (!before) return false;
if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 });
if (before.book_type === 'maintenance') throw Object.assign(new Error('The PM book cannot be deleted'), { status: 400 });
const used = one('SELECT COUNT(*) AS count FROM asset_books WHERE book_type = ?', [before.code]).count;
if (used) throw Object.assign(new Error(`This book is used by ${used} asset record(s); disable it instead`), { status: 400 });
run('DELETE FROM books WHERE id = ?', [id]);
audit(userId, 'delete', 'book', id, before, null);
return true;
}
function updateBook(id, body, userId) {
const before = one('SELECT * FROM books WHERE id = ?', [id]);
if (!before) return null;
run(
`UPDATE books SET
name = ?, description = ?, book_type = ?, enabled = ?, tax_rule_set_id = ?,
default_method = ?, default_convention = ?, fiscal_year_end_month = ?, updated_at = ?
WHERE id = ?`,
[
body.name ?? before.name,
body.description ?? before.description,
body.book_type ?? before.book_type,
body.enabled === undefined ? before.enabled : (body.enabled ? 1 : 0),
body.tax_rule_set_id === undefined ? before.tax_rule_set_id : (body.tax_rule_set_id || null),
body.default_method ?? before.default_method,
body.default_convention ?? before.default_convention,
body.fiscal_year_end_month ?? before.fiscal_year_end_month,
now(),
id
]
);
audit(userId, 'update', 'book', id, before, body);
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [id]));
}
// General-ledger view of a book: per-asset basis, accumulated depreciation and
// current-year expense rolled up to GL accounts, plus an asset-level detail.
function bookLedger(code, year) {
const book = getBook(code);
if (!book) return null;
if (book.book_type === 'maintenance') {
return { ...pmBookLedger(year), book };
}
const rules = ruleSetForBook(code);
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
ORDER BY a.asset_id`,
[code]
);
const accountMap = new Map();
const addLine = (account, type, debit, credit) => {
const key = `${type}::${account}`;
const entry = accountMap.get(key) || { account, type, debit: 0, credit: 0 };
entry.debit += debit;
entry.credit += credit;
accountMap.set(key, entry);
};
let totalCost = 0;
let totalAccum = 0;
let totalDepr = 0;
const assets = rows.map((row) => {
const asset = assetFromRow(row);
const bk = {
book_type: code,
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
};
const c = computeYear(asset, bk, rules, year);
const assetAccount = asset.gl_asset_account || DEFAULT_ACCOUNTS.asset;
const accumAccount = asset.gl_accumulated_account || DEFAULT_ACCOUNTS.accumulated;
const expenseAccount = asset.gl_expense_account || DEFAULT_ACCOUNTS.expense;
addLine(assetAccount, 'Asset cost', c.cost, 0);
addLine(accumAccount, 'Accumulated depreciation', 0, c.accumulated_end);
if (c.depreciation) addLine(expenseAccount, 'Depreciation expense', c.depreciation, 0);
totalCost += c.cost;
totalAccum += c.accumulated_end;
totalDepr += c.depreciation;
return {
asset_id: asset.asset_id,
description: asset.description,
asset_account: assetAccount,
accumulated_account: accumAccount,
expense_account: expenseAccount,
cost: c.cost,
depreciation: c.depreciation,
accumulated: c.accumulated_end,
nbv: c.nbv_end
};
});
const accounts = [...accountMap.values()]
.map((entry) => ({ account: entry.account, type: entry.type, debit: money(entry.debit), credit: money(entry.credit) }))
.sort((a, b) => a.account.localeCompare(b.account) || a.type.localeCompare(b.type));
return {
book,
year,
rule_set: { id: book.tax_rule_set_id, name: book.tax_rule_set_name, version: book.tax_rule_set_version },
summary: {
assets: assets.length,
cost: money(totalCost),
accumulated: money(totalAccum),
depreciation: money(totalDepr),
nbv: money(totalCost - totalAccum)
},
accounts,
accountTotals: { debit: money(accounts.reduce((s, a) => s + a.debit, 0)), credit: money(accounts.reduce((s, a) => s + a.credit, 0)) },
assets
};
}
// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
function ledgerReport(code, year) {
const ledger = bookLedger(code, year);
if (!ledger) return null;
return {
title: `${ledger.book.name} general ledger — ${year}`,
generatedAt: now(),
options: { book: code, year },
columns: [
{ key: 'account', label: 'GL account', format: 'text' },
{ key: 'type', label: 'Account type', format: 'text' },
{ key: 'debit', label: 'Debit', format: 'currency' },
{ key: 'credit', label: 'Credit', format: 'currency' }
],
rows: ledger.accounts,
totals: ledger.accountTotals,
meta: {
note: ledger.kind === 'maintenance'
? `${ledger.summary.assets} asset(s) · ${ledger.summary.completions} service(s) · total PM spend ${ledger.summary.cost}`
: `Cost ${ledger.summary.cost} · Accumulated ${ledger.summary.accumulated} · NBV ${ledger.summary.nbv} · ${year} depreciation ${ledger.summary.depreciation}`
}
};
}
module.exports = { bookLedger, createBook, deleteBook, getBook, ledgerReport, listBooks, updateBook };

175
server/services/contacts.js Normal file
View File

@@ -0,0 +1,175 @@
const { all, audit, json, now, one, parseJson, run } = require('../db');
const TYPES = ['employee', 'vendor', 'service_provider', 'contractor', 'work_location', 'other'];
const CREDIT_TERMS = ['na', 'net_30', 'net_60', 'net_90', 'net_180'];
const COLUMNS = [
'type', 'first_name', 'last_name', 'organization', 'email', 'phone',
'address_line1', 'address_line2', 'city', 'state_region', 'postal_code', 'country',
'notes', 'status', 'employee_id', 'tax_id', 'department', 'work_location',
'manager_first_name', 'manager_last_name', 'manager_email', 'start_date',
'rating', 'credit_term', 'source', 'workday_worker_id'
];
function contactNotes(contactId) {
return all(
`SELECT n.id, n.body, n.created_at, u.name AS author
FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by
WHERE n.contact_id = ? ORDER BY n.id DESC`,
[contactId]
);
}
function displayName(row) {
const person = [row.first_name, row.last_name].filter(Boolean).join(' ').trim();
return row.organization || person || row.email || `Contact ${row.id}`;
}
function fromRow(row, withNotes = false) {
if (!row) return null;
const contact = { ...row, display_name: displayName(row) };
if (withNotes) contact.contact_notes = contactNotes(row.id);
return contact;
}
function normalize(body) {
const input = {};
for (const column of COLUMNS) {
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
}
input.type = TYPES.includes(input.type) ? input.type : 'other';
input.status = input.status || 'active';
input.source = input.source || 'manual';
if (input.credit_term && !CREDIT_TERMS.includes(input.credit_term)) input.credit_term = 'na';
if (input.rating != null && input.rating !== '') input.rating = Math.max(0, Math.min(5, Number(input.rating)));
else input.rating = null;
if (input.workday_worker_id === '') input.workday_worker_id = null;
return input;
}
function listContacts(query = {}) {
return all(
`SELECT * FROM contacts
WHERE (? IS NULL OR type = ?)
AND (? IS NULL OR status = ?)
AND (? IS NULL OR (
first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR
organization LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR
employee_id LIKE '%' || ? || '%'
))
ORDER BY type, organization, last_name, first_name`,
[
query.type || null, query.type || null,
query.status || null, query.status || null,
query.search || null, query.search || null, query.search || null,
query.search || null, query.search || null, query.search || null
]
).map((row) => fromRow(row));
}
function getContact(id) {
return fromRow(one('SELECT * FROM contacts WHERE id = ?', [id]), true);
}
function createContact(body, userId) {
const input = normalize(body);
const ts = now();
const cols = COLUMNS.filter((c) => input[c] !== undefined);
const result = run(
`INSERT INTO contacts (${cols.join(', ')}, created_by, created_at, updated_at)
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => input[c]), userId, ts, ts]
);
audit(userId, 'create', 'contact', result.lastInsertRowid, null, { type: input.type, name: displayName(input) });
return getContact(result.lastInsertRowid);
}
function updateContact(id, body, userId) {
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
if (!before) return null;
const input = normalize({ ...before, ...body });
run(
`UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
);
audit(userId, 'update', 'contact', id, before, body);
return getContact(id);
}
function deleteContact(id, userId) {
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
if (!before) return false;
run('DELETE FROM contacts WHERE id = ?', [id]);
audit(userId, 'delete', 'contact', id, before, null);
return true;
}
function addNote(contactId, bodyText, userId) {
if (!one('SELECT id FROM contacts WHERE id = ?', [contactId])) return null;
const result = run('INSERT INTO contact_notes (contact_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', [contactId, bodyText, userId, now()]);
audit(userId, 'create', 'contact_note', result.lastInsertRowid, null, { contact_id: contactId });
return one('SELECT n.id, n.body, n.created_at, u.name AS author FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?', [result.lastInsertRowid]);
}
function deleteNote(contactId, noteId, userId) {
const result = run('DELETE FROM contact_notes WHERE id = ? AND contact_id = ?', [noteId, contactId]);
if (!result.changes) return false;
audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId });
return true;
}
// Upsert an employee contact from a normalized Workday worker record.
function upsertWorkdayContact(worker) {
const ts = now();
const existing = worker.workday_worker_id
? one('SELECT id FROM contacts WHERE workday_worker_id = ?', [worker.workday_worker_id])
: (worker.email ? one('SELECT id FROM contacts WHERE type = ? AND lower(email) = lower(?)', ['employee', worker.email]) : null);
const fields = {
type: 'employee',
first_name: worker.first_name || null,
last_name: worker.last_name || null,
organization: null,
email: worker.email || null,
department: worker.department || null,
work_location: worker.location || null,
employee_id: worker.employee_number || null,
manager_first_name: worker.manager_first_name || null,
manager_last_name: worker.manager_last_name || null,
manager_email: worker.manager_email || null,
start_date: worker.start_date || null,
status: worker.status || 'active',
source: 'workday',
workday_worker_id: worker.workday_worker_id || null,
source_payload: json(worker.source_payload || worker)
};
if (existing) {
const cols = Object.keys(fields);
run(
`UPDATE contacts SET ${cols.map((c) => `${c} = COALESCE(?, ${c})`).join(', ')}, last_synced_at = ?, updated_at = ? WHERE id = ?`,
[...cols.map((c) => fields[c]), ts, ts, existing.id]
);
return existing.id;
}
const cols = Object.keys(fields);
const result = run(
`INSERT INTO contacts (${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => fields[c]), ts, ts, ts]
);
return result.lastInsertRowid;
}
module.exports = {
CREDIT_TERMS,
TYPES,
addNote,
createContact,
deleteContact,
deleteNote,
getContact,
listContacts,
parsePayload: (value) => parseJson(value, null),
updateContact,
upsertWorkdayContact
};

View File

@@ -0,0 +1,187 @@
const { all, audit, now, one, run, tx } = require('../db');
const { annualSchedule, money } = require('../depreciation');
const { ruleSetForBook } = require('./ruleSets');
const { hydrateAsset } = require('./assets');
const DISPOSAL_METHODS = ['sale', 'exchange', 'involuntary_conversion', 'like_kind', 'abandonment'];
function yearOf(value) {
if (!value) return new Date().getFullYear();
return new Date(`${value}T00:00:00`).getFullYear();
}
function netAdjustments(assetId, bookType) {
// Impairments and write-downs reduce carrying value; write-ups/revaluations restore it.
const rows = all('SELECT type, amount FROM asset_adjustments WHERE asset_id = ? AND book_type = ?', [assetId, bookType]);
return rows.reduce((sum, row) => {
const sign = row.type === 'write_up' || row.type === 'revaluation' ? -1 : 1;
return sum + sign * Number(row.amount || 0);
}, 0);
}
function bookFor(asset, bookType) {
return asset.books.find((book) => book.book_type === bookType) || asset.books[0] || { book_type: bookType };
}
function computeDisposal(asset, input = {}) {
const bookType = input.book_type || 'GAAP';
const book = bookFor(asset, bookType);
const rules = ruleSetForBook(bookType);
const disposalDate = input.disposal_date || new Date().toISOString().slice(0, 10);
const year = yearOf(disposalDate);
const totalCost = Number(book.cost ?? asset.acquisition_cost ?? 0);
const partial = Boolean(input.partial);
const disposedCost = partial ? Math.min(totalCost, Number(input.disposed_cost || 0)) : totalCost;
const proportion = totalCost > 0 ? disposedCost / totalCost : 1;
const row = annualSchedule(asset, book, rules, { startYear: year, endYear: year })[0] || {};
const accumulatedFull = Number(row.accumulated || 0);
const nbvFull = row.netBookValue != null ? Number(row.netBookValue) : Math.max(0, totalCost - accumulatedFull);
const accumulated = money(accumulatedFull * proportion);
const impairment = money(netAdjustments(asset.id, bookType) * proportion);
const netBookValue = money(Math.max(0, nbvFull * proportion - impairment));
const price = Number(input.disposal_price || 0);
const expense = Number(input.disposal_expense || 0);
const realized = money(price - expense);
const gainLoss = money(realized - netBookValue);
return {
book_type: bookType,
disposal_date: disposalDate,
method: DISPOSAL_METHODS.includes(input.method) ? input.method : 'sale',
property_type: input.property_type || asset.property_type || null,
partial: partial ? 1 : 0,
disposed_cost: money(disposedCost),
total_cost: money(totalCost),
disposal_price: money(price),
disposal_expense: money(expense),
accumulated_depreciation: accumulated,
impairment,
net_book_value: netBookValue,
realized,
gain_loss: gainLoss
};
}
function previewDisposal(assetId, input) {
const asset = hydrateAsset(assetId);
if (!asset) return null;
return computeDisposal(asset, input);
}
function recordDisposal(assetId, input, userId) {
const asset = hydrateAsset(assetId);
if (!asset) return null;
const result = computeDisposal(asset, input);
const timestamp = now();
return tx(() => {
const inserted = run(
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
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
]
);
if (result.partial) {
const totalCost = result.total_cost;
const remainingProportion = totalCost > 0 ? Math.max(0, (totalCost - result.disposed_cost) / totalCost) : 1;
run('UPDATE assets SET acquisition_cost = acquisition_cost - ?, updated_at = ? WHERE id = ?', [
result.disposed_cost,
timestamp,
assetId
]);
run('UPDATE asset_books SET cost = cost * ? WHERE asset_id = ? AND cost IS NOT NULL', [remainingProportion, assetId]);
} else {
run(
`UPDATE assets SET
status = 'disposed', disposal_date = ?, disposal_price = ?, disposal_expense = ?,
disposal_type = ?, property_type = ?, updated_at = ?
WHERE id = ?`,
[result.disposal_date, result.disposal_price, result.disposal_expense, result.method, result.property_type, timestamp, assetId]
);
}
const disposal = one('SELECT * FROM disposals WHERE id = ?', [inserted.lastInsertRowid]);
audit(userId, 'dispose', 'asset', assetId, null, disposal);
return { disposal, asset: hydrateAsset(assetId) };
});
}
function reverseDisposal(assetId, disposalId, userId) {
const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]);
if (!disposal) return null;
const timestamp = now();
return tx(() => {
run('DELETE FROM disposals WHERE id = ?', [disposalId]);
if (disposal.partial) {
run('UPDATE assets SET acquisition_cost = acquisition_cost + ?, updated_at = ? WHERE id = ?', [
disposal.disposed_cost,
timestamp,
assetId
]);
} else if (!one('SELECT id FROM disposals WHERE asset_id = ? AND partial = 0', [assetId])) {
run(
`UPDATE assets SET
status = 'in_service', disposal_date = NULL, disposal_price = NULL,
disposal_expense = NULL, disposal_type = NULL, updated_at = ?
WHERE id = ?`,
[timestamp, assetId]
);
}
audit(userId, 'reverse_disposal', 'asset', assetId, disposal, null);
return hydrateAsset(assetId);
});
}
function recordAdjustment(assetId, input, userId) {
const asset = one('SELECT id FROM assets WHERE id = ?', [assetId]);
if (!asset) return null;
const timestamp = now();
const result = run(
`INSERT INTO asset_adjustments (asset_id, adjustment_date, type, amount, book_type, reason, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId,
input.adjustment_date || timestamp.slice(0, 10),
input.type || 'impairment',
Number(input.amount || 0),
input.book_type || 'GAAP',
input.reason || null,
userId,
timestamp
]
);
const adjustment = one('SELECT * FROM asset_adjustments WHERE id = ?', [result.lastInsertRowid]);
audit(userId, 'adjust', 'asset', assetId, null, adjustment);
return adjustment;
}
function deleteAdjustment(assetId, adjustmentId, userId) {
const result = run('DELETE FROM asset_adjustments WHERE id = ? AND asset_id = ?', [adjustmentId, assetId]);
if (!result.changes) return false;
audit(userId, 'delete', 'asset_adjustment', adjustmentId, null, { asset_id: assetId });
return true;
}
module.exports = {
DISPOSAL_METHODS,
computeDisposal,
deleteAdjustment,
netAdjustments,
previewDisposal,
recordAdjustment,
recordDisposal,
reverseDisposal
};

146
server/services/leases.js Normal file
View File

@@ -0,0 +1,146 @@
const { all, audit, now, one, run } = require('../db');
const { money } = require('../depreciation');
const FREQUENCIES = { monthly: 12, quarterly: 4, semiannual: 2, annual: 1 };
function periodsPerYear(frequency) {
return FREQUENCIES[frequency] || 12;
}
function addMonths(date, count) {
const next = new Date(date.getTime());
next.setMonth(next.getMonth() + count);
return next;
}
function listLeases(query = {}) {
return all(
`SELECT l.*, a.asset_id AS asset_code, a.description AS asset_description
FROM leases l
LEFT JOIN assets a ON a.id = l.asset_id
WHERE (? IS NULL OR l.asset_id = ?)
ORDER BY l.start_date DESC, l.id DESC`,
[query.asset_id || null, query.asset_id || null]
);
}
function getLease(id) {
return one('SELECT * FROM leases WHERE id = ?', [id]);
}
function createLease(input, userId) {
const timestamp = now();
const result = run(
`INSERT INTO leases (asset_id, lessor, contract_value, start_date, end_date, discount_rate, payment_frequency, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.asset_id || null,
input.lessor || null,
Number(input.contract_value || 0),
input.start_date || null,
input.end_date || null,
Number(input.discount_rate || 0),
input.payment_frequency || 'monthly',
input.notes || null,
timestamp,
timestamp
]
);
audit(userId, 'create', 'lease', result.lastInsertRowid, null, input);
return getLease(result.lastInsertRowid);
}
function updateLease(id, input, userId) {
const before = getLease(id);
if (!before) return null;
run(
`UPDATE leases SET
lessor = ?, contract_value = ?, start_date = ?, end_date = ?, discount_rate = ?,
payment_frequency = ?, notes = ?, updated_at = ?
WHERE id = ?`,
[
input.lessor ?? before.lessor,
Number(input.contract_value ?? before.contract_value),
input.start_date ?? before.start_date,
input.end_date ?? before.end_date,
Number(input.discount_rate ?? before.discount_rate),
input.payment_frequency ?? before.payment_frequency,
input.notes ?? before.notes,
now(),
id
]
);
audit(userId, 'update', 'lease', id, before, input);
return getLease(id);
}
function deleteLease(id, userId) {
const before = getLease(id);
if (!before) return false;
run('DELETE FROM leases WHERE id = ?', [id]);
audit(userId, 'delete', 'lease', id, before, null);
return true;
}
// Straight-line ROU amortization with an ASC 842-style liability schedule.
function leaseSchedule(lease) {
if (!lease) return null;
const ppy = periodsPerYear(lease.payment_frequency);
const monthsPerPeriod = 12 / ppy;
const start = lease.start_date ? new Date(`${lease.start_date}T00:00:00`) : new Date();
const end = lease.end_date ? new Date(`${lease.end_date}T00:00:00`) : addMonths(start, 12);
const totalMonths = Math.max(monthsPerPeriod, (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()));
const periods = Math.max(1, Math.round(totalMonths / monthsPerPeriod));
const contractValue = Number(lease.contract_value || 0);
const payment = contractValue / periods;
const rate = Number(lease.discount_rate || 0) / 100 / ppy;
const presentValue = rate > 0 ? payment * (1 - (1 + rate) ** -periods) / rate : contractValue;
const rouAmortization = presentValue / periods;
let liability = presentValue;
let rouBalance = presentValue;
let totalInterest = 0;
const rows = [];
for (let period = 1; period <= periods; period += 1) {
const interest = liability * rate;
const principal = payment - interest;
liability = Math.max(0, liability - principal);
rouBalance = Math.max(0, rouBalance - rouAmortization);
totalInterest += interest;
rows.push({
period,
date: addMonths(start, period * monthsPerPeriod).toISOString().slice(0, 10),
payment: money(payment),
interest: money(interest),
principal: money(principal),
liability_balance: money(liability),
rou_amortization: money(rouAmortization),
rou_balance: money(rouBalance)
});
}
return {
summary: {
periods,
frequency: lease.payment_frequency,
periodic_payment: money(payment),
present_value: money(presentValue),
total_payments: money(payment * periods),
total_interest: money(totalInterest)
},
rows
};
}
module.exports = {
FREQUENCIES,
createLease,
deleteLease,
getLease,
leaseSchedule,
listLeases,
updateLease
};

View File

@@ -0,0 +1,118 @@
const nodemailer = require('nodemailer');
const { all, audit, now, run } = require('../db');
function readSettings() {
const rows = all('SELECT key, value FROM app_settings');
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
// Raw config (includes password) — for sending mail only.
function getConfig() {
const s = readSettings();
return {
host: s.smtp_host || '',
port: Number(s.smtp_port || 587),
secure: s.smtp_secure === 'true',
user: s.smtp_user || '',
password: s.smtp_password || '',
from: s.smtp_from || s.smtp_user || '',
enabled: s.alerts_enabled === 'true',
leadDays: Number(s.alerts_lead_days || 30),
recipients: String(s.alerts_recipients || '').split(',').map((x) => x.trim()).filter(Boolean),
webhookEnabled: s.webhook_enabled === 'true',
webhookUrl: s.webhook_url || '',
webhookSecret: s.webhook_secret || ''
};
}
// Safe config for the API/UI — password is never returned, only whether it is set.
function publicConfig() {
const c = getConfig();
return {
smtp_host: c.host,
smtp_port: c.port,
smtp_secure: c.secure,
smtp_user: c.user,
smtp_from: c.from,
smtp_password_set: Boolean(c.password),
alerts_enabled: c.enabled,
alerts_lead_days: c.leadDays,
alerts_recipients: c.recipients.join(', '),
configured: Boolean(c.host),
webhook_enabled: c.webhookEnabled,
webhook_url: c.webhookUrl,
webhook_secret_set: Boolean(c.webhookSecret),
webhook_configured: Boolean(c.webhookUrl)
};
}
function setValue(key, value) {
run(
'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
[key, String(value), now()]
);
}
function saveConfig(body, userId) {
if (body.smtp_host !== undefined) setValue('smtp_host', body.smtp_host || '');
if (body.smtp_port !== undefined) setValue('smtp_port', Number(body.smtp_port) || 587);
if (body.smtp_secure !== undefined) setValue('smtp_secure', body.smtp_secure ? 'true' : 'false');
if (body.smtp_user !== undefined) setValue('smtp_user', body.smtp_user || '');
if (body.smtp_from !== undefined) setValue('smtp_from', body.smtp_from || '');
if (body.smtp_password) setValue('smtp_password', body.smtp_password); // only overwrite when a new value is supplied
if (body.alerts_enabled !== undefined) setValue('alerts_enabled', body.alerts_enabled ? 'true' : 'false');
if (body.alerts_lead_days !== undefined) setValue('alerts_lead_days', Number(body.alerts_lead_days) || 30);
if (body.alerts_recipients !== undefined) setValue('alerts_recipients', body.alerts_recipients || '');
if (body.webhook_enabled !== undefined) setValue('webhook_enabled', body.webhook_enabled ? 'true' : 'false');
if (body.webhook_url !== undefined) setValue('webhook_url', body.webhook_url || '');
if (body.webhook_secret_clear) setValue('webhook_secret', '');
else if (body.webhook_secret) setValue('webhook_secret', body.webhook_secret); // only overwrite when a new value is supplied
audit(userId, 'update', 'notification_settings', 'smtp', null, {
...body,
smtp_password: body.smtp_password ? '[set]' : undefined,
webhook_secret: body.webhook_secret ? '[set]' : undefined
});
return publicConfig();
}
function getTransport() {
const c = getConfig();
if (!c.host) {
throw Object.assign(new Error('SMTP host is not configured'), { status: 400 });
}
return nodemailer.createTransport({
host: c.host,
port: c.port,
secure: c.secure,
auth: c.user ? { user: c.user, pass: c.password } : undefined
});
}
async function sendMail({ to, subject, html, text }) {
const c = getConfig();
const recipients = to ? (Array.isArray(to) ? to : [to]) : c.recipients;
if (!recipients.length) {
throw Object.assign(new Error('No recipients are configured'), { status: 400 });
}
const transport = getTransport();
return transport.sendMail({
from: c.from || c.user,
to: recipients.join(', '),
subject,
html,
text
});
}
async function sendTestEmail(to, userId) {
const info = await sendMail({
to: to ? [to] : null,
subject: 'MixedAssets test email',
html: '<p>Your MixedAssets SMTP settings are working. This is a test message.</p>',
text: 'Your MixedAssets SMTP settings are working. This is a test message.'
});
audit(userId, 'send', 'test_email', null, null, { to });
return { messageId: info.messageId, accepted: info.accepted };
}
module.exports = { getConfig, publicConfig, saveConfig, sendMail, sendTestEmail };

312
server/services/parts.js Normal file
View File

@@ -0,0 +1,312 @@
const { all, audit, now, one, run, tx } = require('../db');
const STATUSES = ['active', 'inactive', 'discontinued'];
// Stock movements. receive/issue/adjust change on-hand; reserve/unreserve change the
// reserved (committed) quantity. `adjust` sets the on-hand count to an absolute value.
const TRANSACTION_TYPES = ['receive', 'issue', 'adjust', 'reserve', 'unreserve'];
const COLUMNS = [
'part_number', 'name', 'description', 'category', 'manufacturer', 'manufacturer_part_number',
'barcode', 'unit_of_measure', 'unit_cost', 'reorder_point', 'reorder_quantity', 'measurements',
'supplier_contact_id', 'status', 'notes'
];
function round2(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}
function num(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function contactName(row, prefix) {
if (!row || !row[`${prefix}_id`]) return null;
const org = row[`${prefix}_org`];
const person = [row[`${prefix}_first`], row[`${prefix}_last`]].filter(Boolean).join(' ').trim();
return org || person || null;
}
function stockRows(partId) {
return all(
`SELECT s.*, c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last
FROM part_stock s LEFT JOIN contacts c ON c.id = s.location_contact_id
WHERE s.part_id = ? ORDER BY c.organization, s.aisle, s.bin, s.id`,
[partId]
).map((s) => ({
id: s.id,
part_id: s.part_id,
location_contact_id: s.location_contact_id,
location_name: s.location_org || [s.location_first, s.location_last].filter(Boolean).join(' ').trim() || 'Unassigned',
aisle: s.aisle,
bin: s.bin,
quantity: round2(s.quantity),
reserved: round2(s.reserved),
available: round2(s.quantity - s.reserved)
}));
}
function partPhotos(partId) {
return all('SELECT id, name, mime_type, data_base64 FROM part_photos WHERE part_id = ? ORDER BY id', [partId])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 }));
}
function partTransactions(partId, limit = 100) {
return all(
`SELECT t.id, t.type, t.quantity, t.unit_cost, t.reference, t.notes, t.created_at, t.asset_id,
u.name AS created_by_name, a.asset_id AS asset_code,
c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last,
s.aisle, s.bin
FROM part_transactions t
LEFT JOIN users u ON u.id = t.created_by
LEFT JOIN assets a ON a.id = t.asset_id
LEFT JOIN part_stock s ON s.id = t.stock_id
LEFT JOIN contacts c ON c.id = s.location_contact_id
WHERE t.part_id = ? ORDER BY t.id DESC LIMIT ?`,
[partId, limit]
).map((t) => ({
id: t.id,
type: t.type,
quantity: round2(t.quantity),
unit_cost: t.unit_cost == null ? null : round2(t.unit_cost),
reference: t.reference,
notes: t.notes,
created_at: t.created_at,
created_by_name: t.created_by_name,
asset_code: t.asset_code,
location_name: t.location_org || [t.location_first, t.location_last].filter(Boolean).join(' ').trim() || null,
aisle: t.aisle,
bin: t.bin
}));
}
function totalsFor(stock) {
const on_hand = round2(stock.reduce((sum, s) => sum + s.quantity, 0));
const reserved = round2(stock.reduce((sum, s) => sum + s.reserved, 0));
return { on_hand, reserved, available: round2(on_hand - reserved), location_count: stock.length };
}
function decorate(row, totals) {
const onHand = totals.on_hand;
return {
...row,
supplier_name: contactName(row, 'supplier'),
on_hand: onHand,
reserved: totals.reserved,
available: totals.available,
location_count: totals.location_count,
stock_value: round2(onHand * num(row.unit_cost)),
low_stock: num(row.reorder_point) > 0 && onHand <= num(row.reorder_point)
};
}
function listParts(query = {}) {
const rows = all(
`SELECT p.*,
sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last,
COALESCE(st.on_hand, 0) AS on_hand, COALESCE(st.reserved, 0) AS reserved, COALESCE(st.locations, 0) AS location_count
FROM parts p
LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id
LEFT JOIN (
SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations
FROM part_stock GROUP BY part_id
) st ON st.part_id = p.id
WHERE (? IS NULL OR p.status = ?)
AND (? IS NULL OR p.category = ?)
AND (? IS NULL OR (
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
p.description LIKE '%' || ? || '%' OR p.barcode LIKE '%' || ? || '%' OR
p.manufacturer_part_number LIKE '%' || ? || '%'
))
ORDER BY p.name`,
[
query.status || null, query.status || null,
query.category || null, query.category || null,
query.search || null, query.search || null, query.search || null,
query.search || null, query.search || null
]
).map((row) => {
const onHand = round2(row.on_hand);
const reserved = round2(row.reserved);
return decorate(row, { on_hand: onHand, reserved, available: round2(onHand - reserved), location_count: row.location_count });
});
if (query.low_stock === 'true' || query.low_stock === true) return rows.filter((r) => r.low_stock);
return rows;
}
function getPart(id) {
const row = one(
`SELECT p.*, sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ?`,
[id]
);
if (!row) return null;
const stock = stockRows(id);
const part = decorate(row, totalsFor(stock));
part.stock = stock;
part.photos = partPhotos(id);
part.transactions = partTransactions(id);
return part;
}
function normalize(body) {
const input = {};
for (const column of COLUMNS) {
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
}
input.status = STATUSES.includes(input.status) ? input.status : 'active';
input.unit_of_measure = input.unit_of_measure || 'each';
for (const numeric of ['unit_cost', 'reorder_point', 'reorder_quantity']) {
if (input[numeric] !== undefined) input[numeric] = num(input[numeric]);
}
if (input.supplier_contact_id === '' || input.supplier_contact_id === undefined) input.supplier_contact_id = null;
return input;
}
function createPart(body, userId) {
const input = normalize(body);
if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 });
if (one('SELECT id FROM parts WHERE part_number = ?', [input.part_number])) {
throw Object.assign(new Error('That part number already exists'), { status: 400 });
}
const ts = now();
const cols = COLUMNS.filter((c) => input[c] !== undefined);
const result = run(
`INSERT INTO parts (${cols.join(', ')}, created_by, created_at, updated_at)
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => input[c]), userId, ts, ts]
);
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number });
return getPart(result.lastInsertRowid);
}
function updatePart(id, body, userId) {
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
if (!before) return null;
const input = normalize({ ...before, ...body });
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND id <> ?', [input.part_number, id])) {
throw Object.assign(new Error('That part number already exists'), { status: 400 });
}
run(
`UPDATE parts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
);
audit(userId, 'update', 'part', id, before, body);
return getPart(id);
}
function deletePart(id, userId) {
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
if (!before) return false;
run('DELETE FROM parts WHERE id = ?', [id]);
audit(userId, 'delete', 'part', id, before, null);
return true;
}
// ---- Stock locations -------------------------------------------------------
function saveStock(partId, body, userId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
const ts = now();
const location = body.location_contact_id || null;
const aisle = body.aisle || null;
const bin = body.bin || null;
const quantity = num(body.quantity);
const reserved = Math.max(0, Math.min(quantity, num(body.reserved)));
if (body.id) {
const existing = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.id, partId]);
if (!existing) return null;
run(
'UPDATE part_stock SET location_contact_id = ?, aisle = ?, bin = ?, quantity = ?, reserved = ?, updated_at = ? WHERE id = ?',
[location, aisle, bin, quantity, reserved, ts, body.id]
);
audit(userId, 'update', 'part_stock', body.id, existing, body);
} else {
const result = run(
'INSERT INTO part_stock (part_id, location_contact_id, aisle, bin, quantity, reserved, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[partId, location, aisle, bin, quantity, reserved, ts, ts]
);
audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId });
}
return getPart(partId);
}
function deleteStock(partId, stockId, userId) {
const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]);
if (!result.changes) return null;
audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null);
return getPart(partId);
}
// ---- Stock movements -------------------------------------------------------
function recordTransaction(partId, body, userId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive';
const stock = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.stock_id, partId]);
if (!stock) throw Object.assign(new Error('Select a stock location for this movement'), { status: 400 });
const qty = Math.abs(num(body.quantity));
if (!qty && type !== 'adjust') throw Object.assign(new Error('Quantity must be greater than zero'), { status: 400 });
let quantity = stock.quantity;
let reserved = stock.reserved;
switch (type) {
case 'receive': quantity += qty; break;
case 'issue': quantity = Math.max(0, quantity - qty); break;
case 'adjust': quantity = num(body.quantity); break; // absolute count correction
case 'reserve': reserved = Math.min(quantity, reserved + qty); break;
case 'unreserve': reserved = Math.max(0, reserved - qty); break;
default: break;
}
reserved = Math.min(reserved, quantity);
return tx(() => {
run('UPDATE part_stock SET quantity = ?, reserved = ?, updated_at = ? WHERE id = ?', [quantity, reserved, now(), stock.id]);
run(
`INSERT INTO part_transactions (part_id, stock_id, type, quantity, unit_cost, asset_id, reference, notes, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
partId, stock.id, type, type === 'adjust' ? num(body.quantity) : qty,
body.unit_cost != null && body.unit_cost !== '' ? num(body.unit_cost) : null,
body.asset_id || null, body.reference || null, body.notes || null, userId, now()
]
);
audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
return getPart(partId);
});
}
// ---- Photos ----------------------------------------------------------------
function addPhoto(partId, body, userId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
run(
'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)',
[partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()]
);
audit(userId, 'create', 'part_photo', partId, null, { name: body.name });
return getPart(partId);
}
function deletePhoto(partId, photoId, userId) {
const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]);
if (!result.changes) return null;
audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null);
return getPart(partId);
}
module.exports = {
STATUSES,
TRANSACTION_TYPES,
addPhoto,
createPart,
deletePart,
deletePhoto,
deleteStock,
getPart,
listParts,
recordTransaction,
saveStock,
updatePart
};

467
server/services/pm.js Normal file
View File

@@ -0,0 +1,467 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
const FREQUENCY_UNITS = ['days', 'weeks', 'months', 'quarters', 'semiannual', 'years'];
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function addInterval(dateStr, value, unit) {
const date = new Date(`${dateStr || todayStr()}T00:00:00`);
const v = Math.max(1, Number(value) || 1);
switch (unit) {
case 'days': date.setDate(date.getDate() + v); break;
case 'weeks': date.setDate(date.getDate() + v * 7); break;
case 'quarters': date.setMonth(date.getMonth() + v * 3); break;
case 'semiannual': date.setMonth(date.getMonth() + v * 6); break;
case 'years': date.setFullYear(date.getFullYear() + v); break;
case 'months':
default: date.setMonth(date.getMonth() + v); break;
}
return date.toISOString().slice(0, 10);
}
// ---- PM plan templates -----------------------------------------------------
function round2(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}
function yearOf(value) {
return value ? Number(String(value).slice(0, 4)) : null;
}
function planSteps(planId) {
return all('SELECT id, step_order, title, details, est_minutes FROM pm_plan_steps WHERE plan_id = ? ORDER BY step_order, id', [planId]);
}
function planComponents(planId) {
return all('SELECT id, sort_order, part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
}
// Guidance photos for a plan. `withData` is false for list views (omits the base64 blob).
function planPhotos(planId, withData = true) {
const columns = withData ? 'id, name, mime_type, caption, data_base64, sort_order' : 'id, name, mime_type, caption, sort_order';
return all(`SELECT ${columns} FROM pm_plan_photos WHERE plan_id = ? ORDER BY sort_order, id`, [planId])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, caption: p.caption, ...(withData ? { data: p.data_base64 } : {}) }));
}
function stepsMinutes(steps) {
return (steps || []).reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
}
function componentsTotal(components) {
return round2((components || []).reduce((sum, component) => sum + (Number(component.cost) || 0), 0));
}
function planFromRow(row, { withPhotoData = true } = {}) {
if (!row) return null;
const components = planComponents(row.id);
const photos = planPhotos(row.id, withPhotoData);
return { ...row, steps: planSteps(row.id), components, components_total: componentsTotal(components), photos, photo_count: photos.length };
}
function listPlans() {
return all('SELECT * FROM pm_plans ORDER BY name').map((row) => planFromRow(row, { withPhotoData: false }));
}
function getPlan(id) {
return planFromRow(one('SELECT * FROM pm_plans WHERE id = ?', [id]));
}
function writeSteps(planId, steps) {
run('DELETE FROM pm_plan_steps WHERE plan_id = ?', [planId]);
(steps || []).forEach((step, index) => {
const title = (step.title || '').trim();
if (!title) return;
const est = step.est_minutes === '' || step.est_minutes == null ? null : Number(step.est_minutes);
run('INSERT INTO pm_plan_steps (plan_id, step_order, title, details, est_minutes) VALUES (?, ?, ?, ?, ?)', [planId, index, title, step.details || null, est]);
});
}
function writeComponents(planId, components) {
run('DELETE FROM pm_plan_components WHERE plan_id = ?', [planId]);
(components || []).forEach((component, index) => {
const part = (component.part_number || '').trim();
const description = (component.description || '').trim();
if (!part && !description && !Number(component.cost)) return;
run(
'INSERT INTO pm_plan_components (plan_id, sort_order, part_number, description, supplier, cost) VALUES (?, ?, ?, ?, ?, ?)',
[planId, index, component.part_number || null, component.description || null, component.supplier || null, Number(component.cost) || 0]
);
});
}
// Reconcile guidance photos: existing items (carry an id) keep their stored blob and
// only update caption/order; new items (carry data) are inserted; missing ones are removed.
function writePhotos(planId, photos) {
const existing = all('SELECT id FROM pm_plan_photos WHERE plan_id = ?', [planId]).map((r) => r.id);
const keep = new Set();
(photos || []).forEach((photo, index) => {
if (photo.id && existing.includes(photo.id)) {
keep.add(photo.id);
run('UPDATE pm_plan_photos SET caption = ?, sort_order = ? WHERE id = ? AND plan_id = ?', [photo.caption || null, index, photo.id, planId]);
} else if (photo.data) {
const result = run(
'INSERT INTO pm_plan_photos (plan_id, name, mime_type, caption, data_base64, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[planId, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.caption || null, photo.data, index, now()]
);
keep.add(result.lastInsertRowid);
}
});
for (const id of existing) {
if (!keep.has(id)) run('DELETE FROM pm_plan_photos WHERE id = ?', [id]);
}
}
// Estimated minutes are derived from the step times when steps are supplied.
function resolveEstimatedMinutes(body, fallback) {
if (Array.isArray(body.steps)) {
const total = stepsMinutes(body.steps);
if (total > 0) return total;
}
if (body.estimated_minutes != null && body.estimated_minutes !== '') return Number(body.estimated_minutes);
return fallback ?? null;
}
function createPlan(body, userId) {
const ts = now();
return tx(() => {
const result = run(
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
body.name || 'PM plan', body.description || null, body.category || null,
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
resolveEstimatedMinutes(body, null), body.instructions || null,
userId, ts, ts
]
);
writeSteps(result.lastInsertRowid, body.steps);
writeComponents(result.lastInsertRowid, body.components);
writePhotos(result.lastInsertRowid, body.photos);
audit(userId, 'create', 'pm_plan', result.lastInsertRowid, null, { name: body.name });
return getPlan(result.lastInsertRowid);
});
}
function updatePlan(id, body, userId) {
const before = one('SELECT * FROM pm_plans WHERE id = ?', [id]);
if (!before) return null;
return tx(() => {
run(
`UPDATE pm_plans SET name = ?, description = ?, category = ?, frequency_value = ?, frequency_unit = ?,
estimated_minutes = ?, instructions = ?, updated_at = ? WHERE id = ?`,
[
body.name ?? before.name, body.description ?? before.description, body.category ?? before.category,
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
resolveEstimatedMinutes(body, before.estimated_minutes),
body.instructions ?? before.instructions, now(), id
]
);
if (Array.isArray(body.steps)) writeSteps(id, body.steps);
if (Array.isArray(body.components)) writeComponents(id, body.components);
if (Array.isArray(body.photos)) writePhotos(id, body.photos);
audit(userId, 'update', 'pm_plan', id, before, { name: body.name });
return getPlan(id);
});
}
function deletePlan(id, userId) {
const before = one('SELECT * FROM pm_plans WHERE id = ?', [id]);
if (!before) return false;
run('DELETE FROM pm_plans WHERE id = ?', [id]);
audit(userId, 'delete', 'pm_plan', id, before, null);
return true;
}
// ---- Per-asset PM schedules ------------------------------------------------
function assetPmRows(assetId) {
return all(
`SELECT ap.*, p.name AS plan_name, p.description AS plan_description, p.instructions AS plan_instructions,
u.name AS assigned_to_name
FROM asset_pm_plans ap
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = ap.assigned_to
WHERE ap.asset_id = ?
ORDER BY ap.active DESC, ap.next_due_date IS NULL, ap.next_due_date`,
[assetId]
).map((row) => {
const components = row.plan_id ? planComponents(row.plan_id) : [];
const completions = all(
`SELECT c.id, c.completed_at, c.notes, c.notes_json, c.steps_json, c.cost, c.components_json,
c.rating_safety, c.rating_physical, c.rating_operating,
CASE WHEN c.signature IS NOT NULL THEN 1 ELSE 0 END AS has_signature, c.next_due_date,
u.name AS completed_by_name,
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photo_count
FROM pm_completions c LEFT JOIN users u ON u.id = c.completed_by
WHERE c.asset_pm_id = ? ORDER BY c.completed_at DESC LIMIT 12`,
[row.id]
).map((c) => ({
...c,
has_signature: Boolean(c.has_signature),
notes: parseJson(c.notes_json, c.notes ? [c.notes] : []),
steps: parseJson(c.steps_json, []),
components: parseJson(c.components_json, []),
ratings: { safety: c.rating_safety, physical: c.rating_physical, operating: c.rating_operating }
}));
const totalCost = round2(completions.reduce((sum, c) => sum + Number(c.cost || 0), 0));
return {
...row,
active: Boolean(row.active),
steps: row.plan_id ? planSteps(row.plan_id) : [],
plan_photos: row.plan_id ? planPhotos(row.plan_id) : [],
components,
expected_cost: componentsTotal(components),
total_cost: totalCost,
last_cost: completions[0] ? round2(completions[0].cost) : 0,
completions
};
});
}
function attachPlan(assetId, body, userId) {
const ts = now();
const plan = body.plan_id ? one('SELECT * FROM pm_plans WHERE id = ?', [body.plan_id]) : null;
const value = Number(body.frequency_value) || (plan ? plan.frequency_value : 1);
const unit = FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : (plan ? plan.frequency_unit : 'months');
const startDate = body.start_date || todayStr();
const nextDue = body.next_due_date || startDate;
const result = run(
`INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, ts, ts]
);
audit(userId, 'attach', 'asset_pm', result.lastInsertRowid, null, { asset_id: assetId, plan_id: body.plan_id });
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [result.lastInsertRowid]);
}
function updateAssetPm(assetId, apId, body, userId) {
const before = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
if (!before) return null;
run(
`UPDATE asset_pm_plans SET frequency_value = ?, frequency_unit = ?, start_date = ?, end_date = ?,
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, updated_at = ? WHERE id = ?`,
[
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
body.start_date ?? before.start_date,
body.end_date !== undefined ? body.end_date : before.end_date,
body.next_due_date ?? before.next_due_date,
body.assigned_to !== undefined ? body.assigned_to : before.assigned_to,
body.active === undefined ? before.active : (body.active ? 1 : 0),
body.notes ?? before.notes,
now(), apId
]
);
audit(userId, 'update', 'asset_pm', apId, before, body);
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
}
function detachAssetPm(assetId, apId, userId) {
const result = run('DELETE FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
if (!result.changes) return false;
audit(userId, 'detach', 'asset_pm', apId, null, { asset_id: assetId });
return true;
}
function clampRating(value) {
if (value == null || value === '') return null;
return Math.max(1, Math.min(5, Number(value)));
}
function completePm(assetId, apId, body, userId) {
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
if (!ap) return null;
if (!body.signature) throw Object.assign(new Error('A signature is required to complete a PM'), { status: 400 });
const ts = now();
const completedDate = (body.completed_at || ts).slice(0, 10);
let nextDue = addInterval(completedDate, ap.frequency_value, ap.frequency_unit);
let active = ap.active;
if (ap.end_date && nextDue > ap.end_date) {
nextDue = null;
active = 0; // schedule complete — past the asset's maintenance end date
}
const planComps = ap.plan_id ? planComponents(ap.plan_id) : [];
const components = Array.isArray(body.components) ? body.components : planComps;
const cost = body.cost != null && body.cost !== '' ? round2(body.cost) : componentsTotal(planComps);
const notesList = Array.isArray(body.notes_list) ? body.notes_list.filter((n) => String(n || '').trim()) : (body.notes ? [body.notes] : []);
const ratings = body.ratings || {};
const photos = Array.isArray(body.photos) ? body.photos.filter((p) => p && p.data) : [];
return tx(() => {
const inserted = run(
`INSERT INTO pm_completions (asset_pm_id, completed_at, completed_by, notes, notes_json, steps_json, cost, components_json,
signature, rating_safety, rating_physical, rating_operating, next_due_date, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
apId, ts, userId, notesList.join('\n') || null, json(notesList), json(body.steps || []), cost, json(components),
body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), nextDue, ts
]
);
for (const photo of photos) {
run('INSERT INTO pm_completion_photos (completion_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)', [
inserted.lastInsertRowid, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.data, ts
]);
}
run('UPDATE asset_pm_plans SET last_completed_date = ?, next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [completedDate, nextDue, active, ts, apId]);
audit(userId, 'complete', 'asset_pm', apId, null, { next_due_date: nextDue, completion_id: inserted.lastInsertRowid });
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
});
}
function listCompletions(query = {}) {
return all(
`SELECT c.id, c.completed_at, c.cost, c.notes_json, c.rating_safety, c.rating_physical, c.rating_operating,
CASE WHEN c.signature IS NOT NULL THEN 1 ELSE 0 END AS has_signature,
u.name AS completed_by_name, a.asset_id AS asset_code, a.description AS asset_description, p.name AS plan_name,
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photo_count
FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = c.completed_by
WHERE (? IS NULL OR ap.asset_id = ?)
ORDER BY c.completed_at DESC, c.id DESC
LIMIT 500`,
[query.asset_id || null, query.asset_id || null]
).map((r) => ({ ...r, has_signature: Boolean(r.has_signature), notes: parseJson(r.notes_json, []) }));
}
function getCompletion(id) {
const row = one(
`SELECT c.*, u.name AS completed_by_name, a.asset_id AS asset_code, a.description AS asset_description, p.name AS plan_name
FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = c.completed_by
WHERE c.id = ?`,
[id]
);
if (!row) return null;
return {
id: row.id,
asset_code: row.asset_code,
asset_description: row.asset_description,
plan_name: row.plan_name,
completed_at: row.completed_at,
completed_by_name: row.completed_by_name,
cost: row.cost,
notes: parseJson(row.notes_json, row.notes ? [row.notes] : []),
steps: parseJson(row.steps_json, []),
components: parseJson(row.components_json, []),
signature: row.signature || null,
ratings: { safety: row.rating_safety, physical: row.rating_physical, operating: row.rating_operating },
photos: all('SELECT id, name, mime_type, data_base64 FROM pm_completion_photos WHERE completion_id = ? ORDER BY id', [row.id])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 }))
};
}
// ---- Settings & alert candidates ------------------------------------------
function getSettings() {
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'pm_%'");
const map = Object.fromEntries(rows.map((r) => [r.key, r.value]));
return {
pm_default_frequency_value: Number(map.pm_default_frequency_value || 3),
pm_default_frequency_unit: map.pm_default_frequency_unit || 'months',
pm_lead_days: Number(map.pm_lead_days || 7)
};
}
function saveSettings(body, userId) {
const set = (k, v) => run('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [k, String(v), now()]);
if (body.pm_default_frequency_value !== undefined) set('pm_default_frequency_value', Number(body.pm_default_frequency_value) || 3);
if (body.pm_default_frequency_unit !== undefined) set('pm_default_frequency_unit', FREQUENCY_UNITS.includes(body.pm_default_frequency_unit) ? body.pm_default_frequency_unit : 'months');
if (body.pm_lead_days !== undefined) set('pm_lead_days', Number(body.pm_lead_days) || 7);
audit(userId, 'update', 'pm_settings', null, null, body);
return getSettings();
}
// Alert candidates for PM schedules that are due/overdue.
function pmAlertCandidates() {
const lead = getSettings().pm_lead_days;
const today = todayStr();
const horizon = new Date(Date.now() + lead * 86400000).toISOString().slice(0, 10);
const rows = all(
`SELECT ap.*, p.name AS plan_name, a.asset_id AS asset_code
FROM asset_pm_plans ap
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN assets a ON a.id = ap.asset_id
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL`
);
const list = [];
for (const ap of rows) {
const name = ap.plan_name || 'Preventative maintenance';
const where = ap.asset_code ? ` on ${ap.asset_code}` : '';
if (ap.next_due_date < today) {
list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.`, due_date: ap.next_due_date });
} else if (ap.next_due_date <= horizon) {
list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.`, due_date: ap.next_due_date });
}
}
return list;
}
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
function pmBookLedger(year) {
const rows = all(
`SELECT c.cost, c.completed_at, a.asset_id, a.description
FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
JOIN assets a ON a.id = ap.asset_id`
);
const byAsset = {};
let total = 0;
let count = 0;
for (const row of rows) {
if (yearOf(row.completed_at) !== Number(year)) continue;
const cost = Number(row.cost || 0);
const entry = byAsset[row.asset_id] || (byAsset[row.asset_id] = { asset_id: row.asset_id, description: row.description, completions: 0, cost: 0, last_service: null });
entry.completions += 1;
entry.cost += cost;
const day = String(row.completed_at).slice(0, 10);
if (!entry.last_service || day > entry.last_service) entry.last_service = day;
total += cost;
count += 1;
}
const assets = Object.values(byAsset).map((a) => ({ ...a, cost: round2(a.cost) })).sort((a, b) => b.cost - a.cost);
const accounts = [
{ account: '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 },
{ account: '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) }
];
return {
year: Number(year),
kind: 'maintenance',
rule_set: {},
summary: { assets: assets.length, completions: count, cost: round2(total) },
accounts,
accountTotals: { debit: round2(total), credit: round2(total) },
assets
};
}
module.exports = {
FREQUENCY_UNITS,
addInterval,
assetPmRows,
pmBookLedger,
attachPlan,
completePm,
createPlan,
deletePlan,
detachAssetPm,
getCompletion,
getPlan,
listCompletions,
getSettings,
listPlans,
pmAlertCandidates,
saveSettings,
updateAssetPm,
updatePlan
};

View File

@@ -1,5 +1,6 @@
const { json, now, one, parseJson, run } = require('../db');
const { publicUser } = require('../middleware/auth');
const { capabilitiesForRole } = require('./roles');
const defaultPreferences = {
theme: 'mixedAssetsDark'
@@ -16,7 +17,8 @@ function getPreferences(userId) {
function getProfile(user) {
return {
user: publicUser(user),
preferences: getPreferences(user.id)
preferences: getPreferences(user.id),
capabilities: capabilitiesForRole(user.role)
};
}

View File

@@ -0,0 +1,807 @@
const { all, now, one, parseJson } = require('../db');
const { annualSchedule, money } = require('../depreciation');
const { ruleSetForBook } = require('./ruleSets');
const { assetFromRow } = require('./assets');
const BOOKS = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
function currentYear() {
return new Date().getFullYear();
}
function yearOf(value) {
if (!value) return null;
return new Date(`${value}T00:00:00`).getFullYear();
}
function sumBy(rows, keys) {
const totals = {};
for (const key of keys) totals[key] = money(rows.reduce((sum, row) => sum + Number(row[key] || 0), 0));
return totals;
}
// Columns helper: { key, label, format } where format drives client + export rendering.
function col(key, label, format = 'text') {
return { key, label, format };
}
// ---- Asset/book loading ----------------------------------------------------
function assetBookRows(bookType, options = {}) {
return all(
`SELECT a.*, b.book_type, 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, e.name AS entity_name
FROM assets a
JOIN asset_books b ON b.asset_id = a.id
LEFT JOIN entities e ON e.id = a.entity_id
WHERE b.book_type = ? AND b.active = 1
AND (? IS NULL OR a.entity_id = ?)
AND (? IS NULL OR a.category = ?)
AND (? IS NULL OR a.status = ?)
ORDER BY a.asset_id`,
[
bookType,
options.entity_id || null, options.entity_id || null,
options.category || null, options.category || null,
options.status || null, options.status || null
]
);
}
function toAssetBook(row) {
return {
asset: assetFromRow(row),
entity_name: row.entity_name,
book: {
book_type: row.book_type,
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
}
};
}
function computeYear(asset, book, rules, year) {
const rows = annualSchedule(asset, book, rules, { startYear: year - 1, endYear: year });
const end = rows.find((row) => row.year === year);
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
const depreciation = end ? Number(end.depreciation) : 0;
const accumulatedEnd = end ? Number(end.accumulated) : Number(asset.prior_depreciation || 0);
const accumulatedBegin = money(accumulatedEnd - depreciation);
return {
cost,
depreciation: money(depreciation),
accumulated_begin: accumulatedBegin,
accumulated_end: money(accumulatedEnd),
nbv_begin: money(cost - accumulatedBegin),
nbv_end: money(cost - accumulatedEnd),
method: end ? end.method : book.depreciation_method,
methodLabel: end ? end.methodLabel : book.depreciation_method
};
}
function lifetimeAccumulated(asset, book, rules) {
const rows = annualSchedule(asset, book, rules, { startYear: currentYear(), endYear: currentYear() });
return rows.length ? Number(rows[rows.length - 1].accumulated) : Number(asset.prior_depreciation || 0);
}
// ---- Report builders -------------------------------------------------------
function depreciationExpense(options, titleBook) {
const book = titleBook || options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => {
const c = computeYear(asset, bk, rules, year);
return {
asset_id: asset.asset_id,
description: asset.description,
entity: entity_name,
category: asset.category,
method: c.methodLabel,
cost: c.cost,
depreciation: c.depreciation,
accumulated: c.accumulated_end,
nbv: c.nbv_end
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
col('method', 'Method'), col('cost', 'Cost', 'currency'), col('depreciation', 'Depreciation', 'currency'),
col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv'])
};
}
function monthlyDepreciation(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const annual = depreciationExpense({ ...options, book }).totals.depreciation;
const monthly = money(annual / 12);
const rows = Array.from({ length: 12 }, (_, index) => ({ period: `M${index + 1}`, depreciation: monthly }));
return {
columns: [col('period', 'Month'), col('depreciation', 'Depreciation', 'currency')],
rows,
totals: { depreciation: money(monthly * 12) },
chart: { type: 'bar', labels: rows.map((r) => r.period), values: rows.map((r) => r.depreciation) }
};
}
function quarterlyDepreciation(options) {
const annual = depreciationExpense(options).totals.depreciation;
const quarter = money(annual / 4);
const rows = ['Q1', 'Q2', 'Q3', 'Q4'].map((period) => ({ period, depreciation: quarter }));
return {
columns: [col('period', 'Quarter'), col('depreciation', 'Depreciation', 'currency')],
rows,
totals: { depreciation: money(quarter * 4) },
chart: { type: 'bar', labels: rows.map((r) => r.period), values: rows.map((r) => r.depreciation) }
};
}
function ytdDepreciation(options) {
const month = Math.min(12, Math.max(1, Number(options.month) || 12));
const base = depreciationExpense(options);
const rows = base.rows.map((row) => ({ ...row, ytd: money((row.depreciation * month) / 12) }));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Method'),
col('depreciation', 'Annual', 'currency'), col('ytd', `YTD through M${month}`, 'currency'),
col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['depreciation', 'ytd', 'accumulated', 'nbv'])
};
}
function netBookValue(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => {
const c = computeYear(asset, bk, rules, year);
return {
asset_id: asset.asset_id, description: asset.description, entity: entity_name,
cost: c.cost, accumulated: c.accumulated_end, nbv: c.nbv_end
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('entity', 'Entity'),
col('cost', 'Cost', 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'accumulated', 'nbv'])
};
}
function lifetimeDepreciation(options) {
const book = options.book || 'GAAP';
const rules = ruleSetForBook(book);
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => {
const cost = Number(bk.cost ?? asset.acquisition_cost ?? 0);
const accumulated = lifetimeAccumulated(asset, bk, rules);
return {
asset_id: asset.asset_id, description: asset.description, entity: entity_name,
in_service_date: asset.in_service_date, cost,
lifetime_depreciation: money(accumulated), nbv: money(cost - accumulated)
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('in_service_date', 'In service', 'date'),
col('cost', 'Cost', 'currency'), col('lifetime_depreciation', 'Lifetime depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'lifetime_depreciation', 'nbv'])
};
}
function acquisitions(options) {
const year = Number(options.year) || currentYear();
const rows = all(
`SELECT a.asset_id, a.description, a.category, a.acquired_date, a.vendor, a.invoice_number, a.acquisition_cost
FROM assets a WHERE a.acquired_date IS NOT NULL
AND (? IS NULL OR a.entity_id = ?)
ORDER BY a.acquired_date`,
[options.entity_id || null, options.entity_id || null]
).filter((row) => yearOf(row.acquired_date) === year)
.map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
col('acquired_date', 'Acquired', 'date'), col('vendor', 'Vendor'), col('invoice_number', 'Invoice'),
col('acquisition_cost', 'Cost', 'currency')
],
rows,
totals: sumBy(rows, ['acquisition_cost'])
};
}
function disposalRows(year, entityId) {
return all(
`SELECT d.*, a.asset_id, a.description, a.category, a.acquired_date
FROM disposals d JOIN assets a ON a.id = d.asset_id
WHERE (? IS NULL OR a.entity_id = ?)
ORDER BY d.disposal_date`,
[entityId || null, entityId || null]
).filter((row) => yearOf(row.disposal_date) === year);
}
function disposals(options) {
const year = Number(options.year) || currentYear();
const rows = disposalRows(year, options.entity_id).map((row) => ({
asset_id: row.asset_id,
description: row.description,
method: row.method,
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)
}));
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')
],
rows,
totals: sumBy(rows, ['proceeds', 'net_book_value', 'gain_loss'])
};
}
function adjustments(options) {
const year = Number(options.year) || currentYear();
const rows = all(
`SELECT adj.*, a.asset_id, a.description FROM asset_adjustments adj
JOIN assets a ON a.id = adj.asset_id
WHERE (? IS NULL OR a.entity_id = ?)
ORDER BY adj.adjustment_date`,
[options.entity_id || null, options.entity_id || null]
).filter((row) => yearOf(row.adjustment_date) === year)
.map((row) => ({
asset_id: row.asset_id, description: row.description, type: row.type,
book_type: row.book_type, adjustment_date: row.adjustment_date,
amount: money(row.amount), reason: row.reason
}));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('type', 'Type'),
col('book_type', 'Book'), col('adjustment_date', 'Date', 'date'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')
],
rows,
totals: sumBy(rows, ['amount'])
};
}
function rollforward(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const groups = {};
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
const c = computeYear(asset, bk, rules, year);
const key = asset.category || 'Uncategorized';
groups[key] = groups[key] || { category: key, beginning_nbv: 0, additions: 0, depreciation: 0, disposals: 0, ending_nbv: 0 };
groups[key].beginning_nbv += c.nbv_begin;
groups[key].depreciation += c.depreciation;
if (yearOf(asset.acquired_date) === year) groups[key].additions += c.cost;
groups[key].ending_nbv += c.nbv_end;
}
for (const row of disposalRows(year, options.entity_id)) {
const key = row.category || 'Uncategorized';
groups[key] = groups[key] || { category: key, beginning_nbv: 0, additions: 0, depreciation: 0, disposals: 0, ending_nbv: 0 };
groups[key].disposals += Number(row.net_book_value || 0);
}
const rows = Object.values(groups).map((row) => ({
category: row.category,
beginning_nbv: money(row.beginning_nbv),
additions: money(row.additions),
depreciation: money(row.depreciation),
disposals: money(row.disposals),
ending_nbv: money(row.ending_nbv)
}));
return {
columns: [
col('category', 'Category'), col('beginning_nbv', 'Beginning NBV', 'currency'),
col('additions', 'Additions', 'currency'), col('depreciation', 'Depreciation', 'currency'),
col('disposals', 'Disposals (NBV)', 'currency'), col('ending_nbv', 'Ending NBV', 'currency')
],
rows,
totals: sumBy(rows, ['beginning_nbv', 'additions', 'depreciation', 'disposals', 'ending_nbv'])
};
}
function projection(options) {
const book = options.book || 'GAAP';
const startYear = Number(options.startYear) || currentYear();
const endYear = Number(options.endYear) || startYear + 5;
const rules = ruleSetForBook(book);
const items = assetBookRows(book, options).map(toAssetBook);
const rows = [];
for (let year = startYear; year <= endYear; year += 1) {
let depreciation = 0;
let nbv = 0;
for (const { asset, book: bk } of items) {
const c = computeYear(asset, bk, rules, year);
depreciation += c.depreciation;
nbv += c.nbv_end;
}
rows.push({ year, depreciation: money(depreciation), ending_nbv: money(nbv) });
}
return {
columns: [col('year', 'Year', 'number'), col('depreciation', 'Projected depreciation', 'currency'), col('ending_nbv', 'Ending NBV', 'currency')],
rows,
totals: { depreciation: money(rows.reduce((sum, row) => sum + row.depreciation, 0)) },
chart: { type: 'bar', labels: rows.map((r) => String(r.year)), values: rows.map((r) => r.depreciation) }
};
}
function journalDepreciation(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const accounts = {};
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
const c = computeYear(asset, bk, rules, year);
if (!c.depreciation) continue;
const expense = asset.gl_expense_account || '6400';
const accum = asset.gl_accumulated_account || '1590';
accounts[expense] = (accounts[expense] || 0) + c.depreciation;
accounts[`__accum__${accum}`] = (accounts[`__accum__${accum}`] || 0) + c.depreciation;
}
const rows = [];
for (const [key, amount] of Object.entries(accounts)) {
if (key.startsWith('__accum__')) {
rows.push({ account: key.replace('__accum__', ''), memo: 'Accumulated depreciation', debit: 0, credit: money(amount) });
} else {
rows.push({ account: key, memo: 'Depreciation expense', debit: money(amount), credit: 0 });
}
}
rows.sort((a, b) => b.debit - a.debit);
return {
columns: [col('account', 'GL account'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
rows,
totals: sumBy(rows, ['debit', 'credit'])
};
}
function journalDisposals(options) {
const year = Number(options.year) || currentYear();
const rows = [];
for (const d of disposalRows(year, options.entity_id)) {
const proceeds = money(d.disposal_price - d.disposal_expense);
rows.push({ asset_id: d.asset_id, memo: 'Cash / proceeds', debit: proceeds, credit: 0 });
rows.push({ asset_id: d.asset_id, memo: 'Accumulated depreciation', debit: money(d.accumulated_depreciation), credit: 0 });
rows.push({ asset_id: d.asset_id, memo: 'Asset cost', debit: 0, credit: money(d.disposed_cost) });
if (d.gain_loss >= 0) rows.push({ asset_id: d.asset_id, memo: 'Gain on disposal', debit: 0, credit: money(d.gain_loss) });
else rows.push({ asset_id: d.asset_id, memo: 'Loss on disposal', debit: money(Math.abs(d.gain_loss)), credit: 0 });
}
return {
columns: [col('asset_id', 'Asset ID'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
rows,
totals: sumBy(rows, ['debit', 'credit'])
};
}
function form4562(options) {
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook('FEDERAL');
const items = assetBookRows('FEDERAL', options).map(toAssetBook)
.filter(({ asset }) => yearOf(asset.in_service_date || asset.acquired_date) === year);
let section179 = 0;
let bonus = 0;
let macrs = 0;
const rows = items.map(({ asset, book: bk }) => {
const c = computeYear(asset, bk, rules, year);
const s179 = Math.min(Number(bk.cost ?? asset.acquisition_cost ?? 0), Number(bk.section_179_amount || 0));
section179 += s179;
macrs += c.depreciation;
return {
asset_id: asset.asset_id, description: asset.description, method: c.methodLabel,
cost: c.cost, section_179: money(s179), depreciation: c.depreciation
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Method'),
col('cost', 'Cost', 'currency'), col('section_179', '§179', 'currency'), col('depreciation', 'Depreciation', 'currency')
],
rows,
totals: { section_179: money(section179), bonus: money(bonus), depreciation: money(macrs), cost: money(rows.reduce((s, r) => s + r.cost, 0)) },
meta: { note: 'Summary for IRS Form 4562 (Federal book, current-year placements). Validate against current tax law before filing.' }
};
}
function form4797(options) {
const year = Number(options.year) || currentYear();
const rows = disposalRows(year, options.entity_id).map((d) => ({
asset_id: d.asset_id,
description: d.description,
property_type: d.property_type || 'other',
acquired_date: d.acquired_date,
disposal_date: d.disposal_date,
proceeds: money(d.disposal_price - d.disposal_expense),
cost: money(d.disposed_cost),
accumulated: money(d.accumulated_depreciation),
gain_loss: money(d.gain_loss)
}));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('property_type', 'Property type'),
col('acquired_date', 'Acquired', 'date'), col('disposal_date', 'Sold', 'date'),
col('proceeds', 'Proceeds', 'currency'), col('cost', 'Cost basis', 'currency'),
col('accumulated', 'Depr. allowed', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')
],
rows,
totals: sumBy(rows, ['proceeds', 'cost', 'accumulated', 'gain_loss']),
meta: { note: 'Summary for IRS Form 4797 (Sales of Business Property). §1245/§1250 recapture analysis should be reviewed before filing.' }
};
}
function personalPropertyTax(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const groups = {};
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
const c = computeYear(asset, bk, rules, year);
const key = asset.location || 'Unassigned';
groups[key] = groups[key] || { location: key, count: 0, cost: 0, nbv: 0 };
groups[key].count += 1;
groups[key].cost += c.cost;
groups[key].nbv += c.nbv_end;
}
const rows = Object.values(groups).map((row) => ({ ...row, cost: money(row.cost), nbv: money(row.nbv) }));
return {
columns: [col('location', 'Location'), col('count', 'Assets', 'number'), col('cost', 'Cost', 'currency'), col('nbv', 'Taxable NBV', 'currency')],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost', 'nbv']) }
};
}
function constructionInProgress(options) {
const rows = all(
`SELECT asset_id, description, category, acquired_date, acquisition_cost, vendor
FROM assets WHERE status = 'cip' AND (? IS NULL OR entity_id = ?) ORDER BY acquired_date`,
[options.entity_id || null, options.entity_id || null]
).map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
return {
columns: [
col('asset_id', 'Project ID'), col('description', 'Description'), col('category', 'Category'),
col('acquired_date', 'Started', 'date'), col('vendor', 'Vendor'), col('acquisition_cost', 'Cost to date', 'currency')
],
rows,
totals: sumBy(rows, ['acquisition_cost'])
};
}
function ubia(options) {
const rows = all(
`SELECT asset_id, description, in_service_date, acquisition_cost, land_value
FROM assets WHERE status != 'disposed' AND (? IS NULL OR entity_id = ?) ORDER BY asset_id`,
[options.entity_id || null, options.entity_id || null]
).map((row) => ({
asset_id: row.asset_id, description: row.description, in_service_date: row.in_service_date,
ubia: money(Number(row.acquisition_cost || 0) - Number(row.land_value || 0))
}));
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'),
col('in_service_date', 'In service', 'date'), col('ubia', 'UBIA', 'currency')
],
rows,
totals: sumBy(rows, ['ubia']),
meta: { note: 'Unadjusted Basis Immediately After Acquisition for the §199A qualified business income limitation.' }
};
}
function fixedAssetCard(options) {
const asset = options.asset_id
? assetFromRow(one('SELECT * FROM assets WHERE id = ? OR asset_id = ? LIMIT 1', [options.asset_id, options.asset_id]))
: null;
if (!asset) return { columns: [], rows: [], totals: {}, meta: { note: 'Select an asset to view its card.' } };
const year = Number(options.year) || currentYear();
const books = all('SELECT * FROM asset_books WHERE asset_id = ? AND active = 1 ORDER BY book_type', [asset.id]);
const rows = books.map((bookRow) => {
const book = { ...bookRow, useful_life_months: bookRow.useful_life_months, manual_depreciation: bookRow.manual_depreciation };
const rules = ruleSetForBook(bookRow.book_type);
const c = computeYear(asset, book, rules, year);
return {
book: bookRow.book_type, method: c.methodLabel, cost: c.cost,
depreciation: c.depreciation, accumulated: c.accumulated_end, nbv: c.nbv_end
};
});
return {
columns: [
col('book', 'Book'), col('method', 'Method'), col('cost', 'Cost', 'currency'),
col('depreciation', `${year} depr.`, 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv']),
meta: {
asset: {
asset_id: asset.asset_id, description: asset.description, category: asset.category, status: asset.status,
vendor: asset.vendor, serial_number: asset.serial_number, location: asset.location,
department: asset.department, custodian: asset.custodian, acquired_date: asset.acquired_date,
in_service_date: asset.in_service_date, barcode_value: asset.barcode_value
}
}
};
}
// ---- Report Builder (custom) ----------------------------------------------
const BUILDER_FIELDS = [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
col('status', 'Status'), col('entity_name', 'Entity'), col('location', 'Location'),
col('department', 'Department'), col('group_name', 'Group'), col('custodian', 'Custodian'),
col('vendor', 'Vendor'), col('serial_number', 'Serial'), col('invoice_number', 'Invoice'),
col('barcode_value', 'Barcode'), col('condition', 'Condition'), col('new_or_used', 'New/Used'),
col('acquisition_cost', 'Cost', 'currency'), col('salvage_value', 'Salvage', 'currency'),
col('land_value', 'Land value', 'currency'), col('useful_life_months', 'Life (months)', 'number'),
col('acquired_date', 'Acquired', 'date'), col('in_service_date', 'In service', 'date'),
col('gl_asset_account', 'Asset GL'), col('gl_expense_account', 'Expense GL'), col('gl_accumulated_account', 'Accum. GL')
];
function customReport(options) {
const requested = Array.isArray(options.columns) && options.columns.length
? options.columns
: ['asset_id', 'description', 'category', 'status', 'acquisition_cost'];
const columns = BUILDER_FIELDS.filter((field) => requested.includes(field.key));
const rows = all(
`SELECT a.*, e.name AS entity_name FROM assets a LEFT JOIN entities e ON e.id = a.entity_id
WHERE (? IS NULL OR a.entity_id = ?)
AND (? IS NULL OR a.category = ?)
AND (? IS NULL OR a.status = ?)
ORDER BY a.asset_id`,
[
options.entity_id || null, options.entity_id || null,
options.category || null, options.category || null,
options.status || null, options.status || null
]
).map((row) => {
const projected = {};
for (const column of columns) projected[column.key] = row[column.key];
return projected;
});
const currencyKeys = columns.filter((c) => c.format === 'currency').map((c) => c.key);
return { columns, rows, totals: sumBy(rows, currencyKeys) };
}
// ---- Maintenance & alerts --------------------------------------------------
function dateStr(date) {
return date.toISOString().slice(0, 10);
}
function freqLabel(value, unit) {
return `every ${value} ${unit}`;
}
function pmDue(options) {
const before = options.before_date || dateStr(new Date(Date.now() + 30 * 86400000));
const rows = all(
`SELECT ap.next_due_date, ap.last_completed_date, ap.frequency_value, ap.frequency_unit,
p.name AS plan_name, a.asset_id AS asset_code, a.description AS asset_description, u.name AS assigned_to_name
FROM asset_pm_plans ap
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = ap.assigned_to
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL AND ap.next_due_date <= ?
AND (? IS NULL OR a.entity_id = ?)
ORDER BY ap.next_due_date`,
[before, options.entity_id || null, options.entity_id || null]
).map((row) => ({ ...row, plan_name: row.plan_name || 'Maintenance', frequency: freqLabel(row.frequency_value, row.frequency_unit) }));
return {
columns: [
col('asset_code', 'Asset'), col('asset_description', 'Description'), col('plan_name', 'Plan'),
col('frequency', 'Frequency'), col('next_due_date', 'Next due', 'date'), col('last_completed_date', 'Last done', 'date'),
col('assigned_to_name', 'Assigned to')
],
rows,
totals: {},
meta: { note: `${rows.length} PM schedule(s) due on or before ${before}.` }
};
}
function pmUncompleted(options) {
const today = dateStr(new Date());
const rows = all(
`SELECT ap.next_due_date, ap.last_completed_date, ap.frequency_value, ap.frequency_unit,
p.name AS plan_name, a.asset_id AS asset_code, a.description AS asset_description, u.name AS assigned_to_name
FROM asset_pm_plans ap
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = ap.assigned_to
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL AND ap.next_due_date < ?
AND (? IS NULL OR a.entity_id = ?)
ORDER BY ap.next_due_date`,
[today, options.entity_id || null, options.entity_id || null]
).map((row) => ({
...row,
plan_name: row.plan_name || 'Maintenance',
frequency: freqLabel(row.frequency_value, row.frequency_unit),
days_overdue: Math.max(0, Math.round((new Date(`${today}T00:00:00`) - new Date(`${row.next_due_date}T00:00:00`)) / 86400000))
}));
return {
columns: [
col('asset_code', 'Asset'), col('asset_description', 'Description'), col('plan_name', 'Plan'),
col('next_due_date', 'Was due', 'date'), col('days_overdue', 'Days overdue', 'number'),
col('last_completed_date', 'Last done', 'date'), col('assigned_to_name', 'Assigned to')
],
rows,
totals: {},
meta: { note: `${rows.length} asset PM schedule(s) overdue / uncompleted as of ${today}.` }
};
}
function pmCompleted(options) {
const since = options.since_date || dateStr(new Date(Date.now() - 30 * 86400000));
const rows = all(
`SELECT c.completed_at, c.cost, c.rating_safety, c.rating_physical, c.rating_operating,
CASE WHEN c.signature IS NOT NULL THEN 'yes' ELSE 'no' END AS signed,
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photos,
u.name AS completed_by_name, a.asset_id AS asset_code, p.name AS plan_name
FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
JOIN assets a ON a.id = ap.asset_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = c.completed_by
WHERE c.completed_at >= ?
AND (? IS NULL OR a.entity_id = ?)
ORDER BY c.completed_at DESC`,
[since, options.entity_id || null, options.entity_id || null]
).map((row) => ({ ...row, plan_name: row.plan_name || 'Maintenance', completed_at: String(row.completed_at).slice(0, 10), cost: money(row.cost) }));
return {
columns: [
col('asset_code', 'Asset'), col('plan_name', 'Plan'), col('completed_at', 'Completed', 'date'),
col('completed_by_name', 'By'), col('rating_safety', 'Safety', 'number'), col('rating_operating', 'Operating', 'number'),
col('photos', 'Photos', 'number'), col('signed', 'Signed'), col('cost', 'Cost', 'currency')
],
rows,
totals: sumBy(rows, ['cost']),
meta: { note: `${rows.length} PM service(s) completed since ${since}.` }
};
}
function pmPlanPrint(options) {
const plan = options.pm_plan_id ? one('SELECT * FROM pm_plans WHERE id = ?', [options.pm_plan_id]) : null;
if (!plan) return { columns: [], rows: [], totals: {}, meta: { note: 'Select a PM plan to print.' } };
const steps = all('SELECT step_order, title, details, est_minutes FROM pm_plan_steps WHERE plan_id = ? ORDER BY step_order, id', [plan.id]);
const components = all('SELECT part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [plan.id]);
const rows = steps.map((step, index) => ({ step: index + 1, title: step.title, details: step.details, est_minutes: step.est_minutes }));
const estTotal = steps.reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
const partsTotal = components.reduce((sum, comp) => sum + (Number(comp.cost) || 0), 0);
const partsLines = components.map((comp) => `${comp.part_number ? comp.part_number + ' ' : ''}${comp.description || ''}${comp.supplier ? ` (${comp.supplier})` : ''}${money(comp.cost)}`).join('; ');
const noteParts = [
`Frequency: ${freqLabel(plan.frequency_value, plan.frequency_unit)}`,
`Estimated time: ${estTotal} min`
];
if (plan.description) noteParts.push(`Description: ${plan.description}`);
if (plan.instructions) noteParts.push(`Instructions: ${plan.instructions}`);
if (components.length) noteParts.push(`Parts (${money(partsTotal)}): ${partsLines}`);
return {
title: `PM plan — ${plan.name}`,
columns: [col('step', '#', 'number'), col('title', 'Step'), col('details', 'Details'), col('est_minutes', 'Est. min', 'number')],
rows,
totals: { est_minutes: estTotal },
meta: { note: noteParts.join(' · ') }
};
}
function alertsSince(options) {
const since = options.since_date || dateStr(new Date(Date.now() - 30 * 86400000));
const rows = all(
`SELECT al.severity, al.type, al.title, al.due_date, al.status, al.created_at, a.asset_id AS asset_code
FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id
WHERE al.created_at >= ?
ORDER BY al.created_at DESC`,
[since]
).map((row) => ({ ...row, type: String(row.type).replace(/_/g, ' '), created_at: String(row.created_at).slice(0, 10) }));
return {
columns: [
col('severity', 'Severity'), col('type', 'Type'), col('title', 'Alert'), col('asset_code', 'Asset'),
col('due_date', 'Due', 'date'), col('status', 'Status'), col('created_at', 'Raised', 'date')
],
rows,
totals: {},
meta: { note: `${rows.length} alert(s) raised since ${since}.` }
};
}
// ---- Registry --------------------------------------------------------------
const REPORTS = {
depreciation_expense: { title: 'Annual depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => depreciationExpense(o) },
monthly_depreciation: { title: '12-month depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => monthlyDepreciation(o) },
quarterly_depreciation: { title: 'Quarterly depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => quarterlyDepreciation(o) },
ytd_depreciation: { title: 'Annual & YTD depreciation', group: 'Depreciation', params: ['book', 'year', 'month', 'entity_id'], build: (o) => ytdDepreciation(o) },
net_book_value: { title: 'Net book value', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => netBookValue(o) },
lifetime_depreciation: { title: 'Lifetime depreciation', group: 'Depreciation', params: ['book', 'entity_id'], build: (o) => lifetimeDepreciation(o) },
projection: { title: 'Projection', group: 'Depreciation', params: ['book', 'startYear', 'endYear', 'entity_id'], build: (o) => projection(o) },
rollforward: { title: 'Annual rollforward', group: 'Activity', params: ['book', 'year', 'entity_id'], build: (o) => rollforward(o) },
acquisitions: { title: 'Annual acquisitions', group: 'Activity', params: ['year', 'entity_id'], build: (o) => acquisitions(o) },
disposals: { title: 'Annual disposals & gain/loss', group: 'Activity', params: ['year', 'entity_id'], build: (o) => disposals(o) },
adjustments: { title: 'Annual adjustments', group: 'Activity', params: ['year', 'entity_id'], build: (o) => adjustments(o) },
cip: { title: 'Construction-in-progress', group: 'Activity', params: ['entity_id'], build: (o) => constructionInProgress(o) },
journal_depreciation: { title: 'Journal entry: depreciation', group: 'Journal entries', params: ['book', 'year', 'entity_id'], build: (o) => journalDepreciation(o) },
journal_disposals: { title: 'Journal entry: disposals', group: 'Journal entries', params: ['year', 'entity_id'], build: (o) => journalDisposals(o) },
form_4562: { title: 'Federal Form 4562 (depreciation)', group: 'Tax', params: ['year', 'entity_id'], build: (o) => form4562(o) },
form_4797: { title: 'Federal Form 4797 (sales)', group: 'Tax', params: ['year', 'entity_id'], build: (o) => form4797(o) },
amt: { title: 'AMT depreciation', group: 'Tax', params: ['year', 'entity_id'], build: (o) => depreciationExpense(o, 'AMT') },
ace: { title: 'ACE depreciation', group: 'Tax', params: ['year', 'entity_id'], build: (o) => depreciationExpense(o, 'ACE') },
personal_property_tax: { title: 'Personal property tax', group: 'Tax', params: ['book', 'year', 'entity_id'], build: (o) => personalPropertyTax(o) },
ubia: { title: 'UBIA (§199A)', group: 'Tax', params: ['entity_id'], build: (o) => ubia(o) },
pm_due: { title: 'PM due before date', group: 'Maintenance', params: ['before_date', 'entity_id'], build: (o) => pmDue(o) },
pm_uncompleted: { title: 'Assets with uncompleted PM', group: 'Maintenance', params: ['entity_id'], build: (o) => pmUncompleted(o) },
pm_completed: { title: 'PM completed since date', group: 'Maintenance', params: ['since_date', 'entity_id'], build: (o) => pmCompleted(o) },
pm_plan: { title: 'PM plan (printable)', group: 'Maintenance', params: ['pm_plan_id'], build: (o) => pmPlanPrint(o) },
alerts_since: { title: 'Alerts since date', group: 'Maintenance', params: ['since_date'], build: (o) => alertsSince(o) },
fixed_asset_card: { title: 'Fixed asset card', group: 'Detail', params: ['asset_id', 'year'], build: (o) => fixedAssetCard(o) },
custom: { title: 'Report builder (custom)', group: 'Detail', params: ['columns', 'entity_id', 'category', 'status'], build: (o) => customReport(o) }
};
function paramSpecs() {
const entities = all('SELECT id, name FROM entities ORDER BY name').map((row) => ({ title: row.name, value: row.id }));
const assets = all('SELECT id, asset_id, description FROM assets ORDER BY asset_id LIMIT 1000')
.map((row) => ({ title: `${row.asset_id} · ${row.description}`, value: row.id }));
const categories = all('SELECT DISTINCT category FROM assets ORDER BY category').map((row) => row.category).filter(Boolean);
const statuses = all('SELECT DISTINCT status FROM assets ORDER BY status').map((row) => row.status).filter(Boolean);
const year = currentYear();
const pmPlanOptions = all('SELECT id, name FROM pm_plans ORDER BY name').map((row) => ({ title: row.name, value: row.id }));
const today = new Date();
const bookCodes = (() => {
try {
const rows = all("SELECT code FROM books WHERE book_type != 'maintenance' ORDER BY sort_order, id");
if (rows.length) return rows.map((row) => row.code);
} catch {
// books table may be unavailable
}
return BOOKS;
})();
return {
book: { label: 'Book', type: 'select', options: bookCodes, default: bookCodes[0] || 'GAAP' },
year: { label: 'Year', type: 'number', default: year },
month: { label: 'Through month', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), default: 12 },
startYear: { label: 'Start year', type: 'number', default: year },
endYear: { label: 'End year', type: 'number', default: year + 5 },
entity_id: { label: 'Entity', type: 'select', options: entities, clearable: true, default: null },
category: { label: 'Category', type: 'select', options: categories, clearable: true, default: null },
status: { label: 'Status', type: 'select', options: statuses, clearable: true, default: null },
asset_id: { label: 'Asset', type: 'select', options: assets, default: null },
columns: { label: 'Columns', type: 'multiselect', options: BUILDER_FIELDS.map((f) => ({ title: f.label, value: f.key })), default: ['asset_id', 'description', 'category', 'status', 'acquisition_cost'] },
before_date: { label: 'Due before', type: 'date', default: dateStr(new Date(today.getTime() + 30 * 86400000)) },
since_date: { label: 'Since', type: 'date', default: dateStr(new Date(today.getTime() - 30 * 86400000)) },
pm_plan_id: { label: 'PM plan', type: 'select', options: pmPlanOptions, default: pmPlanOptions[0]?.value || null }
};
}
function catalog() {
const reports = Object.entries(REPORTS).map(([key, def]) => ({ key, title: def.title, group: def.group, params: def.params }));
return { reports, params: paramSpecs() };
}
function runReport(type, options = {}) {
const def = REPORTS[type];
if (!def) {
const error = new Error(`Unknown report type: ${type}`);
error.status = 400;
throw error;
}
const result = def.build(options);
return { type, title: def.title, group: def.group, generatedAt: now(), options, ...result };
}
module.exports = { BOOKS, catalog, computeYear, runReport };

View File

@@ -0,0 +1,123 @@
const PDFDocument = require('pdfkit');
const Papa = require('papaparse');
const writeXlsxFile = require('write-excel-file/node');
function formatValue(value, format) {
if (value === null || value === undefined || value === '') return '';
if (format === 'currency') return Number(value).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (format === 'number') return Number(value).toLocaleString('en-US');
return String(value);
}
function tableMatrix(report) {
const header = report.columns.map((column) => column.label);
const body = report.rows.map((row) => report.columns.map((column) => {
const value = row[column.key];
return column.format === 'currency' || column.format === 'number' ? Number(value || 0) : (value ?? '');
}));
return { header, body };
}
function totalsRow(report) {
if (!report.totals || !Object.keys(report.totals).length) return null;
return report.columns.map((column, index) => {
if (index === 0) return 'Totals';
if (Object.prototype.hasOwnProperty.call(report.totals, column.key)) return report.totals[column.key];
return '';
});
}
function exportCsv(report) {
const { header, body } = tableMatrix(report);
const totals = totalsRow(report);
const rows = totals ? [...body, totals] : body;
return {
contentType: 'text/csv',
body: Papa.unparse([header, ...rows])
};
}
async function exportXlsx(report) {
const { header } = tableMatrix(report);
const totals = totalsRow(report);
const data = [
header.map((label) => ({ value: label, fontWeight: 'bold' })),
...report.rows.map((row) => report.columns.map((column) => {
const value = row[column.key];
if (column.format === 'currency' || column.format === 'number') {
return { type: Number, value: Number(value || 0), format: column.format === 'currency' ? '#,##0.00' : '#,##0' };
}
return { value: value === null || value === undefined ? '' : String(value) };
}))
];
if (totals) {
data.push(totals.map((value, index) => {
if (index === 0) return { value: 'Totals', fontWeight: 'bold' };
return typeof value === 'number'
? { type: Number, value, format: '#,##0.00', fontWeight: 'bold' }
: { value: String(value || ''), fontWeight: 'bold' };
}));
}
return {
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
body: await writeXlsxFile(data).toBuffer()
};
}
function exportPdf(report) {
const doc = new PDFDocument({ margin: 36, size: 'LETTER', layout: 'landscape' });
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.fontSize(16).text(report.title || 'MixedAssets report');
doc.fontSize(9).fillColor('#555').text(
`Generated ${new Date(report.generatedAt || Date.now()).toLocaleString()}` +
(report.options ? ` · ${Object.entries(report.options).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}: ${v}`).join(' · ')}` : '')
);
if (report.meta && report.meta.note) doc.moveDown(0.3).fontSize(8).fillColor('#777').text(report.meta.note);
doc.fillColor('#000').moveDown(0.6);
const pageWidth = doc.page.width - 72;
const columns = report.columns;
const colWidth = pageWidth / columns.length;
const drawRow = (cells, options = {}) => {
const y = doc.y;
doc.fontSize(options.bold ? 8.5 : 8);
cells.forEach((cell, index) => {
const column = columns[index];
const align = column.format === 'currency' || column.format === 'number' ? 'right' : 'left';
doc.text(String(cell ?? ''), 36 + index * colWidth, y, { width: colWidth - 6, align, ellipsis: true });
});
doc.moveDown(0.2);
if (doc.y > doc.page.height - 48) doc.addPage();
};
drawRow(columns.map((column) => column.label), { bold: true });
doc.moveTo(36, doc.y).lineTo(36 + pageWidth, doc.y).strokeColor('#ccc').stroke();
doc.moveDown(0.2);
for (const row of report.rows) {
drawRow(columns.map((column) => formatValue(row[column.key], column.format)));
}
const totals = totalsRow(report);
if (totals) {
doc.moveTo(36, doc.y).lineTo(36 + pageWidth, doc.y).strokeColor('#ccc').stroke();
doc.moveDown(0.2);
drawRow(totals.map((value, index) => (typeof value === 'number' ? formatValue(value, columns[index].format || 'currency') : value)), { bold: true });
}
doc.end();
return new Promise((resolve) => {
doc.on('end', () => resolve({ contentType: 'application/pdf', body: Buffer.concat(chunks) }));
});
}
async function exportReport(report, format) {
if (format === 'csv') return exportCsv(report);
if (format === 'xlsx') return exportXlsx(report);
if (format === 'pdf') return exportPdf(report);
return { contentType: 'application/json', body: JSON.stringify(report, null, 2) };
}
module.exports = { exportReport };

View File

@@ -1,15 +1,11 @@
const PDFDocument = require('pdfkit');
const { all, one, parseJson } = require('../db');
const { all, one } = require('../db');
const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
const { assetExportRows, assetFromRow } = require('./assets');
function activeRuleSet() {
const row = one('SELECT * FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1');
return row ? parseJson(row.rules_json, {}) : {};
}
const { ruleSetForBook } = require('./ruleSets');
function depreciationReport(year, bookType) {
const rules = activeRuleSet();
const rules = ruleSetForBook(bookType);
const rows = all(`
SELECT a.*, b.book_type, b.active, b.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_business_use_percent,
@@ -59,7 +55,7 @@ function depreciationReport(year, bookType) {
}
function monthlyDepreciationReport(year, bookType) {
const rules = activeRuleSet();
const rules = ruleSetForBook(bookType);
const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow);
const rows = assets.flatMap((asset) => {
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
@@ -100,7 +96,6 @@ function writeDepreciationPdf(res, year, book) {
}
module.exports = {
activeRuleSet,
depreciationReport,
monthlyDepreciationReport,
writeDepreciationPdf

102
server/services/roles.js Normal file
View File

@@ -0,0 +1,102 @@
const { all, audit, json, now, one, parseJson, run } = require('../db');
const { CAPABILITY_KEYS, WILDCARD, capabilitySetIncludes, sanitizeCapabilities } = require('./accessControl');
function slugify(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 40);
}
function fromRow(row) {
if (!row) return null;
return {
key: row.key,
name: row.name,
description: row.description || '',
capabilities: parseJson(row.capabilities_json, []),
is_system: Boolean(row.is_system),
locked: Boolean(row.locked),
user_count: one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [row.key]).c
};
}
function listRoles() {
return all('SELECT * FROM roles ORDER BY sort_order, name').map(fromRow);
}
function getRole(key) {
return fromRow(one('SELECT * FROM roles WHERE key = ?', [key]));
}
function roleExists(key) {
return Boolean(one('SELECT key FROM roles WHERE key = ?', [key]));
}
// Capabilities granted to a role key (empty when the role is unknown).
function capabilitiesForRole(key) {
const row = one('SELECT capabilities_json FROM roles WHERE key = ?', [key]);
return row ? parseJson(row.capabilities_json, []) : [];
}
function roleHasCapability(key, capability) {
return capabilitySetIncludes(capabilitiesForRole(key), capability);
}
function createRole(body, userId) {
const name = String(body.name || '').trim();
if (!name) throw Object.assign(new Error('A role name is required'), { status: 400 });
const key = slugify(body.key || name);
if (!key) throw Object.assign(new Error('A valid role key is required'), { status: 400 });
if (roleExists(key)) throw Object.assign(new Error('A role with that key already exists'), { status: 400 });
const capabilities = sanitizeCapabilities(body.capabilities);
const ts = now();
const sort = (one('SELECT MAX(sort_order) AS m FROM roles').m || 0) + 1;
run(
'INSERT INTO roles (key, name, description, capabilities_json, is_system, locked, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?)',
[key, name, body.description || null, json(capabilities), sort, ts, ts]
);
audit(userId, 'create', 'role', key, null, { name, capabilities });
return getRole(key);
}
function updateRole(key, body, userId) {
const before = one('SELECT * FROM roles WHERE key = ?', [key]);
if (!before) return null;
const name = body.name !== undefined ? String(body.name).trim() || before.name : before.name;
// Locked roles (admin) always keep the wildcard capability set.
const capabilities = before.locked
? parseJson(before.capabilities_json, [WILDCARD])
: (body.capabilities !== undefined ? sanitizeCapabilities(body.capabilities) : parseJson(before.capabilities_json, []));
run(
'UPDATE roles SET name = ?, description = ?, capabilities_json = ?, updated_at = ? WHERE key = ?',
[name, body.description ?? before.description, json(capabilities), now(), key]
);
audit(userId, 'update', 'role', key, fromRow(before), { name, capabilities });
return getRole(key);
}
function deleteRole(key, userId) {
const before = one('SELECT * FROM roles WHERE key = ?', [key]);
if (!before) return { ok: false, status: 404, error: 'Role not found' };
if (before.is_system) return { ok: false, status: 400, error: 'Built-in roles cannot be deleted' };
const inUse = one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [key]).c;
if (inUse) return { ok: false, status: 400, error: `Reassign the ${inUse} user(s) with this role before deleting it` };
run('DELETE FROM roles WHERE key = ?', [key]);
audit(userId, 'delete', 'role', key, fromRow(before), null);
return { ok: true };
}
module.exports = {
CAPABILITY_KEYS,
capabilitiesForRole,
createRole,
deleteRole,
getRole,
listRoles,
roleExists,
roleHasCapability,
updateRole
};

View File

@@ -0,0 +1,25 @@
const { one, parseJson } = require('../db');
function activeRuleSet() {
const row = one('SELECT rules_json FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1');
return row ? parseJson(row.rules_json, {}) : {};
}
// Resolve the depreciation rule set a book should use: its explicitly assigned
// rule set when present, otherwise the globally active set.
function ruleSetForBook(bookCode) {
if (bookCode) {
try {
const book = one('SELECT tax_rule_set_id FROM books WHERE code = ?', [bookCode]);
if (book && book.tax_rule_set_id) {
const row = one('SELECT rules_json FROM tax_rule_sets WHERE id = ?', [book.tax_rule_set_id]);
if (row) return parseJson(row.rules_json, {});
}
} catch {
// books table may not exist on a very old database; fall through to active set
}
}
return activeRuleSet();
}
module.exports = { activeRuleSet, ruleSetForBook };

View File

@@ -0,0 +1,337 @@
const { all, audit, now, one, run } = require('../db');
const { createAsset, updateAsset } = require('./assets');
const TIMEOUT_MS = 20000;
// Default CMDB CI field -> MixedAssets asset field mapping. Values are ServiceNow
// column names on the configured CI class; requested with display values so that
// reference fields (manufacturer, location, model) arrive as readable strings.
const DEFAULT_CMDB_MAP = {
asset_id: 'asset_tag',
description: 'name',
serial_number: 'serial_number',
category: 'sys_class_name',
vendor: 'manufacturer',
location: 'location',
department: 'department',
custodian: 'assigned_to',
acquisition_cost: 'cost',
acquired_date: 'purchase_date',
notes: 'short_description'
};
function readSettings() {
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'servicenow_%'");
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
function parseMap(value) {
if (!value) return { ...DEFAULT_CMDB_MAP };
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
return parsed && typeof parsed === 'object' && Object.keys(parsed).length ? parsed : { ...DEFAULT_CMDB_MAP };
} catch {
return { ...DEFAULT_CMDB_MAP };
}
}
// Raw config (includes password) — for making API calls only.
function getConfig() {
const s = readSettings();
return {
instanceUrl: (s.servicenow_instance_url || '').replace(/\/+$/, ''),
username: s.servicenow_username || '',
password: s.servicenow_password || '',
incidentTable: s.servicenow_incident_table || 'incident',
assignmentGroup: s.servicenow_assignment_group || '',
callerId: s.servicenow_caller_id || '',
ticketEnabled: s.servicenow_ticket_enabled === 'true',
autoTicket: s.servicenow_auto_ticket === 'true',
cmdbTable: s.servicenow_cmdb_table || 'cmdb_ci_hardware',
cmdbQuery: s.servicenow_cmdb_query || '',
cmdbLimit: Number(s.servicenow_cmdb_limit || 200),
cmdbMap: parseMap(s.servicenow_cmdb_map),
lastSyncAt: s.servicenow_last_sync_at || '',
lastSyncStatus: s.servicenow_last_sync_status || ''
};
}
// Safe config for the API/UI — password is never returned, only whether it is set.
function publicConfig() {
const c = getConfig();
return {
servicenow_instance_url: c.instanceUrl,
servicenow_username: c.username,
servicenow_password_set: Boolean(c.password),
servicenow_incident_table: c.incidentTable,
servicenow_assignment_group: c.assignmentGroup,
servicenow_caller_id: c.callerId,
servicenow_ticket_enabled: c.ticketEnabled,
servicenow_auto_ticket: c.autoTicket,
servicenow_cmdb_table: c.cmdbTable,
servicenow_cmdb_query: c.cmdbQuery,
servicenow_cmdb_limit: c.cmdbLimit,
servicenow_cmdb_map: c.cmdbMap,
servicenow_last_sync_at: c.lastSyncAt,
servicenow_last_sync_status: c.lastSyncStatus,
configured: Boolean(c.instanceUrl && c.username)
};
}
function setValue(key, value) {
run(
'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
[key, String(value == null ? '' : value), now()]
);
}
function saveConfig(body, userId) {
if (body.servicenow_instance_url !== undefined) setValue('servicenow_instance_url', (body.servicenow_instance_url || '').replace(/\/+$/, ''));
if (body.servicenow_username !== undefined) setValue('servicenow_username', body.servicenow_username || '');
if (body.servicenow_password_clear) setValue('servicenow_password', '');
else if (body.servicenow_password) setValue('servicenow_password', body.servicenow_password);
if (body.servicenow_incident_table !== undefined) setValue('servicenow_incident_table', body.servicenow_incident_table || 'incident');
if (body.servicenow_assignment_group !== undefined) setValue('servicenow_assignment_group', body.servicenow_assignment_group || '');
if (body.servicenow_caller_id !== undefined) setValue('servicenow_caller_id', body.servicenow_caller_id || '');
if (body.servicenow_ticket_enabled !== undefined) setValue('servicenow_ticket_enabled', body.servicenow_ticket_enabled ? 'true' : 'false');
if (body.servicenow_auto_ticket !== undefined) setValue('servicenow_auto_ticket', body.servicenow_auto_ticket ? 'true' : 'false');
if (body.servicenow_cmdb_table !== undefined) setValue('servicenow_cmdb_table', body.servicenow_cmdb_table || 'cmdb_ci_hardware');
if (body.servicenow_cmdb_query !== undefined) setValue('servicenow_cmdb_query', body.servicenow_cmdb_query || '');
if (body.servicenow_cmdb_limit !== undefined) setValue('servicenow_cmdb_limit', Number(body.servicenow_cmdb_limit) || 200);
if (body.servicenow_cmdb_map !== undefined) {
const value = typeof body.servicenow_cmdb_map === 'string' ? body.servicenow_cmdb_map : JSON.stringify(body.servicenow_cmdb_map || {});
setValue('servicenow_cmdb_map', value.trim() ? value : '');
}
audit(userId, 'update', 'servicenow_settings', null, null, { ...body, servicenow_password: body.servicenow_password ? '[set]' : undefined });
return publicConfig();
}
function ensureConfigured(c) {
if (!c.instanceUrl || !c.username) {
throw Object.assign(new Error('ServiceNow instance URL and username are required'), { status: 400 });
}
}
function authHeader(c) {
return `Basic ${Buffer.from(`${c.username}:${c.password}`).toString('base64')}`;
}
async function snFetch(c, pathAndQuery, options = {}) {
const url = `${c.instanceUrl}${pathAndQuery}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let response;
try {
response = await fetch(url, {
...options,
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authHeader(c), ...(options.headers || {}) },
signal: controller.signal
});
} catch (error) {
throw Object.assign(new Error(`ServiceNow request failed: ${error.message}`), { status: 502 });
} finally {
clearTimeout(timer);
}
const text = await response.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
if (!response.ok) {
const detail = body?.error?.message || body?.error?.detail || `HTTP ${response.status}`;
throw Object.assign(new Error(`ServiceNow: ${detail}`), { status: response.status === 401 ? 400 : 502 });
}
return body;
}
async function testConnection(userId) {
const c = getConfig();
ensureConfigured(c);
await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}?sysparm_limit=1&sysparm_fields=sys_id`, { method: 'GET' });
audit(userId, 'test', 'servicenow', null, null, { instance: c.instanceUrl });
return { ok: true, instance: c.instanceUrl };
}
// MixedAssets severity -> ServiceNow impact/urgency (1 high … 3 low).
function severityToImpactUrgency(severity) {
if (severity === 'critical') return { impact: '1', urgency: '1' };
if (severity === 'warning') return { impact: '2', urgency: '2' };
return { impact: '3', urgency: '3' };
}
function incidentUrl(c, sysId) {
return `${c.instanceUrl}/nav_to.do?uri=${encodeURIComponent(`/${c.incidentTable}.do?sys_id=${sysId}`)}`;
}
async function createIncidentForAlert(alert, userId, c = getConfig()) {
ensureConfigured(c);
const { impact, urgency } = severityToImpactUrgency(alert.severity);
const descriptionLines = [
alert.message || '',
'',
`MixedAssets alert #${alert.id} (${alert.type})`,
alert.asset_code ? `Asset: ${alert.asset_code}` : null,
alert.due_date ? `Due: ${alert.due_date}` : null
].filter((line) => line !== null);
const payload = {
short_description: alert.title,
description: descriptionLines.join('\n'),
impact,
urgency
};
if (c.assignmentGroup) payload.assignment_group = c.assignmentGroup;
if (c.callerId) payload.caller_id = c.callerId;
const data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}`, { method: 'POST', body: JSON.stringify(payload) });
const result = data?.result || {};
const url = result.sys_id ? incidentUrl(c, result.sys_id) : null;
run(
'UPDATE alerts SET external_ticket_id = ?, external_ticket_number = ?, external_ticket_url = ?, external_ticket_system = ?, updated_at = ? WHERE id = ?',
[result.sys_id || null, result.number || null, url, 'servicenow', now(), alert.id]
);
audit(userId, 'ticket', 'alert', alert.id, null, { system: 'servicenow', number: result.number });
return { sys_id: result.sys_id, number: result.number, url };
}
function alertWithCode(alertId) {
return one(
`SELECT al.*, a.asset_id AS asset_code FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id WHERE al.id = ?`,
[alertId]
);
}
// Manually submit a ServiceNow incident for one alert (returns existing ticket if already raised).
async function submitTicket(alertId, userId) {
const c = getConfig();
ensureConfigured(c);
const alert = alertWithCode(alertId);
if (!alert) return null;
if (alert.external_ticket_id) {
return { sys_id: alert.external_ticket_id, number: alert.external_ticket_number, url: alert.external_ticket_url, duplicate: true };
}
return createIncidentForAlert(alert, userId, c);
}
// Auto-create incidents for new critical alerts during the alert cycle (best-effort).
async function autoTicketAlerts(alerts, userId) {
const c = getConfig();
if (!c.ticketEnabled || !c.autoTicket || !c.instanceUrl) return { created: 0 };
let created = 0;
for (const alert of alerts) {
if (alert.severity !== 'critical' || alert.external_ticket_id) continue;
try {
await createIncidentForAlert(alert, userId, c);
created += 1;
} catch {
// best-effort: a failed ticket never breaks the alert cycle
}
}
return { created };
}
// ---- CMDB pull -------------------------------------------------------------
function parseCost(value) {
if (value == null || value === '') return undefined;
const num = Number(String(value).replace(/[^0-9.\-]/g, ''));
return Number.isFinite(num) ? num : undefined;
}
function parseDate(value) {
if (!value) return undefined;
const match = String(value).match(/\d{4}-\d{2}-\d{2}/);
return match ? match[0] : undefined;
}
function mapCi(ci, map) {
const asset = {};
for (const [assetField, ciField] of Object.entries(map)) {
if (!ciField) continue;
let value = ci[ciField];
if (value == null || value === '') continue;
value = String(value).trim();
if (!value) continue;
if (assetField === 'acquisition_cost') {
const cost = parseCost(value);
if (cost !== undefined) asset.acquisition_cost = cost;
} else if (assetField === 'acquired_date' || assetField === 'in_service_date' || assetField === 'disposal_date') {
const date = parseDate(value);
if (date) asset[assetField] = date;
} else {
asset[assetField] = value;
}
}
return asset;
}
function findExistingAsset(sysId, mapped) {
if (sysId) {
const bySysId = one('SELECT id FROM assets WHERE servicenow_sys_id = ?', [sysId]);
if (bySysId) return bySysId.id;
}
if (mapped.serial_number) {
const bySerial = one('SELECT id FROM assets WHERE serial_number = ? COLLATE NOCASE', [mapped.serial_number]);
if (bySerial) return bySerial.id;
}
if (mapped.asset_id) {
const byCode = one('SELECT id FROM assets WHERE asset_id = ? COLLATE NOCASE', [mapped.asset_id]);
if (byCode) return byCode.id;
}
return null;
}
async function syncCmdb(userId) {
const c = getConfig();
ensureConfigured(c);
const fields = [...new Set(['sys_id', ...Object.values(c.cmdbMap).filter(Boolean)])];
const params = new URLSearchParams({
sysparm_limit: String(c.cmdbLimit || 200),
sysparm_display_value: 'true',
sysparm_exclude_reference_link: 'true',
sysparm_fields: fields.join(',')
});
if (c.cmdbQuery) params.set('sysparm_query', c.cmdbQuery);
let data;
try {
data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.cmdbTable)}?${params.toString()}`, { method: 'GET' });
} catch (error) {
setValue('servicenow_last_sync_at', now());
setValue('servicenow_last_sync_status', `Failed: ${error.message}`);
throw error;
}
const cis = data?.result || [];
let created = 0;
let updated = 0;
let skipped = 0;
for (const ci of cis) {
const sysId = ci.sys_id || null;
const mapped = mapCi(ci, c.cmdbMap);
if (!mapped.description && !mapped.asset_id && !mapped.serial_number) { skipped += 1; continue; }
const existingId = findExistingAsset(sysId, mapped);
if (existingId) {
updateAsset(existingId, mapped, userId);
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, existingId]);
updated += 1;
} else {
const asset = createAsset({ ...mapped, source: 'servicenow' }, userId);
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, asset.id]);
created += 1;
}
}
const status = `Synced ${cis.length} CI(s): ${created} created, ${updated} updated${skipped ? `, ${skipped} skipped` : ''}.`;
setValue('servicenow_last_sync_at', now());
setValue('servicenow_last_sync_status', status);
audit(userId, 'cmdb_sync', 'servicenow', null, null, { table: c.cmdbTable, created, updated, skipped });
return { created, updated, skipped, total: cis.length, status };
}
module.exports = {
DEFAULT_CMDB_MAP,
autoTicketAlerts,
getConfig,
publicConfig,
saveConfig,
submitTicket,
syncCmdb,
testConnection
};

View File

@@ -0,0 +1,88 @@
const crypto = require('crypto');
const { audit } = require('../db');
const { getConfig } = require('./notifications');
const TIMEOUT_MS = 10000;
// The JSON object delivered for each alert. Stable, flat shape for easy consumption.
function buildPayload(alert) {
return {
event: 'alert',
id: alert.id,
type: alert.type,
severity: alert.severity,
title: alert.title,
message: alert.message || null,
due_date: alert.due_date || null,
status: alert.status,
reference_type: alert.reference_type,
reference_id: alert.reference_id,
asset_id: alert.asset_id || null,
asset_code: alert.asset_code || null,
created_at: alert.created_at,
sent_at: new Date().toISOString()
};
}
// POST a single JSON object. When a secret is set, sign the raw body with HMAC-SHA256
// so the receiver can verify authenticity via the X-MixedAssets-Signature header.
async function postJson(url, secret, payload) {
const body = JSON.stringify(payload);
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'MixedAssets-Webhook/1' };
if (secret) {
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
headers['X-MixedAssets-Signature'] = `sha256=${signature}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
return { ok: response.ok, status: response.status };
} finally {
clearTimeout(timer);
}
}
// Deliver each alert as its own JSON POST. Best-effort: a failed delivery is counted
// but never throws, so one bad endpoint can't break the alert cycle.
async function deliverAlerts(alerts) {
const c = getConfig();
if (!c.webhookEnabled || !c.webhookUrl || !alerts.length) {
return { attempted: false, delivered: 0, failed: 0 };
}
let delivered = 0;
let failed = 0;
for (const alert of alerts) {
try {
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
if (result.ok) delivered += 1;
else failed += 1;
} catch {
failed += 1;
}
}
return { attempted: true, delivered, failed };
}
async function sendTestWebhook(userId) {
const c = getConfig();
if (!c.webhookUrl) throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 });
const payload = {
event: 'test',
severity: 'info',
title: 'MixedAssets webhook test',
message: 'If you received this, your webhook destination is configured correctly.',
sent_at: new Date().toISOString()
};
let result;
try {
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
} catch (error) {
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
}
audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status });
if (!result.ok) throw Object.assign(new Error(`Webhook endpoint returned HTTP ${result.status}`), { status: 400 });
return { ok: true, status: result.status };
}
module.exports = { buildPayload, deliverAlerts, sendTestWebhook };

View File

@@ -1,5 +1,6 @@
const { audit, json, now, one, run } = require('../db');
const { upsertEmployee } = require('./assignments');
const { upsertWorkdayContact } = require('./contacts');
function publicConnection(row) {
if (!row) return null;
@@ -12,12 +13,21 @@ function publicConnection(row) {
client_id: row.client_id,
workers_path: row.workers_path,
enabled: Boolean(row.enabled),
sync_enabled: Boolean(row.sync_enabled),
sync_interval_hours: row.sync_interval_hours || 24,
last_sync_at: row.last_sync_at,
last_sync_status: row.last_sync_status,
configured: Boolean(row.base_url && row.token_url && row.client_id && row.client_secret)
};
}
function splitName(full) {
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return { first: null, last: null };
if (parts.length === 1) return { first: parts[0], last: null };
return { first: parts[0], last: parts.slice(1).join(' ') };
}
function getConnection() {
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
}
@@ -37,23 +47,25 @@ function saveConnection(body, userId) {
client_id: body.client_id ?? existing?.client_id ?? '',
client_secret: body.client_secret ? body.client_secret : existing?.client_secret ?? '',
workers_path: body.workers_path ?? existing?.workers_path ?? '/workers',
enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0)
enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0),
sync_enabled: body.sync_enabled === undefined ? Number(existing?.sync_enabled || 0) : (body.sync_enabled ? 1 : 0),
sync_interval_hours: body.sync_interval_hours === undefined ? (existing?.sync_interval_hours || 24) : Math.max(1, Number(body.sync_interval_hours) || 24)
};
if (existing) {
run(
`UPDATE workday_connections SET
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
workers_path = ?, enabled = ?, updated_at = ?
workers_path = ?, enabled = ?, sync_enabled = ?, sync_interval_hours = ?, updated_at = ?
WHERE id = ?`,
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, timestamp, existing.id]
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, existing.id]
);
} else {
run(
`INSERT INTO workday_connections (
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, timestamp, timestamp]
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, sync_enabled, sync_interval_hours, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, timestamp]
);
}
@@ -85,19 +97,31 @@ function normalizeWorkers(payload) {
? payload
: payload.workers || payload.Workers || payload.data || payload.value || [];
return rows.map((worker) => ({
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
name: worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' '),
email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email,
department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor,
location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor,
manager_name: worker.managerName || worker.manager?.descriptor,
status: worker.status || (worker.active === false ? 'inactive' : 'active'),
source: 'workday',
source_payload: worker,
last_synced_at: now()
}));
return rows.map((worker) => {
const name = worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' ');
const fallbackName = splitName(name);
const managerName = worker.managerName || worker.manager?.descriptor;
const managerSplit = splitName(managerName);
return {
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
name,
first_name: worker.firstName || worker.legalFirstName || worker.First_Name || fallbackName.first,
last_name: worker.lastName || worker.legalLastName || worker.Last_Name || fallbackName.last,
email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email,
department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor,
location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor,
manager_name: managerName,
manager_first_name: worker.manager?.firstName || managerSplit.first,
manager_last_name: worker.manager?.lastName || managerSplit.last,
manager_email: worker.managerEmail || worker.manager?.email,
start_date: worker.hireDate || worker.startDate || worker.Hire_Date || worker.continuousServiceDate,
status: worker.status || (worker.active === false ? 'inactive' : 'active'),
source: 'workday',
source_payload: worker,
last_synced_at: now()
};
});
}
async function syncWorkers(userId) {
@@ -120,7 +144,10 @@ async function syncWorkers(userId) {
}
const workers = normalizeWorkers(await response.json());
for (const worker of workers) upsertEmployee(worker);
for (const worker of workers) {
upsertEmployee(worker);
upsertWorkdayContact(worker);
}
const timestamp = now();
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
@@ -135,14 +162,30 @@ async function syncWorkers(userId) {
function importWorkersFromPayload(workers, userId) {
const normalized = normalizeWorkers(workers);
for (const worker of normalized) upsertEmployee(worker);
for (const worker of normalized) {
upsertEmployee(worker);
upsertWorkdayContact(worker);
}
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
return { imported: normalized.length };
}
// Run an automatic sync if it's enabled, configured, and the interval has elapsed.
async function runScheduledSync() {
const connection = getPrivateConnection();
if (!connection || !connection.sync_enabled || !connection.enabled) return { skipped: true };
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) return { skipped: true };
const intervalMs = (Number(connection.sync_interval_hours) || 24) * 3600000;
if (connection.last_sync_at && Date.now() - new Date(connection.last_sync_at).getTime() < intervalMs) {
return { skipped: true };
}
return syncWorkers(null);
}
module.exports = {
getConnection,
importWorkersFromPayload,
runScheduledSync,
saveConnection,
syncWorkers
};

114
server/services/zones.js Normal file
View File

@@ -0,0 +1,114 @@
const { all, audit, now, one, run } = require('../db');
let cache = null;
function clearCache() {
cache = null;
}
function lookup() {
if (!cache) {
cache = {};
for (const row of all('SELECT * FROM depreciation_zones')) cache[row.code] = row;
}
return cache;
}
// Raw row (incl. dates) for the engine; null when unknown or disabled.
function getZone(code) {
if (!code) return null;
const zone = lookup()[code];
return zone && zone.enabled ? zone : null;
}
function slugify(value) {
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
}
function fromRow(row) {
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
allowance_percent: row.allowance_percent,
pis_start: row.pis_start,
pis_end: row.pis_end,
real_property_end: row.real_property_end,
max_recovery_years: row.max_recovery_years,
leasehold_life_years: row.leasehold_life_years,
notes: row.notes,
enabled: Boolean(row.enabled),
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c
};
}
function list() {
return all('SELECT * FROM depreciation_zones ORDER BY name').map(fromRow);
}
function normalize(body) {
const num = (v) => (v === '' || v === null || v === undefined ? null : Number(v));
return {
name: String(body.name || '').trim(),
allowance_percent: Number(body.allowance_percent) || 0,
pis_start: body.pis_start || null,
pis_end: body.pis_end || null,
real_property_end: body.real_property_end || null,
max_recovery_years: num(body.max_recovery_years),
leasehold_life_years: num(body.leasehold_life_years),
notes: body.notes || null,
enabled: body.enabled === false ? 0 : 1
};
}
function createZone(body, userId) {
const input = normalize(body);
if (!input.name) throw Object.assign(new Error('A zone name is required'), { status: 400 });
const code = slugify(body.code || input.name);
if (!code) throw Object.assign(new Error('A valid zone code is required'), { status: 400 });
if (one('SELECT id FROM depreciation_zones WHERE code = ?', [code])) {
throw Object.assign(new Error('A zone with that code already exists'), { status: 400 });
}
const ts = now();
run(
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, notes, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.notes, input.enabled, ts, ts]
);
clearCache();
audit(userId, 'create', 'depreciation_zone', code, null, input);
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
}
function updateZone(code, body, userId) {
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
if (!before) return null;
const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled });
run(
`UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?,
max_recovery_years = ?, leasehold_life_years = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`,
[input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.notes, input.enabled, now(), code]
);
clearCache();
audit(userId, 'update', 'depreciation_zone', code, before, input);
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
}
function deleteZone(code, userId) {
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
if (!before) return false;
run('DELETE FROM depreciation_zones WHERE code = ?', [code]);
clearCache();
audit(userId, 'delete', 'depreciation_zone', code, before, null);
return true;
}
module.exports = {
clearCache,
createZone,
deleteZone,
getZone,
list,
updateZone
};

File diff suppressed because it is too large Load Diff