This commit is contained in:
2026-06-03 13:58:12 -05:00
commit 2f13b8c590
54 changed files with 5136 additions and 0 deletions

65
server/app.js Normal file
View File

@@ -0,0 +1,65 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const { DB_PATH, initialize } = require('./db');
const { createCorsMiddleware } = require('./middleware/cors');
const { requireAuth } = require('./middleware/auth');
const adminRoutes = require('./routes/admin');
const assetRoutes = require('./routes/assets');
const assignmentRoutes = require('./routes/assignments');
const authRoutes = require('./routes/auth');
const dashboardRoutes = require('./routes/dashboard');
const dataPortabilityRoutes = require('./routes/dataPortability');
const profileRoutes = require('./routes/profile');
const referenceRoutes = require('./routes/reference');
const reportRoutes = require('./routes/reports');
const taxRuleRoutes = require('./routes/taxRules');
const templateRoutes = require('./routes/templates');
const workdayRoutes = require('./routes/workday');
function createApp() {
initialize();
const app = express();
app.use(createCorsMiddleware());
app.use(express.json({ limit: '12mb' }));
app.get('/api/health', (req, res) => {
res.json({ ok: true, app: 'MixedAssets', database: DB_PATH });
});
app.use('/api', authRoutes);
app.use('/api', requireAuth);
app.use('/api', profileRoutes);
app.use('/api', dashboardRoutes);
app.use('/api', referenceRoutes);
app.use('/api', assetRoutes);
app.use('/api', assignmentRoutes);
app.use('/api', templateRoutes);
app.use('/api', taxRuleRoutes);
app.use('/api', reportRoutes);
app.use('/api', dataPortabilityRoutes);
app.use('/api', adminRoutes);
app.use('/api', workdayRoutes);
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
app.get(/.*/, (req, res, next) => {
if (req.path.startsWith('/api')) return next();
return res.sendFile(path.join(distPath, 'index.html'));
});
app.use((error, req, res, next) => {
if (res.headersSent) return next(error);
const status = error.status || 500;
return res.status(status).json({ error: error.message || 'MixedAssets server error' });
});
return app;
}
module.exports = {
createApp
};

492
server/db.js Normal file
View File

