Files
MixedAssets/server/db.js
2026-06-03 13:58:12 -05:00

493 lines
15 KiB
JavaScript

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
};