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

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