@@ -0,0 +1,492 @@
const fs = require('fs');
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const bcrypt = require('bcryptjs');
const { expandRuleSet } = require('./rule-catalog');
const DATA_DIR = process.env.MIXEDASSETS_DATA_DIR || path.join(process.cwd(), 'data');
const DB_PATH = process.env.MIXEDASSETS_DB_PATH || path.join(DATA_DIR, 'mixedassets.sqlite');
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec('PRAGMA foreign_keys = ON;');
db.exec('PRAGMA journal_mode = WAL;');
function now() {
return new Date().toISOString();
}
function json(value, fallback = null) {
if (value === undefined) return fallback === null ? null : JSON.stringify(fallback);
return JSON.stringify(value);
}
function parseJson(value, fallback = null) {
if (value === null || value === undefined || value === '') return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function bind(statement, params) {
if (Array.isArray(params)) return statement;
return statement;
}
function args(params) {
if (Array.isArray(params)) return params;
if (params && Object.keys(params).length > 0) return [params];
return [];
}
function one(sql, params = {}) {
const statement = bind(db.prepare(sql), params);
return statement.get(...args(params));
}
function all(sql, params = {}) {
const statement = bind(db.prepare(sql), params);
return statement.all(...args(params));
}
function run(sql, params = {}) {
const statement = bind(db.prepare(sql), params);
return statement.run(...args(params));
}
function tx(fn) {
db.exec('BEGIN IMMEDIATE');
try {
const result = fn();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function initialize() {
db.exec(`
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
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 CHECK(role IN ('admin','finance','operations','viewer')),
status TEXT NOT NULL DEFAULT 'active',
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,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
legal_name TEXT,
tax_id TEXT,
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
base_currency TEXT NOT NULL DEFAULT 'USD',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reference_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
code TEXT,
metadata TEXT,
UNIQUE(type, name)
);
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workday_worker_id TEXT UNIQUE,
employee_number TEXT,
name TEXT NOT NULL,
email TEXT,
department TEXT,
location TEXT,
manager_name TEXT,
status TEXT NOT NULL DEFAULT 'active',
source TEXT NOT NULL DEFAULT 'manual',
source_payload TEXT,
last_synced_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id TEXT NOT NULL UNIQUE,
entity_id INTEGER REFERENCES entities(id),
template_id INTEGER,
description TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_service',
acquisition_cost REAL NOT NULL DEFAULT 0,
acquired_date TEXT,
in_service_date TEXT,
useful_life_months INTEGER NOT NULL DEFAULT 60,
salvage_value REAL NOT NULL DEFAULT 0,
land_value REAL NOT NULL DEFAULT 0,
prior_depreciation REAL NOT NULL DEFAULT 0,
location TEXT,
department TEXT,
group_name TEXT,
custodian TEXT,
vendor TEXT,
serial_number TEXT,
invoice_number TEXT,
gl_asset_account TEXT,
gl_expense_account TEXT,
gl_accumulated_account TEXT,
barcode_value TEXT,
condition TEXT,
new_or_used TEXT DEFAULT 'new',
listed_property INTEGER NOT NULL DEFAULT 0,
business_use_percent REAL NOT NULL DEFAULT 100,
investment_use_percent REAL NOT NULL DEFAULT 0,
disposal_date TEXT,
disposal_price REAL,
disposal_expense REAL,
disposal_type TEXT,
property_type TEXT,
notes TEXT,
custom_fields TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
book_type TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
cost REAL,
depreciation_method TEXT NOT NULL DEFAULT 'straight_line',
convention TEXT NOT NULL DEFAULT 'half_year',
useful_life_months INTEGER,
section_179_amount REAL NOT NULL DEFAULT 0,
bonus_percent REAL NOT NULL DEFAULT 0,
business_use_percent REAL,
investment_use_percent REAL,
manual_depreciation TEXT,
UNIQUE(asset_id, book_type)
);
CREATE TABLE IF NOT EXISTS asset_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
defaults TEXT NOT NULL,
custom_fields TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tax_rule_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jurisdiction TEXT NOT NULL,
name TEXT NOT NULL,
effective_date TEXT NOT NULL,
version TEXT NOT NULL,
rules_json TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
source_note TEXT,
created_at TEXT NOT NULL,
UNIQUE(jurisdiction, version)
);
CREATE TABLE IF NOT EXISTS leases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
lessor TEXT,
contract_value REAL NOT NULL DEFAULT 0,
start_date TEXT,
end_date TEXT,
discount_rate REAL NOT NULL DEFAULT 0,
payment_frequency TEXT NOT NULL DEFAULT 'monthly',
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS warranties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
provider TEXT,
phone TEXT,
start_date TEXT,
expiration_date TEXT,
warranty_limit TEXT,
actual_usage TEXT,
coverage_details TEXT,
document_name TEXT,
document_base64 TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
content_base64 TEXT NOT NULL,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
title TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'maintenance',
status TEXT NOT NULL DEFAULT 'open',
due_date TEXT,
assigned_to INTEGER REFERENCES users(id),
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS checkouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
checked_out_to TEXT NOT NULL,
destination TEXT,
due_date TEXT,
checked_out_at TEXT NOT NULL,
checked_in_at TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS asset_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
employee_id INTEGER REFERENCES employees(id),
department TEXT,
location TEXT,
status TEXT NOT NULL DEFAULT 'assigned',
assigned_at TEXT NOT NULL,
released_at TEXT,
assigned_by INTEGER REFERENCES users(id),
release_reason TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workday_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
tenant TEXT,
base_url TEXT,
token_url TEXT,
client_id TEXT,
client_secret TEXT,
workers_path TEXT NOT NULL DEFAULT '/workers',
enabled INTEGER NOT NULL DEFAULT 0,
last_sync_at TEXT,
last_sync_status TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS saved_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
report_type TEXT NOT NULL,
options_json TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value 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),
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
before_json TEXT,
after_json TEXT,
created_at TEXT NOT NULL
);
`);
seed();
}
function seed() {
const createdAt = now();
if (!one('SELECT id FROM teams LIMIT 1')) {
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
'Finance Operations',
'Default MixedAssets team',
createdAt
]);
}
const team = one('SELECT id FROM teams WHERE name = ?', ['Finance Operations']);
if (!one('SELECT id FROM users LIMIT 1')) {
run(
'INSERT INTO users (team_id, name, email, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[
team.id,
'MixedAssets Admin',
'admin@mixedassets.local',
bcrypt.hashSync(process.env.MIXEDASSETS_ADMIN_PASSWORD || 'ChangeMe123!', 12),
'admin',
createdAt,
createdAt
]
);
}
if (!one('SELECT id FROM entities LIMIT 1')) {
run(
'INSERT INTO entities (name, legal_name, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
['Demo Company', 'Demo Company LLC', 12, 'USD', createdAt, createdAt]
);
}
const refs = [
['location', 'Headquarters', 'HQ'],
['location', 'Warehouse', 'WH'],
['department', 'Accounting', 'ACCT'],
['department', 'Operations', 'OPS'],
['category', 'Computer Equipment', 'COMP'],
['category', 'Office Furniture', 'FURN'],
['category', 'Vehicles', 'AUTO'],
['category', 'Leasehold Improvements', 'LHI']
];
for (const item of refs) {
run('INSERT OR IGNORE INTO reference_values (type, name, code, metadata) VALUES (?, ?, ?, ?)', [
item[0],
item[1],
item[2],
'{}'
]);
}
run(
`INSERT OR IGNORE INTO employees (
employee_number, name, email, department, location, manager_name, status, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
['E-1001', 'Jordan Avery', 'jordan.avery@example.com', 'Operations', 'Headquarters', 'MixedAssets Admin', 'active', 'seed', createdAt, createdAt]
);
run(
`INSERT OR IGNORE INTO workday_connections (
name, tenant, base_url, token_url, workers_path, enabled, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
['Primary Workday', '', '', '', '/workers', 0, createdAt, createdAt]
);
const starterTemplate = {
category: 'Computer Equipment',
useful_life_months: 60,
depreciation_method: 'macrs_gds_200db_5',
convention: 'half_year',
gl_asset_account: '1500',
gl_expense_account: '6400',
gl_accumulated_account: '1590',
business_use_percent: 100
};
run(
'INSERT OR IGNORE INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[
'Laptop or workstation',
'Standard computer equipment template with 5-year tax life.',
json(starterTemplate),
json([
{ key: 'assigned_employee_id', label: 'Employee ID', type: 'text', required: false },
{ key: 'software_profile', label: 'Software profile', type: 'text', required: false }
]),
createdAt,
createdAt
]
);
const federalPath = path.join(process.cwd(), 'data', 'tax-rules', 'federal-2026.json');
if (fs.existsSync(federalPath)) {
const rules = expandRuleSet(JSON.parse(fs.readFileSync(federalPath, 'utf8')));
run(
`INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(jurisdiction, version) DO UPDATE SET
name = excluded.name,
effective_date = excluded.effective_date,
rules_json = excluded.rules_json,
active = excluded.active,
source_note = excluded.source_note`,
[
rules.jurisdiction || 'US-FED',
rules.name || 'Federal starter depreciation rules',
rules.effectiveDate || rules.effective_date || '2026-01-01',
rules.version || '2026.2',
json(rules),
1,
rules.sourceNote || 'Starter rule template. Confirm current tax law before production filing.',
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'
};
for (const [key, value] of Object.entries(settings)) {
run('INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)', [key, String(value), createdAt]);
}
}
function audit(actorId, action, entityType, entityId, beforeValue, afterValue) {
run(
'INSERT INTO audit_logs (actor_id, action, entity_type, entity_id, before_json, after_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[actorId || null, action, entityType, entityId === undefined ? null : String(entityId), json(beforeValue), json(afterValue), now()]
);
}
module.exports = {
DB_PATH,
all,
audit,
db,
initialize,
json,
now,
one,
parseJson,
run,
tx
};

208
server/depreciation.js Normal file
View File

@@ -0,0 +1,208 @@
const { parseJson } = require('./db');
function money(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}
function yearFromDate(value) {
if (!value) return new Date().getFullYear();
return new Date(`${value}T00:00:00`).getFullYear();
}
function monthFromDate(value) {
if (!value) return 1;
return new Date(`${value}T00:00:00`).getMonth() + 1;
}
function parseMaybeJson(value, fallback = null) {
if (typeof value === 'string') return parseJson(value, fallback);
return value ?? fallback;
}
function totalDepreciableBasis(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);
}
function section179Amount(asset, book) {
return Math.min(totalDepreciableBasis(asset, book), Number(book.section_179_amount || 0));
}
function bonusAmount(asset, book) {
const after179 = Math.max(0, totalDepreciableBasis(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));
}
function conventionFor(book, methodRule) {
return methodRule.defaultConvention || book.convention || 'none';
}
function yearFraction(index, convention, asset) {
if (index < 0) return 0;
const month = monthFromDate(asset.in_service_date || asset.acquired_date);
const quarter = Math.ceil(month / 3);
if (convention === 'none') return 1;
if (convention === 'full_month') {
const first = (13 - month) / 12;
if (index === 0) return first;
return null;
}
if (convention === 'mid_month') {
const first = (12 - month + 0.5) / 12;
if (index === 0) return first;
return null;
}
if (convention === 'mid_quarter') {
const firstByQuarter = { 1: 0.875, 2: 0.625, 3: 0.375, 4: 0.125 };
if (index === 0) return firstByQuarter[quarter] || 0.5;
return null;
}
if (convention === 'half_year') {
if (index === 0) return 0.5;
return null;
}
return 1;
}
function fractionForIndex(index, convention, asset, totalYears) {
const first = yearFraction(0, convention, asset);
const explicit = yearFraction(index, convention, asset);
if (explicit !== null) return explicit;
const finalIndex = Math.ceil(totalYears + (1 - first)) - 1;
if (index > finalIndex) return 0;
if (index === finalIndex) return Math.max(0, 1 - first);
return 1;
}
function straightLine(asset, book, index, totalYears, methodRule) {
const basis = depreciableBasis(asset, book);
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
}
function sumOfYearsDigits(asset, book, index, totalYears) {
const basis = depreciableBasis(asset, book);
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;
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 dbAmount = bookValue * (multiplier / totalYears) * fraction;
const remainingLife = Math.max(0.0001, totalYears - elapsed);
const slAmount = (bookValue / remainingLife) * fraction;
const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
const amount = Math.min(bookValue, selected);
if (i === index) return amount;
bookValue -= amount;
elapsed += fraction;
}
return 0;
}
function rateTable(asset, book, index, methodRule) {
const basis = depreciableBasis(asset, book);
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);
}
return basis * Number(rates[index] || 0);
}
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
};
}
function annualSchedule(asset, book, ruleSet, options = {}) {
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
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);
const startYear = Number(options.startYear || placedInServiceYear);
const endYear = Number(options.endYear || startYear + totalYears + 1);
const manual = parseMaybeJson(book.manual_depreciation, {});
const rows = [];
let accumulated = Number(asset.prior_depreciation || 0);
for (let year = placedInServiceYear; year <= endYear; year += 1) {
const index = year - placedInServiceYear;
let depreciation = 0;
if (index >= 0) {
if (index === 0) {
depreciation += section179Amount(asset, book) + bonusAmount(asset, book);
}
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);
} 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') {
depreciation += straightLine(asset, book, index, totalYears, { ...methodRule, defaultConvention: 'half_year' });
} else if (methodRule.formula === 'manual') {
depreciation = Number(manual[year] || 0);
} else {
depreciation += straightLine(asset, book, index, totalYears, methodRule);
}
}
const basis = totalDepreciableBasis(asset, book);
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, basis - accumulated)));
accumulated += depreciation;
if (year >= startYear) {
rows.push({
year,
book: book.book_type,
method: methodRule.code,
methodLabel: methodRule.label || methodRule.code,
depreciation: money(depreciation),
accumulated: money(accumulated),
netBookValue: money(Math.max(0, Number(book.cost ?? asset.acquisition_cost ?? 0) - accumulated))
});
}
}
return rows;
}
function monthlyFromAnnual(row) {
const monthly = money(row.depreciation / 12);
return Array.from({ length: 12 }, (_, index) => ({
month: index + 1,
year: row.year,
book: row.book,
depreciation: monthly
}));
}
module.exports = {
annualSchedule,
depreciableBasis,
totalDepreciableBasis,
getMethodRule,
monthlyFromAnnual,
money
};

8
server/index.js Normal file
View File

@@ -0,0 +1,8 @@
const { createApp } = require('./app');
const PORT = Number(process.env.PORT || process.env.MIXEDASSETS_PORT || 3000);
const app = createApp();
app.listen(PORT, () => {
console.log(`MixedAssets API listening on http://localhost:${PORT}`);
});

49
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,49 @@
const jwt = require('jsonwebtoken');
const { one } = require('../db');
const JWT_SECRET = process.env.MIXEDASSETS_JWT_SECRET || 'mixedassets-local-development-secret';
function publicUser(user) {
if (!user) return null;
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
teamId: user.team_id
};
}
function tokenFor(user) {
return jwt.sign({ sub: user.id, role: user.role, teamId: user.team_id }, JWT_SECRET, { expiresIn: '12h' });
}
function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const [, token] = header.split(' ');
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
const payload = jwt.verify(token, JWT_SECRET);
const user = one('SELECT * FROM users WHERE id = ? AND status = ?', [payload.sub, 'active']);
if (!user) return res.status(401).json({ error: 'User is inactive or missing' });
req.user = user;
return next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Insufficient access' });
return next();
};
}
module.exports = {
publicUser,
requireAuth,
requireRole,
tokenFor
};

31
server/middleware/cors.js Normal file
View File

@@ -0,0 +1,31 @@
const cors = require('cors');
const { one } = require('../db');
function isLocalDevOrigin(origin) {
try {
const url = new URL(origin);
return ['localhost', '127.0.0.1'].includes(url.hostname);
} catch {
return false;
}
}
function createCorsMiddleware() {
const persistedCors = one('SELECT value FROM app_settings WHERE key = ?', ['cors_allowed_domains'])?.value || '';
const allowedOrigins = (process.env.MIXEDASSETS_CORS_ORIGINS || persistedCors || 'http://localhost:5173,http://127.0.0.1:5173')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
return cors({
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin) || isLocalDevOrigin(origin)) return callback(null, true);
return callback(new Error('Origin is not allowed by MixedAssets CORS settings'));
},
credentials: true
});
}
module.exports = {
createCorsMiddleware
};

131
server/routes/admin.js Normal file
View File

@@ -0,0 +1,131 @@
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 router = express.Router();
const userSelect = `
SELECT u.id, u.team_id, t.name AS team_name, u.name, u.email, u.role, u.status, u.created_at, u.updated_at
FROM users u
LEFT JOIN teams t ON t.id = u.team_id
`;
function publicAdminUser(id) {
return one(`${userSelect} WHERE u.id = ?`, [id]);
}
function validateRole(role) {
if (!isValidRole(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
}
router.get('/settings', requireRole('admin'), (req, res) => {
const rows = all('SELECT * FROM app_settings ORDER BY key');
res.json({ settings: Object.fromEntries(rows.map((row) => [row.key, row.value])) });
});
router.put('/settings', requireRole('admin'), (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,
String(value),
now()
]);
}
audit(req.user.id, 'update', 'settings', 'app', null, req.body);
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) => {
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
});
router.post('/users', requireRole('admin'), (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();
const result = run(
'INSERT INTO users (team_id, name, email, password_hash, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
req.body.team_id || one('SELECT id FROM teams ORDER BY id LIMIT 1')?.id,
req.body.name,
req.body.email,
bcrypt.hashSync(req.body.password || 'ChangeMe123!', 12),
req.body.role || 'viewer',
req.body.status || 'active',
created,
created
]
);
audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
});
router.put('/users/:id', requireRole('admin'), (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;
validateRole(nextRole);
const nextStatus = req.body.status || before.status;
if (!['active', 'inactive'].includes(nextStatus)) return res.status(400).json({ error: 'Invalid status' });
const activeAdmins = one("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND status = 'active'").count;
if (before.role === 'admin' && before.status === 'active' && activeAdmins <= 1 && (nextRole !== 'admin' || nextStatus !== 'active')) {
return res.status(400).json({ error: 'At least one active admin is required' });
}
const updated = now();
run(
`UPDATE users SET
team_id = ?,
name = ?,
email = ?,
role = ?,
status = ?,
updated_at = ?
WHERE id = ?`,
[
req.body.team_id ?? before.team_id,
req.body.name || before.name,
req.body.email || before.email,
nextRole,
nextStatus,
updated,
req.params.id
]
);
if (req.body.password) {
run('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', [
bcrypt.hashSync(req.body.password, 12),
updated,
req.params.id
]);
}
const user = publicAdminUser(req.params.id);
audit(req.user.id, 'update', 'user', req.params.id, before, { ...user, password: req.body.password ? '[changed]' : undefined });
return res.json({ user });
});
router.get('/teams', requireRole('admin'), (req, res) => {
res.json({ teams: all('SELECT * FROM teams ORDER BY name') });
});
router.post('/teams', requireRole('admin'), (req, res) => {
const result = run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
req.body.name,
req.body.description || null,
now()
]);
audit(req.user.id, 'create', 'team', result.lastInsertRowid, null, req.body);
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 });
});
module.exports = router;

83
server/routes/assets.js Normal file
View File

@@ -0,0 +1,83 @@
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 {
createAsset,
deleteAsset,
hydrateAsset,
listAssets,
massUpdateAssets,
updateAsset
} = require('../services/assets');
const { activeRuleSet } = require('../services/reports');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
router.get('/assets', (req, res) => {
res.json({ assets: listAssets(req.query) });
});
router.post('/assets', requireRole('admin', 'finance', 'operations'), (req, res) => {
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
});
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) => {
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) => {
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) => {
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' });
return res.json({ updated });
});
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 }));
res.json({ assetId: asset.asset_id, schedules });
});
router.post('/assets/:id/files', upload.single('file'), requireRole('admin', 'finance', 'operations'), (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 (?, ?, ?, ?, ?, ?, ?)',
[req.params.id, req.file.originalname, req.file.mimetype, req.file.size, req.file.buffer.toString('base64'), req.user.id, now()]
);
audit(req.user.id, 'upload_file', 'asset', req.params.id, null, { file: req.file.originalname });
res.status(201).json({ file: one('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE id = ?', [result.lastInsertRowid]) });
});
router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), (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 (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[req.params.id, req.body.title, req.body.type || 'maintenance', req.body.status || 'open', req.body.due_date || null, req.body.assigned_to || null, req.body.notes || null, created, created]
);
audit(req.user.id, 'create', 'task', result.lastInsertRowid, null, req.body);
res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) });
});
module.exports = router;

View File

@@ -0,0 +1,47 @@
const express = require('express');
const { requireRole } = require('../middleware/auth');
const {
assignAsset,
assignmentRows,
assignmentSummary,
listEmployees,
releaseAssignment,
upsertEmployee
} = require('../services/assignments');
const router = express.Router();
router.get('/employees', (req, res) => {
res.json({ employees: listEmployees(req.query) });
});
router.post('/employees', requireRole('admin', 'finance', 'operations'), (req, res) => {
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) });
});
router.get('/assignments', (req, res) => {
res.json({
summary: assignmentSummary(),
assignments: assignmentRows({
asset_id: req.query.asset_id || null,
employee_id: req.query.employee_id || null,
active: req.query.active === 'true'
})
});
});
router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req, res, next) => {
try {
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/assignments/:id/release', requireRole('admin', 'finance', 'operations'), (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 });
});
module.exports = router;

26
server/routes/auth.js Normal file
View File

@@ -0,0 +1,26 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { audit, one } = require('../db');
const { publicUser, tokenFor } = require('../middleware/auth');
const router = express.Router();
router.get('/public/bootstrap', (req, res) => {
res.json({
appName: 'MixedAssets',
setupComplete: Boolean(one('SELECT id FROM users LIMIT 1')),
database: 'sqlite'
});
});
router.post('/auth/login', (req, res) => {
const { email, password } = req.body;
const user = one('SELECT * FROM users WHERE lower(email) = lower(?) AND status = ?', [email || '', 'active']);
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
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) });
});
module.exports = router;

View File

@@ -0,0 +1,29 @@
const express = require('express');
const { all, one } = require('../db');
const router = express.Router();
router.get('/dashboard', (req, res) => {
const stats = one(`
SELECT
COUNT(*) AS asset_count,
COALESCE(SUM(acquisition_cost), 0) AS total_cost,
COALESCE(SUM(CASE WHEN status = 'disposed' THEN 1 ELSE 0 END), 0) AS disposed_count,
COALESCE(SUM(CASE WHEN status = 'in_service' THEN 1 ELSE 0 END), 0) AS in_service_count
FROM assets
`);
const byCategory = all('SELECT category, COUNT(*) AS count, COALESCE(SUM(acquisition_cost), 0) AS value FROM assets GROUP BY category ORDER BY value DESC');
const byStatus = all('SELECT status, COUNT(*) AS count FROM assets GROUP BY status ORDER BY count DESC');
const upcomingTasks = all(`
SELECT t.*, a.asset_id, a.description
FROM tasks t
LEFT JOIN assets a ON a.id = t.asset_id
WHERE t.status != 'complete'
ORDER BY t.due_date IS NULL, t.due_date
LIMIT 8
`);
const auditTrail = all('SELECT al.*, u.name AS actor_name FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id ORDER BY al.id DESC LIMIT 10');
res.json({ stats, byCategory, byStatus, upcomingTasks, auditTrail });
});
module.exports = router;

View File

@@ -0,0 +1,27 @@
const express = require('express');
const multer = require('multer');
const { audit } = require('../db');
const { requireRole } = require('../middleware/auth');
const { upsertImportedAssets } = require('../services/assets');
const { assetExport, extensionForUpload, rowsFromImport } = require('../services/importExport');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
router.get('/export/assets', async (req, res) => {
const format = String(req.query.format || 'json').toLowerCase();
const output = await assetExport(format);
res.setHeader('Content-Type', output.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${output.filename}"`);
return res.send(output.body);
});
router.post('/import/assets', requireRole('admin', 'finance'), 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);
audit(req.user.id, 'import', 'asset', null, null, { file: req.file.originalname, imported });
res.json({ imported });
});
module.exports = router;

17
server/routes/profile.js Normal file
View File

@@ -0,0 +1,17 @@
const express = require('express');
const { audit } = require('../db');
const { getProfile, savePreferences } = require('../services/profile');
const router = express.Router();
router.get('/profile', (req, res) => {
res.json(getProfile(req.user));
});
router.put('/profile', (req, res) => {
const preferences = savePreferences(req.user.id, req.body.preferences || {});
audit(req.user.id, 'update', 'profile_preferences', req.user.id, null, { preferences });
res.json({ user: getProfile(req.user).user, preferences });
});
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const { all, audit, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/entities', (req, res) => {
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
});
router.post('/entities', requireRole('admin', 'finance'), (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 (?, ?, ?, ?, ?, ?, ?)',
[
req.body.name,
req.body.legal_name || null,
req.body.tax_id || null,
Number(req.body.fiscal_year_end_month || 12),
req.body.base_currency || 'USD',
created,
created
]
);
audit(req.user.id, 'create', 'entity', result.lastInsertRowid, null, req.body);
res.status(201).json({ entity: one('SELECT * FROM entities WHERE id = ?', [result.lastInsertRowid]) });
});
router.get('/references', (req, res) => {
const rows = all('SELECT * FROM reference_values ORDER BY type, name');
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
});
module.exports = router;

28
server/routes/reports.js Normal file
View File

@@ -0,0 +1,28 @@
const express = require('express');
const {
depreciationReport,
monthlyDepreciationReport,
writeDepreciationPdf
} = require('../services/reports');
const router = express.Router();
router.get('/reports/depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP';
res.json(depreciationReport(year, bookType));
});
router.get('/reports/monthly-depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP';
res.json(monthlyDepreciationReport(year, bookType));
});
router.get('/reports/depreciation.pdf', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const book = req.query.book || 'GAAP';
writeDepreciationPdf(res, year, book);
});
module.exports = router;

34
server/routes/taxRules.js Normal file
View File

@@ -0,0 +1,34 @@
const express = require('express');
const { all, audit, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
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, {})
}));
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]) });
});
module.exports = router;

View File

@@ -0,0 +1,26 @@
const express = require('express');
const { all, audit, json, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/templates', (req, res) => {
const templates = all('SELECT * FROM asset_templates ORDER BY name').map((row) => ({
...row,
defaults: parseJson(row.defaults, {}),
custom_fields: parseJson(row.custom_fields, [])
}));
res.json({ templates });
});
router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[req.body.name, req.body.description || null, json(req.body.defaults || {}), json(req.body.custom_fields || []), created, created]
);
audit(req.user.id, 'create', 'asset_template', result.lastInsertRowid, null, req.body);
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
});
module.exports = router;

32
server/routes/workday.js Normal file
View File

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

142
server/rule-catalog.js Normal file
View File

@@ -0,0 +1,142 @@
function months(years) {
return Math.round(Number(years) * 12);
}
function method(code, family, label, formula, options = {}) {
return {
code,
family,
label,
formula,
...options
};
}
function macrsDeclining(prefix, labelPrefix, system, multiplier, lives, options = {}) {
return lives.map((life) => method(
`${prefix}_${String(life).replace('.', '_')}`,
'MACRS',
`${labelPrefix} ${life}-year`,
'macrs_declining_balance',
{
system,
lifeMonths: months(life),
rateMultiplier: multiplier,
switchToStraightLine: true,
defaultConvention: life >= 27.5 ? 'mid_month' : 'half_year',
...options
}
));
}
function straightLineMethods(prefix, family, labelPrefix, system, lives, options = {}) {
return lives.map((life) => method(
`${prefix}_${String(life).replace('.', '_')}`,
family,
`${labelPrefix} ${life}-year`,
'straight_line',
{
system,
lifeMonths: months(life),
defaultConvention: life >= 27.5 ? 'mid_month' : 'half_year',
...options
}
));
}
function acrsRateMethod(code, label, life, rates, options = {}) {
return method(code, 'ACRS', label, 'rate_table', {
system: 'ACRS',
lifeMonths: months(life),
defaultConvention: 'none',
rates,
...options
});
}
function buildMacrsMethods() {
return [
...macrsDeclining('macrs_gds_200db', 'MACRS GDS 200% DB', 'GDS', 2, [3, 5, 7, 10]),
...macrsDeclining('macrs_gds_150db', 'MACRS GDS 150% DB', 'GDS', 1.5, [3, 5, 7, 10, 15, 20]),
...straightLineMethods('macrs_gds_sl', 'MACRS', 'MACRS GDS straight-line', 'GDS', [3, 5, 7, 10, 15, 20, 27.5, 39]),
...straightLineMethods('macrs_ads_sl', 'MACRS', 'MACRS ADS straight-line', 'ADS', [3, 5, 7, 10, 12, 15, 20, 30, 40]),
...macrsDeclining('macrs_ads_150db_legacy', 'Legacy MACRS ADS 150% DB', 'ADS', 1.5, [3, 5, 7, 10, 12, 15, 20, 30, 40], {
legacy: true,
sourceNote: 'For property where an older ADS 150% DB election must continue.'
})
];
}
function buildAcrsMethods() {
const regular = [
acrsRateMethod('acrs_regular_3', 'ACRS regular 3-year', 3, [0.25, 0.38, 0.37]),
acrsRateMethod('acrs_regular_5', 'ACRS regular 5-year', 5, [0.15, 0.22, 0.21, 0.21, 0.21]),
acrsRateMethod('acrs_regular_10', 'ACRS regular 10-year', 10, [0.08, 0.14, 0.12, 0.10, 0.10, 0.10, 0.09, 0.09, 0.09, 0.09]),
acrsRateMethod('acrs_regular_15_real', 'ACRS regular 15-year real property', 15, [], { requiresMonthTable: true, fallbackFormula: 'straight_line' }),
acrsRateMethod('acrs_regular_15_low_income', 'ACRS regular 15-year low-income housing', 15, [], { requiresMonthTable: true, fallbackFormula: 'straight_line' }),
acrsRateMethod('acrs_regular_18_real', 'ACRS regular 18-year real property', 18, [], { requiresMonthTable: true, fallbackFormula: 'straight_line' }),
acrsRateMethod('acrs_regular_19_real', 'ACRS regular 19-year real property', 19, [], { requiresMonthTable: true, fallbackFormula: 'straight_line' }),
acrsRateMethod('acrs_regular_listed_property', 'ACRS listed property predominant-use table', 5, [], { requiresListedPropertyTable: true, fallbackFormula: 'straight_line' })
];
const alternate = straightLineMethods('acrs_alternate_sl', 'ACRS', 'ACRS alternate modified straight-line', 'ACRS', [3, 5, 10, 12, 15, 18, 19, 25], {
formula: 'acrs_alternate_straight_line',
defaultConvention: 'half_year'
});
const straightLine = straightLineMethods('acrs_required_sl', 'ACRS', 'ACRS required straight-line', 'ACRS', [3, 5, 10, 15, 18, 19, 35, 45], {
formula: 'straight_line',
defaultConvention: 'half_year',
legacy: true
});
return [...regular, ...alternate, ...straightLine];
}
function buildGaapMethods() {
return [
method('straight_line', 'GAAP', 'Straight-line', 'straight_line'),
method('sum_of_years_digits', 'GAAP', 'Sum-of-years-digits', 'sum_of_years_digits'),
method('declining_balance_200', 'GAAP', '200% declining balance', 'declining_balance', { rateMultiplier: 2 }),
method('declining_balance_175', 'GAAP', '175% declining balance', 'declining_balance', { rateMultiplier: 1.75 }),
method('declining_balance_150', 'GAAP', '150% declining balance', 'declining_balance', { rateMultiplier: 1.5 }),
method('declining_balance_125', 'GAAP', '125% declining balance', 'declining_balance', { rateMultiplier: 1.25 })
];
}
function buildSpecialMethods() {
return [
method('manual', 'Manual', 'Manual depreciation entry', 'manual'),
method('amortization', 'Lease', 'Straight-line amortization', 'straight_line')
];
}
function buildDepreciationMethods() {
return [
...buildMacrsMethods(),
...buildAcrsMethods(),
...buildGaapMethods(),
...buildSpecialMethods()
];
}
function expandRuleSet(ruleSet) {
const methods = buildDepreciationMethods();
return {
...ruleSet,
methodCatalog: ruleSet.methodCatalog || 'mixedassets-68-us-tax-and-accounting-v1',
methodCounts: {
macrs: methods.filter((item) => item.family === 'MACRS').length,
acrs: methods.filter((item) => item.family === 'ACRS').length,
gaap: methods.filter((item) => item.family === 'GAAP').length,
supplemental: methods.filter((item) => !['MACRS', 'ACRS', 'GAAP'].includes(item.family)).length,
total: methods.length
},
methods
};
}
module.exports = {
buildDepreciationMethods,
expandRuleSet
};

View File

@@ -0,0 +1,47 @@
const roles = ['admin', 'finance', 'operations', 'viewer'];
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'] }
];
function isValidRole(role) {
return roles.includes(role);
}
module.exports = {
isValidRole,
roleCapabilities,
rolePermissions,
roles
};

260
server/services/assets.js Normal file
View File

@@ -0,0 +1,260 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
const assetColumns = [
'asset_id',
'entity_id',
'template_id',
'description',
'category',
'status',
'acquisition_cost',
'acquired_date',
'in_service_date',
'useful_life_months',
'salvage_value',
'land_value',
'prior_depreciation',
'location',
'department',
'group_name',
'custodian',
'vendor',
'serial_number',
'invoice_number',
'gl_asset_account',
'gl_expense_account',
'gl_accumulated_account',
'barcode_value',
'condition',
'new_or_used',
'listed_property',
'business_use_percent',
'investment_use_percent',
'disposal_date',
'disposal_price',
'disposal_expense',
'disposal_type',
'property_type',
'notes',
'custom_fields'
];
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
function assetFromRow(row) {
if (!row) return null;
return {
...row,
listed_property: Boolean(row.listed_property),
custom_fields: parseJson(row.custom_fields, {}),
books: row.books ? parseJson(row.books, []) : undefined
};
}
function hydrateAsset(id) {
const asset = assetFromRow(one('SELECT * FROM assets WHERE id = ?', [id]));
if (!asset) return null;
asset.books = all('SELECT * FROM asset_books WHERE asset_id = ? ORDER BY book_type', [id]).map((book) => ({
...book,
active: Boolean(book.active),
manual_depreciation: parseJson(book.manual_depreciation, {})
}));
asset.leases = all('SELECT * FROM leases WHERE asset_id = ? ORDER BY start_date DESC', [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]);
return asset;
}
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')}`;
}
function normalizeAssetInput(body) {
const input = {};
for (const column of assetColumns) {
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
}
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.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);
input.salvage_value = Number(input.salvage_value || 0);
input.land_value = Number(input.land_value || 0);
input.prior_depreciation = Number(input.prior_depreciation || 0);
input.business_use_percent = Number(input.business_use_percent || 100);
input.investment_use_percent = Number(input.investment_use_percent || 0);
input.custom_fields = json(input.custom_fields || {});
input.listed_property = input.listed_property ? 1 : 0;
return input;
}
function createBooks(assetId, asset, books = []) {
const selectedBooks = books.length ? books : defaultBooks.map((book) => ({ book_type: book }));
for (const book of selectedBooks) {
run(
`INSERT OR REPLACE INTO asset_books (
asset_id, book_type, active, cost, depreciation_method, convention, useful_life_months,
section_179_amount, bonus_percent, business_use_percent, investment_use_percent, manual_depreciation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId,
book.book_type || book.book || 'GAAP',
book.active === false ? 0 : 1,
book.cost ?? asset.acquisition_cost,
book.depreciation_method || asset.depreciation_method || 'straight_line',
book.convention || asset.convention || 'half_year',
book.useful_life_months || asset.useful_life_months,
Number(book.section_179_amount || 0),
Number(book.bonus_percent || 0),
book.business_use_percent ?? asset.business_use_percent ?? 100,
book.investment_use_percent ?? asset.investment_use_percent ?? 0,
json(book.manual_depreciation || {})
]
);
}
}
function listAssets(query = {}) {
return 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.status = ?)
AND (? IS NULL OR a.entity_id = ?)
AND (? IS NULL OR a.category = ?)
AND (? IS NULL OR a.description LIKE '%' || ? || '%' OR a.asset_id LIKE '%' || ? || '%')
ORDER BY a.updated_at DESC
LIMIT 500
`, [
query.status || null,
query.status || null,
query.entity_id || null,
query.entity_id || null,
query.category || null,
query.category || null,
query.search || null,
query.search || null,
query.search || null
]).map(assetFromRow);
}
function createAsset(body, userId) {
const created = now();
const input = normalizeAssetInput(body);
const result = tx(() => {
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 || []);
return insert;
});
const asset = hydrateAsset(result.lastInsertRowid);
audit(userId, 'create', 'asset', asset.id, null, asset);
return asset;
}
function updateAsset(id, body, userId) {
const before = hydrateAsset(id);
if (!before) return null;
const input = normalizeAssetInput({ ...before, ...body });
const updated = now();
tx(() => {
run(
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), updated, id]
);
if (Array.isArray(body.books)) createBooks(id, input, body.books);
});
const asset = hydrateAsset(id);
audit(userId, 'update', 'asset', asset.id, before, asset);
return asset;
}
function deleteAsset(id, userId) {
const before = hydrateAsset(id);
if (!before) return false;
run('DELETE FROM assets WHERE id = ?', [id]);
audit(userId, 'delete', 'asset', id, before, null);
return true;
}
function massUpdateAssets(ids, changes, userId) {
const allowed = ['status', 'location', 'department', 'group_name', 'in_service_date', 'useful_life_months', 'condition', 'disposal_date'];
const columns = allowed.filter((column) => Object.prototype.hasOwnProperty.call(changes, column));
if (!ids.length || !columns.length) return 0;
tx(() => {
for (const id of ids) {
run(
`UPDATE assets SET ${columns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...columns.map((column) => changes[column]), now(), id]
);
}
});
audit(userId, 'mass_update', 'asset', ids.join(','), null, { ids, changes });
return ids.length;
}
function assetExportRows() {
return all(`
SELECT a.*, e.name AS entity_name
FROM assets a
LEFT JOIN entities e ON e.id = a.entity_id
ORDER BY a.asset_id
`).map((asset) => ({
...asset,
listed_property: Boolean(asset.listed_property),
custom_fields: parseJson(asset.custom_fields, {})
}));
}
function upsertImportedAssets(rows, userId) {
let imported = 0;
tx(() => {
for (const row of rows) {
const input = normalizeAssetInput(row);
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
if (existing) {
run(
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), now(), existing.id]
);
} else {
const result = run(
`INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at)
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
);
createBooks(result.lastInsertRowid, input, []);
}
imported += 1;
}
});
return imported;
}
module.exports = {
assetColumns,
assetExportRows,
assetFromRow,
createAsset,
createBooks,
defaultBooks,
deleteAsset,
hydrateAsset,
listAssets,
massUpdateAssets,
normalizeAssetInput,
upsertImportedAssets,
updateAsset
};

View File

@@ -0,0 +1,205 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
function employeeFromRow(row) {
if (!row) return null;
return {
...row,
source_payload: parseJson(row.source_payload, null)
};
}
function assignmentFromRow(row) {
if (!row) return null;
return {
...row,
active: !row.released_at
};
}
function listEmployees(query = {}) {
return all(`
SELECT *
FROM employees
WHERE (? IS NULL OR status = ?)
AND (? IS NULL OR name LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR employee_number LIKE '%' || ? || '%')
ORDER BY name
LIMIT 500
`, [
query.status || null,
query.status || null,
query.search || null,
query.search || null,
query.search || null,
query.search || null
]).map(employeeFromRow);
}
function upsertEmployee(employee) {
const timestamp = now();
const workdayId = employee.workday_worker_id || employee.workdayWorkerId || employee.workerId || null;
const existing = workdayId
? one('SELECT id FROM employees WHERE workday_worker_id = ?', [workdayId])
: one('SELECT id FROM employees WHERE employee_number = ? OR lower(email) = lower(?)', [employee.employee_number || '', employee.email || '']);
const values = [
workdayId,
employee.employee_number || employee.employeeNumber || employee.workerNumber || null,
employee.name || employee.fullName || [employee.firstName, employee.lastName].filter(Boolean).join(' ') || 'Unnamed employee',
employee.email || employee.workEmail || null,
employee.department || employee.organization || null,
employee.location || employee.workLocation || null,
employee.manager_name || employee.managerName || null,
employee.status || 'active',
employee.source || 'manual',
json(employee.source_payload || employee),
employee.last_synced_at || null,
timestamp
];
if (existing) {
run(
`UPDATE employees SET
workday_worker_id = COALESCE(?, workday_worker_id),
employee_number = ?,
name = ?,
email = ?,
department = ?,
location = ?,
manager_name = ?,
status = ?,
source = ?,
source_payload = ?,
last_synced_at = COALESCE(?, last_synced_at),
updated_at = ?
WHERE id = ?`,
[...values, existing.id]
);
return one('SELECT * FROM employees WHERE id = ?', [existing.id]);
}
const result = run(
`INSERT INTO employees (
workday_worker_id, employee_number, name, email, department, location, manager_name,
status, source, source_payload, last_synced_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[...values, timestamp]
);
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
}
function assignmentRows(query = {}) {
return all(`
SELECT aa.*, a.asset_id AS asset_code, a.description AS asset_description,
e.name AS employee_name, e.email AS employee_email, e.employee_number
FROM asset_assignments aa
JOIN assets a ON a.id = aa.asset_id
LEFT JOIN employees e ON e.id = aa.employee_id
WHERE (? IS NULL OR aa.asset_id = ?)
AND (? IS NULL OR aa.employee_id = ?)
AND (? IS NULL OR aa.released_at IS NULL)
ORDER BY aa.assigned_at DESC, aa.id DESC
LIMIT 500
`, [
query.asset_id || null,
query.asset_id || null,
query.employee_id || null,
query.employee_id || null,
query.active ? 1 : null
]).map(assignmentFromRow);
}
function assignAsset(body, userId) {
const timestamp = now();
const asset = one('SELECT * FROM assets WHERE id = ?', [body.asset_id]);
if (!asset) throw Object.assign(new Error('Asset not found'), { status: 404 });
const employee = body.employee_id ? one('SELECT * FROM employees WHERE id = ?', [body.employee_id]) : null;
const department = body.department ?? employee?.department ?? asset.department ?? null;
const location = body.location ?? employee?.location ?? asset.location ?? null;
return tx(() => {
run(
`UPDATE asset_assignments
SET released_at = ?, status = 'released', release_reason = 'Reassigned', updated_at = ?
WHERE asset_id = ? AND released_at IS NULL`,
[timestamp, timestamp, body.asset_id]
);
const result = run(
`INSERT INTO asset_assignments (
asset_id, employee_id, department, location, status, assigned_at, assigned_by, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
body.asset_id,
body.employee_id || null,
department,
location,
'assigned',
body.assigned_at || timestamp,
userId,
body.notes || null,
timestamp,
timestamp
]
);
run(
`UPDATE assets SET
custodian = ?,
department = ?,
location = ?,
status = 'in_service',
updated_at = ?
WHERE id = ?`,
[employee?.name || asset.custodian || null, department, location, timestamp, body.asset_id]
);
const assignment = assignmentRows({ asset_id: body.asset_id, active: true })[0];
audit(userId, 'assign', 'asset', body.asset_id, null, assignment);
return assignment || one('SELECT * FROM asset_assignments WHERE id = ?', [result.lastInsertRowid]);
});
}
function releaseAssignment(id, body, userId) {
const before = one('SELECT * FROM asset_assignments WHERE id = ?', [id]);
if (!before) return null;
const timestamp = now();
run(
`UPDATE asset_assignments
SET released_at = ?, status = 'released', release_reason = ?, notes = COALESCE(?, notes), updated_at = ?
WHERE id = ?`,
[timestamp, body.release_reason || 'Released', body.notes || null, timestamp, id]
);
audit(userId, 'release_assignment', 'asset_assignment', id, before, { released_at: timestamp, ...body });
return assignmentRows({ asset_id: before.asset_id }).find((assignment) => assignment.id === Number(id));
}
function assignmentSummary() {
const stats = one(`
SELECT
COUNT(*) AS active_assignments,
COUNT(DISTINCT employee_id) AS assigned_employees,
COUNT(DISTINCT location) AS assigned_locations
FROM asset_assignments
WHERE released_at IS NULL
`);
const unassigned = one(`
SELECT COUNT(*) AS count
FROM assets a
WHERE NOT EXISTS (
SELECT 1 FROM asset_assignments aa
WHERE aa.asset_id = a.id AND aa.released_at IS NULL
)
`);
return { ...stats, unassigned_assets: unassigned.count };
}
module.exports = {
assignAsset,
assignmentRows,
assignmentSummary,
employeeFromRow,
listEmployees,
releaseAssignment,
upsertEmployee
};

View File

@@ -0,0 +1,80 @@
const path = require('path');
const readXlsxFile = require('read-excel-file/node');
const writeXlsxFile = require('write-excel-file/node');
const Papa = require('papaparse');
const { XMLBuilder, XMLParser } = require('fast-xml-parser');
const { now } = require('../db');
const { assetExportRows } = require('./assets');
async function rowsFromImport(format, buffer) {
const text = buffer.toString('utf8');
if (format === 'json') {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : parsed.assets || [];
}
if (format === 'xml') {
const parsed = new XMLParser({ ignoreAttributes: false }).parse(text);
const assets = parsed.assets?.asset || parsed.MixedAssets?.assets?.asset || [];
return Array.isArray(assets) ? assets : [assets];
}
if (format === 'xlsx' || format === 'xls') {
const rows = await readXlsxFile(buffer);
const headers = (rows.shift() || []).map((header) => String(header || '').trim());
return rows.map((row) => Object.fromEntries(headers.map((header, index) => [header, row[index]])));
}
return Papa.parse(text, { header: true, skipEmptyLines: true }).data;
}
async function assetExport(format) {
const rows = assetExportRows();
if (format === 'csv') {
return {
contentType: 'text/csv',
filename: 'mixedassets-assets.csv',
body: Papa.unparse(rows)
};
}
if (format === 'xlsx') {
const columns = Object.keys(rows[0] || {
asset_id: '',
description: '',
category: '',
status: '',
acquisition_cost: '',
in_service_date: ''
});
const sheetData = [
columns.map((column) => ({ value: column, fontWeight: 'bold' })),
...rows.map((row) => columns.map((column) => ({
value: typeof row[column] === 'object' && row[column] !== null ? JSON.stringify(row[column]) : row[column]
})))
];
return {
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
filename: 'mixedassets-assets.xlsx',
body: await writeXlsxFile(sheetData).toBuffer()
};
}
if (format === 'xml') {
return {
contentType: 'application/xml',
filename: 'mixedassets-assets.xml',
body: new XMLBuilder({ format: true }).build({ assets: { asset: rows } })
};
}
return {
contentType: 'application/json',
filename: 'mixedassets-assets.json',
body: JSON.stringify({ exportedAt: now(), assets: rows })
};
}
function extensionForUpload(fileName) {
return path.extname(fileName).replace('.', '').toLowerCase() || 'csv';
}
module.exports = {
assetExport,
extensionForUpload,
rowsFromImport
};

View File

@@ -0,0 +1,44 @@
const { json, now, one, parseJson, run } = require('../db');
const { publicUser } = require('../middleware/auth');
const defaultPreferences = {
theme: 'mixedAssetsDark'
};
function getPreferences(userId) {
const row = one('SELECT preferences_json FROM user_preferences WHERE user_id = ?', [userId]);
return {
...defaultPreferences,
...parseJson(row?.preferences_json, {})
};
}
function getProfile(user) {
return {
user: publicUser(user),
preferences: getPreferences(user.id)
};
}
function savePreferences(userId, preferences) {
const merged = {
...getPreferences(userId),
...(preferences || {})
};
run(
`INSERT INTO user_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = excluded.updated_at`,
[userId, json(merged), now()]
);
return merged;
}
module.exports = {
defaultPreferences,
getProfile,
getPreferences,
savePreferences
};

107
server/services/reports.js Normal file
View File

@@ -0,0 +1,107 @@
const PDFDocument = require('pdfkit');
const { all, one, parseJson } = 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, {}) : {};
}
function depreciationReport(year, bookType) {
const rules = activeRuleSet();
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,
b.investment_use_percent AS book_investment_use_percent, 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.active = 1 AND b.book_type = ?
ORDER BY a.asset_id
`, [bookType]);
const reportRows = rows.map((row) => {
const asset = assetFromRow(row);
const book = {
...row,
useful_life_months: row.book_life,
business_use_percent: row.book_business_use_percent,
investment_use_percent: row.book_investment_use_percent
};
const schedule = annualSchedule(asset, book, rules, { startYear: year, endYear: year })[0] || {};
return {
asset_id: row.asset_id,
description: row.description,
entity_name: row.entity_name,
category: row.category,
book: bookType,
method: row.depreciation_method,
methodLabel: schedule.methodLabel || row.depreciation_method,
cost: money(row.cost ?? row.acquisition_cost),
depreciation: money(schedule.depreciation || 0),
accumulated: money(schedule.accumulated || 0),
net_book_value: money(schedule.netBookValue ?? row.acquisition_cost)
};
});
return {
year,
book: bookType,
totals: {
cost: money(reportRows.reduce((sum, row) => sum + row.cost, 0)),
depreciation: money(reportRows.reduce((sum, row) => sum + row.depreciation, 0)),
accumulated: money(reportRows.reduce((sum, row) => sum + row.accumulated, 0)),
netBookValue: money(reportRows.reduce((sum, row) => sum + row.net_book_value, 0))
},
rows: reportRows
};
}
function monthlyDepreciationReport(year, bookType) {
const rules = activeRuleSet();
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]);
if (!book) return [];
return annualSchedule(asset, book, rules, { startYear: year, endYear: year }).flatMap(monthlyFromAnnual);
});
const months = Array.from({ length: 12 }, (_, index) => {
const month = index + 1;
return {
month,
depreciation: money(rows.filter((row) => row.month === month).reduce((sum, row) => sum + row.depreciation, 0))
};
});
return { year, book: bookType, months };
}
function writeDepreciationPdf(res, year, book) {
const rows = assetExportRows();
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${book}-${year}.pdf"`);
doc.pipe(res);
doc.fontSize(18).text('MixedAssets Depreciation Report');
doc.fontSize(10).text(`Book: ${book} Year: ${year} Generated: ${new Date().toLocaleString()}`);
doc.moveDown();
doc.fontSize(9).text('Asset ID', 36, doc.y, { continued: true, width: 80 });
doc.text('Description', 100, doc.y, { continued: true, width: 210 });
doc.text('Category', 300, doc.y, { continued: true, width: 110 });
doc.text('Cost', 430, doc.y, { align: 'right' });
doc.moveDown(0.5);
rows.slice(0, 80).forEach((row) => {
doc.text(row.asset_id, 36, doc.y, { continued: true, width: 80 });
doc.text(String(row.description).slice(0, 36), 100, doc.y, { continued: true, width: 210 });
doc.text(String(row.category).slice(0, 20), 300, doc.y, { continued: true, width: 110 });
doc.text(money(row.acquisition_cost).toLocaleString(), 430, doc.y, { align: 'right' });
});
doc.end();
}
module.exports = {
activeRuleSet,
depreciationReport,
monthlyDepreciationReport,
writeDepreciationPdf
};

148
server/services/workday.js Normal file
View File

@@ -0,0 +1,148 @@
const { audit, json, now, one, run } = require('../db');
const { upsertEmployee } = require('./assignments');
function publicConnection(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
tenant: row.tenant,
base_url: row.base_url,
token_url: row.token_url,
client_id: row.client_id,
workers_path: row.workers_path,
enabled: Boolean(row.enabled),
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 getConnection() {
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
}
function getPrivateConnection() {
return one('SELECT * FROM workday_connections ORDER BY id LIMIT 1');
}
function saveConnection(body, userId) {
const timestamp = now();
const existing = getPrivateConnection();
const payload = {
name: body.name || existing?.name || 'Primary Workday',
tenant: body.tenant ?? existing?.tenant ?? '',
base_url: body.base_url ?? existing?.base_url ?? '',
token_url: body.token_url ?? existing?.token_url ?? '',
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)
};
if (existing) {
run(
`UPDATE workday_connections SET
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
workers_path = ?, enabled = ?, 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]
);
} 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]
);
}
const saved = getConnection();
audit(userId, 'update', 'workday_connection', saved.id, null, { ...saved, client_secret: '[redacted]' });
return saved;
}
async function fetchAccessToken(connection) {
const basic = Buffer.from(`${connection.client_id}:${connection.client_secret}`).toString('base64');
const response = await fetch(connection.token_url, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({ grant_type: 'client_credentials' })
});
if (!response.ok) {
throw new Error(`Workday token request failed: ${response.status}`);
}
const body = await response.json();
if (!body.access_token) throw new Error('Workday token response did not include an access token');
return body.access_token;
}
function normalizeWorkers(payload) {
const rows = Array.isArray(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()
}));
}
async function syncWorkers(userId) {
const connection = getPrivateConnection();
if (!connection || !connection.enabled) throw Object.assign(new Error('Workday connection is not enabled'), { status: 400 });
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
throw Object.assign(new Error('Workday connection is missing URL or OAuth credentials'), { status: 400 });
}
const token = await fetchAccessToken(connection);
const url = new URL(connection.workers_path || '/workers', connection.base_url);
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json'
}
});
if (!response.ok) {
throw new Error(`Workday workers request failed: ${response.status}`);
}
const workers = normalizeWorkers(await response.json());
for (const worker of workers) upsertEmployee(worker);
const timestamp = now();
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
timestamp,
`Imported ${workers.length} workers`,
timestamp,
connection.id
]);
audit(userId, 'sync', 'workday_workers', connection.id, null, { imported: workers.length });
return { imported: workers.length, workers };
}
function importWorkersFromPayload(workers, userId) {
const normalized = normalizeWorkers(workers);
for (const worker of normalized) upsertEmployee(worker);
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
return { imported: normalized.length };
}
module.exports = {
getConnection,
importWorkersFromPayload,
saveConnection,
syncWorkers
};

207
server/smoke-test.js Normal file
View File

@@ -0,0 +1,207 @@
const { spawn } = require('child_process');
const port = 3999;
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, ['server/index.js'], {
cwd: process.cwd(),
env: { ...process.env, PORT: String(port) },
stdio: ['ignore', 'pipe', 'pipe']
});
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${path} failed: ${response.status} ${JSON.stringify(body)}`);
}
return body;
}
async function waitForServer() {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
await request('/api/health');
return;
} catch {
await wait(250);
}
}
throw new Error('Server did not start');
}
async function main() {
await waitForServer();
const login = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({
email: 'admin@mixedassets.local',
password: process.env.MIXEDASSETS_ADMIN_PASSWORD || 'ChangeMe123!'
})
});
const auth = { Authorization: `Bearer ${login.token}` };
const profile = await request('/api/profile', { headers: auth });
if (profile.preferences.theme !== 'mixedAssetsDark') {
throw new Error(`Expected default dark theme, found ${profile.preferences.theme}`);
}
const updatedProfile = await request('/api/profile', {
method: 'PUT',
headers: auth,
body: JSON.stringify({ preferences: { theme: 'mixedAssetsDark' } })
});
if (updatedProfile.preferences.theme !== 'mixedAssetsDark') {
throw new Error('Profile theme preference did not persist');
}
const taxRules = await request('/api/tax-rules', { headers: auth });
const activeRule = taxRules.ruleSets.find((rule) => rule.active) || taxRules.ruleSets[0];
const methodCount = activeRule?.rules_json?.methods?.length || 0;
if (methodCount !== 68) {
throw new Error(`Expected 68 depreciation methods, found ${methodCount}`);
}
const suffix = Date.now().toString().slice(-6);
const accessControl = await request('/api/access-control', { headers: auth });
if (!accessControl.roles.includes('admin') || !accessControl.roleCapabilities.some((capability) => capability.key === 'users')) {
throw new Error('Access control role matrix was not returned correctly');
}
const teamResult = await request('/api/teams', {
method: 'POST',
headers: auth,
body: JSON.stringify({
name: `Smoke Team ${suffix}`,
description: 'Smoke test RBAC team'
})
});
const userResult = await request('/api/users', {
method: 'POST',
headers: auth,
body: JSON.stringify({
team_id: teamResult.team.id,
name: 'Smoke Test Viewer',
email: `viewer-${suffix}@example.com`,
password: 'ChangeMe123!',
role: 'viewer',
status: 'active'
})
});
const updatedUser = await request(`/api/users/${userResult.user.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({
team_id: teamResult.team.id,
name: 'Smoke Test Operator',
email: `operator-${suffix}@example.com`,
role: 'operations',
status: 'active'
})
});
if (updatedUser.user.role !== 'operations' || updatedUser.user.team_id !== teamResult.team.id) {
throw new Error('User role or team update did not persist');
}
await request('/api/templates', {
method: 'POST',
headers: auth,
body: JSON.stringify({
name: `Smoke template ${suffix}`,
description: 'Template custom field smoke test',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'macrs_gds_200db_5' },
custom_fields: [
{ key: 'employee_id', label: 'Employee ID', type: 'text', required: true },
{ key: 'calibration_due', label: 'Calibration due', type: 'date', required: false }
]
})
});
const templates = await request('/api/templates', { headers: auth });
const smokeTemplate = templates.templates.find((template) => template.name === `Smoke template ${suffix}`);
if (!smokeTemplate || smokeTemplate.custom_fields.length !== 2) {
throw new Error('Template custom fields were not saved or returned correctly');
}
const assetResult = await request('/api/assets', {
method: 'POST',
headers: auth,
body: JSON.stringify({
asset_id: `T-${suffix}`,
description: 'Smoke test workstation',
category: 'Computer Equipment',
acquisition_cost: 2400,
acquired_date: '2026-01-05',
in_service_date: '2026-01-05',
useful_life_months: 60,
depreciation_method: 'macrs_gds_200db_5'
})
});
const employeeResult = await request('/api/employees', {
method: 'POST',
headers: auth,
body: JSON.stringify({
employee_number: `EMP-${suffix}`,
name: 'Smoke Test Employee',
email: `employee-${suffix}@example.com`,
department: 'Operations',
location: 'Headquarters'
})
});
await request('/api/assignments', {
method: 'POST',
headers: auth,
body: JSON.stringify({
asset_id: assetResult.asset.id,
employee_id: employeeResult.employee.id,
notes: 'Smoke assignment'
})
});
const assignments = await request('/api/assignments?active=true', { headers: auth });
if (!assignments.assignments.some((assignment) => assignment.asset_id === assetResult.asset.id && assignment.employee_id === employeeResult.employee.id)) {
throw new Error('Asset assignment was not saved or returned correctly');
}
const workday = await request('/api/workday/connection', {
method: 'PUT',
headers: auth,
body: JSON.stringify({
name: 'Smoke Workday',
tenant: 'smoke',
base_url: 'https://example.workday.com',
token_url: 'https://example.workday.com/oauth2/token',
client_id: 'client',
client_secret: 'secret',
workers_path: '/workers',
enabled: false
})
});
if (!workday.connection.configured) {
throw new Error('Workday connection configuration did not persist');
}
const report = await request('/api/reports/depreciation?year=2026&book=GAAP', { headers: auth });
if (!Array.isArray(report.rows) || !report.rows.length) {
throw new Error('Depreciation report returned no rows');
}
console.log('Smoke test passed');
}
main()
.catch((error) => {
console.error(error.message);
process.exitCode = 1;
})
.finally(() => {
child.kill('SIGTERM');
});