From 4072695432530e23caecd7554aa0df0b145f4eb9 Mon Sep 17 00:00:00 2001 From: mpuckett Date: Fri, 5 Jun 2026 04:15:24 -0500 Subject: [PATCH] Updated A LOT --- README.md | 549 ++++++++++- package.json | 4 + server/app.js | 16 + server/data/irsAssetClasses.js | 342 +++++++ server/db.js | 475 +++++++++- server/depreciation.js | 101 +- server/index.js | 20 + server/middleware/auth.js | 15 + server/routes/admin.js | 79 +- server/routes/alerts.js | 73 ++ server/routes/assets.js | 65 +- server/routes/assignments.js | 8 +- server/routes/auth.js | 3 +- server/routes/books.js | 46 + server/routes/contacts.js | 53 ++ server/routes/dataPortability.js | 4 +- server/routes/disposals.js | 44 + server/routes/leases.js | 39 + server/routes/parts.js | 77 ++ server/routes/pm.js | 96 ++ server/routes/reference.js | 388 +++++++- server/routes/reports.js | 49 + server/routes/servicenow.js | 31 + server/routes/taxRules.js | 132 ++- server/routes/templates.js | 39 +- server/routes/workday.js | 10 +- server/rule-catalog.js | 27 + server/services/accessControl.js | 114 ++- server/services/accessControl.test.js | 45 + server/services/alerts.js | 213 +++++ server/services/alerts.test.js | 30 + server/services/assetClasses.js | 93 ++ server/services/assets.js | 133 ++- server/services/assets.test.js | 10 + server/services/assignments.test.js | 41 + server/services/books.js | 221 +++++ server/services/contacts.js | 175 ++++ server/services/disposals.js | 187 ++++ server/services/leases.js | 146 +++ server/services/notifications.js | 118 +++ server/services/parts.js | 312 ++++++ server/services/pm.js | 467 +++++++++ server/services/profile.js | 4 +- server/services/reportEngine.js | 807 ++++++++++++++++ server/services/reportExport.js | 123 +++ server/services/reports.js | 13 +- server/services/roles.js | 102 ++ server/services/ruleSets.js | 25 + server/services/servicenow.js | 337 +++++++ server/services/webhooks.js | 88 ++ server/services/workday.js | 85 +- server/services/zones.js | 114 +++ server/smoke-test.js | 954 ++++++++++++++++++- src/App.vue | 747 ++++++++++++--- src/components/AssetClassSettings.vue | 211 +++++ src/components/AssetDrawer.vue | 658 ++++++++++++- src/components/AssetIdTemplateSettings.vue | 224 +++++ src/components/BarcodeLabel.vue | 50 + src/components/CategorySettings.vue | 267 ++++++ src/components/DataTable.vue | 205 ++++ src/components/DepartmentSettings.vue | 186 ++++ src/components/DepreciationZoneSettings.vue | 184 ++++ src/components/LabelSheet.vue | 80 ++ src/components/MonacoJsonEditor.vue | 76 ++ src/components/NotificationSettings.vue | 115 +++ src/components/PartCategorySettings.vue | 187 ++++ src/components/PmCategorySettings.vue | 187 ++++ src/components/PmCompletionDialog.vue | 122 +++ src/components/PmSettings.vue | 63 ++ src/components/ServiceNowSettings.vue | 213 +++++ src/components/SignaturePad.vue | 80 ++ src/components/TopNav.vue | 123 ++- src/components/UserManual.vue | 994 ++++++++++++++++++++ src/components/WebhookSettings.vue | 162 ++++ src/constants.js | 148 ++- src/main.js | 56 ++ src/services/api.js | 5 + src/styles/app.css | 373 ++++++-- src/utils/assets.js | 19 +- src/views/AdminView.vue | 365 +++++-- src/views/AlertsView.vue | 219 +++++ src/views/AssetsView.vue | 75 +- src/views/AssignmentsView.vue | 98 +- src/views/BooksView.vue | 357 +++++++ src/views/ContactsView.vue | 253 +++++ src/views/DashboardView.vue | 36 + src/views/PartsView.vue | 509 ++++++++++ src/views/PmView.vue | 363 +++++++ src/views/ReportsView.vue | 317 +++++-- src/views/ScanView.vue | 224 +++++ src/views/TaxRulesView.vue | 254 +++++ src/views/TemplatesView.vue | 162 +++- 92 files changed, 16139 insertions(+), 570 deletions(-) create mode 100644 server/data/irsAssetClasses.js create mode 100644 server/routes/alerts.js create mode 100644 server/routes/books.js create mode 100644 server/routes/contacts.js create mode 100644 server/routes/disposals.js create mode 100644 server/routes/leases.js create mode 100644 server/routes/parts.js create mode 100644 server/routes/pm.js create mode 100644 server/routes/servicenow.js create mode 100644 server/services/accessControl.test.js create mode 100644 server/services/alerts.js create mode 100644 server/services/alerts.test.js create mode 100644 server/services/assetClasses.js create mode 100644 server/services/assets.test.js create mode 100644 server/services/assignments.test.js create mode 100644 server/services/books.js create mode 100644 server/services/contacts.js create mode 100644 server/services/disposals.js create mode 100644 server/services/leases.js create mode 100644 server/services/notifications.js create mode 100644 server/services/parts.js create mode 100644 server/services/pm.js create mode 100644 server/services/reportEngine.js create mode 100644 server/services/reportExport.js create mode 100644 server/services/roles.js create mode 100644 server/services/ruleSets.js create mode 100644 server/services/servicenow.js create mode 100644 server/services/webhooks.js create mode 100644 server/services/zones.js create mode 100644 src/components/AssetClassSettings.vue create mode 100644 src/components/AssetIdTemplateSettings.vue create mode 100644 src/components/BarcodeLabel.vue create mode 100644 src/components/CategorySettings.vue create mode 100644 src/components/DataTable.vue create mode 100644 src/components/DepartmentSettings.vue create mode 100644 src/components/DepreciationZoneSettings.vue create mode 100644 src/components/LabelSheet.vue create mode 100644 src/components/MonacoJsonEditor.vue create mode 100644 src/components/NotificationSettings.vue create mode 100644 src/components/PartCategorySettings.vue create mode 100644 src/components/PmCategorySettings.vue create mode 100644 src/components/PmCompletionDialog.vue create mode 100644 src/components/PmSettings.vue create mode 100644 src/components/ServiceNowSettings.vue create mode 100644 src/components/SignaturePad.vue create mode 100644 src/components/UserManual.vue create mode 100644 src/components/WebhookSettings.vue create mode 100644 src/views/AlertsView.vue create mode 100644 src/views/BooksView.vue create mode 100644 src/views/ContactsView.vue create mode 100644 src/views/PartsView.vue create mode 100644 src/views/PmView.vue create mode 100644 src/views/ScanView.vue create mode 100644 src/views/TaxRulesView.vue diff --git a/README.md b/README.md index 9e94b46..d1d07dd 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,22 @@ MixedAssets is a fixed asset management and depreciation SPA for small and mid-s Each asset carries six independent books (GAAP, Federal, State, AMT, ACE, User-defined), each with its own cost, depreciation method, useful life, §179 amount, bonus percentage, and convention. The asset workbench also includes a File Cabinet (invoices, photos, manuals), warranty/service-agreement tracking, a per-asset task list, and a running notes timeline. +The **Books** screen manages the sets of books as first-class entities: create user-defined books, enable/disable each book, set its type (financial/tax/internal) and entry defaults, and assign which tax rule set drives its depreciation. Enabled depreciation books automatically apply to assets (new and existing), appear in the asset's Books tab, and are selectable in reports — the engine resolves rules per book rather than from a single global set. It also renders each book in a general-ledger table (asset cost, accumulated depreciation, and period expense rolled up to GL accounts with debit/credit and net book value), with PDF/Excel/CSV export. + +Disposals support full and partial dispositions with automatic gain/loss against book net-book-value (with a live preview before committing), Form 4797 property typing, and impairment/write-down adjustments. Lease accounting captures each agreement and generates an ASC 842-style amortization schedule (present value, periodic interest/principal, lease-liability and right-of-use balances). + +Barcode tracking lets you print Code 128 / Code 39 labels (single asset from the workbench, or a batch from the register) and includes a **Scan** screen: use a device camera to scan an asset's barcode (or type the code), then update its status, location, department, custodian, condition, and add a field note on the spot. Camera scanning requires HTTPS or localhost; manual code entry works everywhere. + +The **Maintenance** screen builds reusable preventative-maintenance (PM) plans — each with a frequency (every N days/weeks/months/quarters/bi-yearly/years), an ordered step checklist with per-step time estimates (the plan's estimated minutes auto-totals from the steps), and a components/parts list (part #, description, supplier, cost). Plans are attached to assets from the asset's **PM tab** with a per-asset interval (default set in Admin) and an end date pre-filled to the asset's depreciation end. Completing a service opens a structured form — performed-by (the logged-in user), a **required signature**, 1–5★ condition ratings (Safety/Is it safe?, Physical condition, Operating condition), dynamic completion notes, and photos — then checks off steps, captures parts cost, records history, and rolls the next-due date forward; due/overdue PM raises alerts. Completed forms (with signature, ratings, notes, and photos) are viewable from the **Maintenance** screen and from each asset's **PM tab**. Maintenance spend is tracked per asset in a dedicated **PM book** (Books screen → general ledger) that aggregates completion costs. + +The **Parts inventory** screen (a sub-menu under Maintenance) is a standalone inventory of maintenance parts & supplies, kept separate from the depreciation register. Look up a part to see whether it's in stock, which **aisle/bin** to get it from, and how much is **reserved** vs available; add identification details like photos, measurements, manufacturer part #, and barcode. Stock can live in one or more **Work Location** contacts, and each part's **supplier** is chosen from the Contacts directory (vendors, service providers, or contractors). Stock movements (receive, issue, adjust, reserve, unreserve) are recorded with full history, and parts at/below their reorder point are flagged as low stock. + +The **Contacts** screen is a directory of people and organizations — Employees, Vendors, Service Providers, Contractors, Work Locations, and Other — each with name, international-friendly address and phone, email, and notes, plus type-specific fields (employee ID/department/manager/start date; contractor tax ID or business license; vendor/service-provider star rating and credit term) and a per-contact notes log. It works fully without Workday; when Workday is connected, employee records are synced into Contacts with manager and start-date enrichment, and the sync can be **scheduled automatically** from the Admin → Workday panel. + +The **Alerts** screen surfaces reminders for assets needing maintenance, overdue tasks, preventative-maintenance due dates, and warranties/leases about to expire. A background job (and an on-demand scan) computes alerts with a configurable lead time and severity, and — when SMTP is configured and alerts are enabled — emails a digest of new items via Nodemailer. SMTP/email server settings live in a dedicated panel in the Admin pane (the password is write-only and never returned by the API). + +The Reports workbench is driven by a server report catalog covering depreciation (annual, monthly, quarterly, YTD, net book value, lifetime, projection), activity (rollforward, acquisitions, disposals & gain/loss, adjustments, construction-in-progress), journal entries (depreciation, disposals), tax (Form 4562, Form 4797, AMT, ACE, personal property tax, UBIA for §199A), a fixed-asset card, and a custom **Report Builder** with selectable columns. Every report can be saved with its options for reuse and exported to PDF, Excel, or CSV. + ## Run ```bash @@ -33,7 +49,538 @@ npm test # API smoke test Environment variables are shown in `.env.example`. The app also stores editable runtime settings in SQLite through the Admin screen, including server FQDN, allowed CORS domains, and database metadata. -Tax and depreciation rule updates live as JSON rule sets. The starter federal template is in `data/tax-rules/federal-2026.json` and is seeded into the database on first run. +| Variable | Default | Purpose | +| --- | --- | --- | +| `MIXEDASSETS_PORT` (or `PORT`) | `3000` | API/server port | +| `MIXEDASSETS_DATA_DIR` | `./data` | Data directory | +| `MIXEDASSETS_DB_PATH` | `./data/mixedassets.sqlite` | SQLite database file | +| `MIXEDASSETS_SERVER_FQDN` | `localhost` | Server FQDN (also editable in Admin) | +| `MIXEDASSETS_CORS_ORIGINS` | `http://localhost:5173,...` | Allowed CORS origins (comma-separated) | +| `MIXEDASSETS_JWT_SECRET` | dev fallback | JWT signing secret — set a long random value in production | +| `MIXEDASSETS_ADMIN_PASSWORD` | `ChangeMe123!` | Password for the seeded admin user | + +--- + +# API Reference + +The API is served under `/api`. All payloads are JSON unless noted (file upload and export endpoints differ). + +## Authentication + +Authenticate with `POST /api/auth/login` to receive a JWT, then send it on every other request: + +``` +Authorization: Bearer +``` + +Tokens expire after 12 hours. The only routes that do **not** require a token are `GET /api/health`, `GET /api/public/bootstrap`, and `POST /api/auth/login`. + +```bash +# Login +curl -s http://localhost:3000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@mixedassets.local","password":"ChangeMe123!"}' +# => { "token": "", "user": { "id":1, "name":"...", "role":"admin", ... } } +``` + +## Roles + +Four roles gate write access: `admin`, `finance`, `operations`, `viewer`. The capability matrix is returned by `GET /api/access-control`. Endpoints below list the roles allowed to call them; read endpoints are available to any authenticated user. + +## Errors & status codes + +Errors return `{ "error": "message" }` with an appropriate status: + +| Status | Meaning | +| --- | --- | +| `400` | Validation error (bad body, invalid JSON, missing required field) | +| `401` | Missing/expired token or invalid credentials | +| `403` | Authenticated but role not permitted | +| `404` | Resource not found | +| `500` | Unhandled server error | + +## Endpoints + +### Auth & profile + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/health` | public | Liveness check | +| GET | `/api/public/bootstrap` | public | App name, setup status, DB driver | +| POST | `/api/auth/login` | public | Exchange credentials for a JWT | +| GET | `/api/profile` | any | Current user + preferences | +| PUT | `/api/profile` | any | Update preferences (e.g. theme) | + +### Dashboard & reference data + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/dashboard` | any | Portfolio stats, charts, recent audit trail | +| GET | `/api/entities` | any | List entities/companies | +| POST | `/api/entities` | admin, finance | Create an entity | +| GET | `/api/references` | any | Reference values (locations, departments, categories) | + +### Assets + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/assets` | any | List/filter assets (`?search=&status=&entity_id=&category=`) | +| POST | `/api/assets` | admin, finance, operations | Create asset (optionally with `books[]`) | +| GET | `/api/assets/lookup?code=` | any | Resolve by barcode value, asset ID, or serial number | +| GET | `/api/assets/:id` | any | Hydrated asset (books, files, warranties, tasks, notes, disposals, adjustments, leases) | +| PUT | `/api/assets/:id` | admin, finance, operations | Update asset (and `books[]` when provided) | +| DELETE | `/api/assets/:id` | admin, finance | Delete asset | +| POST | `/api/assets/mass-update` | admin, finance | Bulk field update `{ ids:[], changes:{} }` | +| GET | `/api/assets/:id/depreciation` | any | Multi-year schedule across active books (`?startYear=&endYear=`) | +| POST | `/api/assets/:id/files` | admin, finance, operations | Upload a file (multipart `file`) | +| GET | `/api/assets/:id/files/:fileId` | any | Download a stored file | +| DELETE | `/api/assets/:id/files/:fileId` | admin, finance, operations | Delete a file | +| POST | `/api/assets/:id/photos` | admin, finance, operations | Add an identification photo `{ data, name, mime, caption }` (base64 data URL) | +| PUT | `/api/assets/:id/photos/:photoId` | admin, finance, operations | Update a photo caption | +| DELETE | `/api/assets/:id/photos/:photoId` | admin, finance, operations | Delete a photo | +| POST | `/api/assets/:id/warranties` | admin, finance, operations | Add warranty/service agreement | +| DELETE | `/api/assets/:id/warranties/:warrantyId` | admin, finance, operations | Delete warranty | +| POST | `/api/assets/:id/tasks` | admin, finance, operations | Add task | +| PUT | `/api/assets/:id/tasks/:taskId` | admin, finance, operations | Update task (e.g. mark complete) | +| DELETE | `/api/assets/:id/tasks/:taskId` | admin, finance, operations | Delete task | +| POST | `/api/assets/:id/notes` | admin, finance, operations | Add timestamped note | +| DELETE | `/api/assets/:id/notes/:noteId` | admin, finance, operations | Delete note | + +### Disposals & adjustments + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| POST | `/api/assets/:id/disposal/preview` | any | Compute gain/loss without committing | +| POST | `/api/assets/:id/disposals` | admin, finance | Record full/partial disposal | +| DELETE | `/api/assets/:id/disposals/:disposalId` | admin, finance | Reverse a disposal | +| POST | `/api/assets/:id/adjustments` | admin, finance | Record impairment/write-down/write-up | +| DELETE | `/api/assets/:id/adjustments/:adjustmentId` | admin, finance | Delete an adjustment | + +Disposal body: `{ book_type, disposal_date, disposal_price, disposal_expense, method, property_type, partial, disposed_cost, notes }`. Adjustment body: `{ type, amount, book_type, adjustment_date, reason }` where `type` is `impairment | write_down | write_up | revaluation`. + +### Leases + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/leases` | any | List leases (`?asset_id=`) | +| POST | `/api/leases` | admin, finance | Create lease | +| PUT | `/api/leases/:id` | admin, finance | Update lease | +| DELETE | `/api/leases/:id` | admin, finance | Delete lease | +| GET | `/api/leases/:id/schedule` | any | ASC 842-style amortization schedule | + +Lease body: `{ asset_id, lessor, contract_value, start_date, end_date, discount_rate, payment_frequency }` where `payment_frequency` is `monthly | quarterly | semiannual | annual`. + +### Books & general ledger + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/books` | any | Book definitions + available rule sets | +| POST | `/api/books` | admin, finance | Create a user-defined book `{ code, name, book_type, ... }` | +| PUT | `/api/books/:id` | admin, finance | Update book (name, type, enabled, `tax_rule_set_id`, defaults) | +| DELETE | `/api/books/:id` | admin, finance | Delete a book (blocked for primary/PM books or books in use) | +| GET | `/api/books/:code/ledger?year=` | any | GL rollup + per-asset detail for a book | +| POST | `/api/books/:code/ledger/export` | any | Export the GL (`{ year, format }`, format `pdf|xlsx|csv`) | + +### Assignments & employees + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/employees` | any | List employees (`?search=&status=`) | +| POST | `/api/employees` | admin, finance, operations | Create/upsert employee | +| GET | `/api/assignments` | any | List assignments + summary (`?asset_id=&employee_id=&active=true`) | +| POST | `/api/assignments` | admin, finance, operations | Assign asset to employee/department/location | +| PUT | `/api/assignments/:id/release` | admin, finance, operations | Release an assignment | + +### Contacts + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/contacts` | any | List contacts (`?type=&search=&status=`) | +| GET | `/api/contacts/:id` | any | Single contact with its notes log | +| POST | `/api/contacts` | admin, finance, operations | Create a contact | +| PUT | `/api/contacts/:id` | admin, finance, operations | Update a contact | +| DELETE | `/api/contacts/:id` | admin, finance | Delete a contact | +| POST | `/api/contacts/:id/notes` | admin, finance, operations | Add a note | +| DELETE | `/api/contacts/:id/notes/:noteId` | admin, finance, operations | Delete a note | + +Contact `type` is `employee | vendor | service_provider | contractor | work_location | other`. Vendor/provider `credit_term` is `na | net_30 | net_60 | net_90 | net_180`; `rating` is 1–5. The Workday connector (`/api/workday/*`) upserts `employee` contacts; `PUT /api/workday/connection` accepts `sync_enabled` and `sync_interval_hours` for the automatic scheduled sync. + +### Templates (data-entry forms) + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/templates` | any | List asset entry templates | +| POST | `/api/templates` | admin, finance | Create a template (defaults + custom fields) | + +### Tax rule sets ("tax templates") + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/tax-rules` | any | List rule sets (parsed JSON) | +| POST | `/api/tax-rules` | admin, finance | Create a rule set | +| PUT | `/api/tax-rules/:id` | admin, finance | Update a rule set | +| DELETE | `/api/tax-rules/:id` | admin, finance | Delete a rule set | +| POST | `/api/tax-rules/:id/activate` | admin, finance | Activate (deactivates others in the same jurisdiction) | +| POST | `/api/tax-rules/expand` | admin, finance | Merge the generated 68-method catalog into a draft | + +See [Tax Rule Sets](#tax-rule-sets-tax-templates-1) below for the full JSON schema. + +### Reports + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/reports/catalog` | any | Report types + parameter specs | +| POST | `/api/reports/run` | any | Run a report `{ type, options }` → `{ report }` | +| POST | `/api/reports/export` | any | Export `{ type, options, format }` (`pdf|xlsx|csv`) | +| GET | `/api/saved-reports` | any | List saved reports | +| POST | `/api/saved-reports` | admin, finance, operations | Save a report `{ name, report_type, options }` | +| DELETE | `/api/saved-reports/:id` | admin, finance, operations | Delete a saved report | +| GET | `/api/reports/depreciation` | any | Legacy annual depreciation (`?year=&book=`) | +| GET | `/api/reports/monthly-depreciation` | any | Legacy monthly depreciation | +| GET | `/api/reports/depreciation.pdf` | any | Legacy PDF | + +Report types include `depreciation_expense`, `monthly_depreciation`, `quarterly_depreciation`, `ytd_depreciation`, `net_book_value`, `lifetime_depreciation`, `projection`, `rollforward`, `acquisitions`, `disposals`, `adjustments`, `cip`, `journal_depreciation`, `journal_disposals`, `form_4562`, `form_4797`, `amt`, `ace`, `personal_property_tax`, `ubia`, `fixed_asset_card`, `custom`, and the maintenance set: `pm_due` (PM due before a selectable date), `pm_uncompleted` (assets with overdue PM), `pm_completed` (PM completed since a selectable date), `pm_plan` (printable individual PM plan via selector), and `alerts_since` (alerts raised since a selectable date). Parameter types include `select`, `multiselect`, `number`, and `date`. A report result has the shape `{ title, columns:[{key,label,format}], rows:[], totals:{}, chart?, meta? }`. + +### Import / export + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/export/assets?format=` | any | Export assets (`json|csv|xlsx|xml`) | +| POST | `/api/import/assets` | admin, finance | Import assets (multipart `file`: JSON/CSV/XLSX/XML) | + +### Preventative maintenance + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/pm-plans` | any | List PM plans (steps/components; photo `photo_count` only, no blobs) | +| GET | `/api/pm-plans/:id` | any | One plan with full guidance-photo image data | +| POST | `/api/pm-plans` | admin, finance, operations | Create a plan `{ name, frequency_value, frequency_unit, steps[], components[], photos[] }` (steps carry `est_minutes`; estimated minutes auto-total) | +| PUT | `/api/pm-plans/:id` | admin, finance, operations | Update a plan (photos: resend existing as `{ id, caption }`, new as `{ data, name, mime, caption }`) | +| DELETE | `/api/pm-plans/:id` | admin, finance | Delete a plan | +| GET | `/api/pm-settings` | any | PM defaults (frequency + alert lead days) | +| PUT | `/api/pm-settings` | admin | Save PM defaults | +| GET | `/api/assets/:id/pm` | any | Asset's attached PM schedules + completions | +| POST | `/api/assets/:id/pm` | admin, finance, operations | Attach a plan `{ plan_id, frequency_value, frequency_unit, start_date, end_date }` | +| PUT | `/api/assets/:id/pm/:apId` | admin, finance, operations | Update an asset's PM schedule | +| DELETE | `/api/assets/:id/pm/:apId` | admin, finance, operations | Detach a schedule | +| POST | `/api/assets/:id/pm/:apId/complete` | admin, finance, operations | Record a service `{ steps[], components[], cost, notes_list[], ratings{safety,physical,operating}, signature, photos[] }` (signature required) and advance next-due | +| GET | `/api/pm-completions` | any | List completed PM forms (`?asset_id=`) | +| GET | `/api/pm-completions/:id` | any | Full completion form (signature, ratings, notes, photos) | + +`frequency_unit` is `days | weeks | months | quarters | semiannual | years`. PM schedules feed the alerts engine (`pm_due` / `pm_overdue`). + +**Photos.** Assets carry **identification photos** (Photos tab in the asset workbench) — what it looks like, where it is, nameplate/serial labels — each with an optional caption. PM plans carry **guidance photos** (diagrams, part locations, before/after) that appear to the technician on the completion form. On a phone, the "Add photos" buttons open the camera (`capture`). Photos are stored as base64 data URLs; plan guidance photos are also surfaced on each asset's PM schedule (`/api/assets/:id/pm`). + +### Parts inventory + +A standalone inventory of maintenance parts & supplies (sub-menu under **Maintenance**). It is **not** part of the depreciation register — parts are not assets and do not depreciate. Each part tracks stock per location with **aisle/bin**, on-hand vs **reserved** quantities, a reorder point for low-stock flagging, identification details (measurements, manufacturer part #, barcode, photos), a **supplier** chosen from Contacts (vendors/service providers/contractors), and stock stored at one or more **Work Location** contacts. + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/parts` | any | List parts with aggregated stock (`?search=&category=&status=&low_stock=true`) | +| GET | `/api/parts/:id` | any | Single part with stock locations, photos, and movement history | +| POST | `/api/parts` | admin, finance, operations | Create a part `{ part_number, name, category, unit_of_measure, unit_cost, reorder_point, reorder_quantity, measurements, manufacturer, barcode, supplier_contact_id, status }` | +| PUT | `/api/parts/:id` | admin, finance, operations | Update a part | +| DELETE | `/api/parts/:id` | admin, finance | Delete a part | +| POST | `/api/parts/:id/stock` | admin, finance, operations | Create/update a stock location `{ id?, location_contact_id, aisle, bin, quantity, reserved }` | +| DELETE | `/api/parts/:id/stock/:stockId` | admin, finance, operations | Remove a stock location | +| POST | `/api/parts/:id/transactions` | admin, finance, operations | Record a movement `{ stock_id, type, quantity, asset_id?, notes? }` | +| POST | `/api/parts/:id/photos` | admin, finance, operations | Attach a photo `{ name, mime, data }` (base64 data URL) | +| DELETE | `/api/parts/:id/photos/:photoId` | admin, finance, operations | Delete a photo | + +Movement `type` is `receive` (add on-hand), `issue` (consume), `adjust` (set on-hand to an absolute count), `reserve`, or `unreserve`. `low_stock` is flagged when on-hand ≤ a non-zero reorder point. `available` = on-hand − reserved. All stock/movement/photo endpoints return the fully hydrated part. + +### Alerts & notifications + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/alerts` | any | List alerts (`?status=&type=`) + summary | +| POST | `/api/alerts/scan` | admin, finance, operations | Recompute alerts from due/expiring items | +| POST | `/api/alerts/run` | admin | Scan and notify (email digest and/or webhook) on new alerts | +| PUT | `/api/alerts/:id/acknowledge` | admin, finance, operations | Acknowledge an alert | +| PUT | `/api/alerts/:id/dismiss` | admin, finance, operations | Dismiss an alert (won't reappear) | +| POST | `/api/alerts/:id/ticket` | admin, finance, operations | Submit the alert as a ServiceNow incident (returns the existing one if already raised) | +| GET | `/api/notifications/settings` | admin | SMTP + alert + webhook settings (password/secret masked) | +| PUT | `/api/notifications/settings` | admin | Save settings (send `smtp_password`/`webhook_secret` only to change them; `webhook_secret_clear: true` removes the secret) | +| POST | `/api/notifications/test` | admin | Send a test email (`{ to }` optional) | +| POST | `/api/notifications/webhook-test` | admin | POST a test event to the configured webhook URL | + +Alert types: `maintenance_overdue`, `maintenance_due`, `warranty_expired`, `warranty_expiring`, `lease_expiring`, `pm_due`, `pm_overdue`. Severity is `critical` (overdue/expired or within 7 days) or `warning`. The server also runs the scan-and-notify cycle ~twice daily; it's a no-op unless something is configured. + +**Webhook delivery.** When `webhook_enabled` is true and `webhook_url` is set, each newly raised alert is delivered as its own JSON `POST` (one request per alert) on the alert cycle — independently of, or alongside, email. An alert is marked notified once any channel handles it, so it is delivered at most once. If `webhook_secret` is set, the raw request body is signed with HMAC-SHA256 and sent in the `X-MixedAssets-Signature: sha256=` header so the receiver can verify authenticity. Per-alert payload: + +```json +{ + "event": "alert", + "id": 142, "type": "pm_overdue", "severity": "critical", + "title": "PM overdue: Lift quarterly service", + "message": "Lift quarterly service on FA-1042 was due 2026-05-20.", + "due_date": "2026-05-20", "status": "open", + "reference_type": "asset_pm", "reference_id": 88, + "asset_id": 1042, "asset_code": "FA-1042", + "created_at": "2026-06-04T12:00:00.000Z", "sent_at": "2026-06-04T12:05:00.000Z" +} +``` + +### Admin & integrations + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/api/settings` | admin | App settings | +| PUT | `/api/settings` | admin | Update settings `{ settings:{} }` | +| GET | `/api/users` | admin | List users | +| POST | `/api/users` | admin | Create user | +| PUT | `/api/users/:id` | admin | Update user (role/status/team/password) | +| GET | `/api/teams` | admin | List teams | +| POST | `/api/teams` | admin | Create team | +| GET | `/api/access-control` | admin | Roles + capability matrix | +| GET | `/api/workday/connection` | admin | Workday connector config | +| PUT | `/api/workday/connection` | admin | Save Workday connector | +| POST | `/api/workday/sync-workers` | admin | Pull workers from Workday | +| POST | `/api/workday/import-workers` | admin | Import a worker payload | +| GET | `/api/servicenow/settings` | admin | ServiceNow connection config (password masked) | +| PUT | `/api/servicenow/settings` | admin | Save ServiceNow config (send `servicenow_password` only to change it; `servicenow_password_clear: true` removes it) | +| POST | `/api/servicenow/test` | admin | Verify the connection (GETs the incident table) | +| POST | `/api/servicenow/cmdb-sync` | admin, finance | Pull configuration items from the CMDB into the asset register | + +### ServiceNow integration + +Configured in **Admin → ServiceNow integration** using **basic auth** over HTTPS against a ServiceNow instance (`https://.service-now.com`). + +**Incident push.** Alerts can be submitted as ServiceNow incidents via the Table API (`POST /api/now/table/{incident_table}`). From the **Alerts** screen, the **ServiceNow** action on any open alert creates an incident and links it back (number + deep link shown in the Ticket column). Severity maps to impact/urgency (critical → 1/1, warning → 2/2, else 3/3); optional `assignment_group` and `caller_id` are applied when set. With **auto-create** enabled, the alert cycle raises an incident for each new *critical* alert automatically. Alerts store the linked `external_ticket_number` / `external_ticket_url` and won't duplicate. + +**CMDB pull.** The asset register can be populated from the CMDB via the Table API (`GET /api/now/table/{ci_table}`, default `cmdb_ci_hardware`), requested with display values. A JSON **field map** (asset field → CI column, with sensible hardware defaults) controls population; an optional `sysparm_query` filters which CIs are pulled. Existing assets are matched by ServiceNow `sys_id`, then serial number, then asset tag, so re-syncing updates in place rather than duplicating. The CI's `sys_id` is stored on the asset (`servicenow_sys_id`). + +--- + +# Tax Rule Sets ("tax templates") + +A **tax rule set** is the JSON template that tells the depreciation engine which methods exist and how to compute them, plus the statutory limits for a jurisdiction (e.g. `US-FED`, `US-CA`). It satisfies the requirement for a JSON-driven system for federal/state/local depreciation-law updates. + +Rule sets are stored in the `tax_rule_sets` table and edited in the **Tax rules** screen (Monaco JSON editor) or via the API. The starter federal template lives at `data/tax-rules/federal-2026.json` and is seeded on first run. + +## How a rule set is selected + +Each **book** (GAAP, Federal, …) has a `tax_rule_set_id`. When the engine depreciates an asset for a given book, it uses **that book's assigned rule set**; if a book has no assignment, it falls back to the globally *active* rule set. Assign rule sets to books on the **Books** screen. + +> Because the engine looks up each asset book's `depreciation_method` **by code** inside the rule set's `methods[]` array, the assigned rule set must contain the method codes your assets use. Use **Regenerate method catalog** (UI) or `POST /api/tax-rules/expand` to inject the standard 68 methods, then add or edit any custom methods. + +## Top-level fields + +```jsonc +{ + "schema": "mixedassets.taxRuleSet.v1", // optional label + "jurisdiction": "US-FED", // unique key with version + "name": "Federal 68-method depreciation catalog", + "version": "2026.2", // unique per jurisdiction + "effectiveDate": "2026-01-01", // ISO date + "sourceNote": "Validate against current IRS guidance before filing.", + "books": ["GAAP", "FEDERAL", "STATE", "AMT", "ACE", "USER"], + "limits": { /* see below */ }, + "conventions": ["half_year","mid_quarter","mid_month","full_month","none"], + "assetLives": [ { "code": "5Y", "label": "5-year property", "months": 60 } ], + "methods": [ /* see below — the part the engine computes from */ ], + "catalogNotes": ["free-form notes"] +} +``` + +| Field | Type | Notes | +| --- | --- | --- | +| `jurisdiction` | string | Paired with `version` as a unique key; assign to a book by id | +| `name`, `version`, `effectiveDate` | string | Metadata; `version` must be unique within a jurisdiction | +| `limits` | object | Statutory thresholds (reference/advisory — see below) | +| `conventions` | string[] | Conventions offered in the UI | +| `assetLives` | object[] | `{ code, label, months }` reference table of class lives | +| `methods` | object[] | **Required for calculation** — the method catalog (see below) | + +## `limits` object + +These values are stored for compliance reference and surfaced in the UI. The engine applies §179 and bonus **per asset-book** (from each book's `section_179_amount` and `bonus_percent`); the limits document the statutory caps your data entry should respect. + +```jsonc +"limits": { + "deMinimisSafeHarbor": { + "defaultThreshold": 2500, + "auditedFinancialStatementThreshold": 5000 + }, + "section179": { + "deductionLimit": 2500000, + "phaseOutBegins": 4000000, + "cannotCreateLoss": true + }, + "bonusDepreciation": { + "qualifiedPropertyPercent": 100, + "effectivePlacedInServiceAfter": "2025-01-19" + } +} +``` + +## `methods[]` — the calculation catalog + +Each method is referenced by an asset book's `depreciation_method` (its `code`). Shape: + +| Field | Type | Used by | Notes | +| --- | --- | --- | --- | +| `code` | string | all | Unique identifier referenced by asset books | +| `family` | string | UI | Grouping label: `MACRS`, `ACRS`, `GAAP`, `Manual`, `Lease` | +| `label` | string | UI | Human-readable name | +| `formula` | string | engine | One of the formulas below — drives the math | +| `lifeMonths` | number | engine | Optional; overrides the book/asset useful life | +| `defaultConvention` | string | engine | Convention applied for first/last partial years | +| `rateMultiplier` | number | engine | Declining-balance rate (e.g. `2` = 200%, `1.5` = 150%) | +| `switchToStraightLine` | boolean | engine | MACRS-style switch to SL when it yields more | +| `rates` | number[] | engine | For `rate_table`: per-year fraction of basis | +| `fallbackFormula` | string | engine | Used when `rates` is empty (e.g. `straight_line`) | +| `system`, `legacy`, `sourceNote`, `requiresMonthTable`, `requiresListedPropertyTable` | mixed | reference | Informational only | + +### Supported `formula` values + +| `formula` | Computation | Honors fields | +| --- | --- | --- | +| `straight_line` | `basis / years`, prorated by convention | `lifeMonths`, `defaultConvention` | +| `sum_of_years_digits` | SYD acceleration | `lifeMonths` | +| `declining_balance` | Declining balance at `rateMultiplier`; optional SL switch | `rateMultiplier`, `switchToStraightLine`, `defaultConvention`, `lifeMonths` | +| `macrs_declining_balance` | Same as `declining_balance` (MACRS GDS/ADS) | same as above | +| `rate_table` | `basis × rates[year]`; falls back to `fallbackFormula` if `rates` empty | `rates`, `fallbackFormula`, `lifeMonths` | +| `acrs_alternate_straight_line` | Straight-line forced to half-year convention (legacy ACRS) | `lifeMonths` | +| `manual` | Reads per-year amounts from the asset book's `manual_depreciation` map | — | +| *(unknown)* | Falls back to `straight_line` | — | + +### Depreciable basis & conventions + +The engine computes, for each asset book: + +``` +depreciable basis = max(0, (cost − land − salvage) × businessUse%) − §179 − bonus +§179 = min(basis, book.section_179_amount) +bonus = (basis − §179) × book.bonus_percent / 100 (taken in year 1) +``` + +First-year proration by convention (subsequent years run full, with the remainder pushed into the final year): + +| Convention | First-year fraction | +| --- | --- | +| `half_year` | `0.5` | +| `mid_quarter` | Q1 `0.875`, Q2 `0.625`, Q3 `0.375`, Q4 `0.125` | +| `mid_month` | `(12 − month + 0.5) / 12` | +| `full_month` | `(13 − month) / 12` | +| `none` | `1` (full year) | + +The effective life is `ceil(lifeMonths / 12)` where `lifeMonths` is the method's `lifeMonths`, else the book's `useful_life_months`, else the asset's, else 60. + +## Worked examples + +A minimal rule set with one custom straight-line method and one rate-table method: + +```jsonc +{ + "jurisdiction": "US-CA", + "name": "California starter", + "version": "2026.1", + "effectiveDate": "2026-01-01", + "limits": { "section179": { "deductionLimit": 25000, "phaseOutBegins": 200000 } }, + "conventions": ["half_year", "mid_month", "none"], + "assetLives": [{ "code": "5Y", "label": "5-year", "months": 60 }], + "methods": [ + { + "code": "ca_sl_5", + "family": "GAAP", + "label": "CA straight-line 5-year", + "formula": "straight_line", + "lifeMonths": 60, + "defaultConvention": "half_year" + }, + { + "code": "ca_macrs_5", + "family": "MACRS", + "label": "CA MACRS 5-year (table)", + "formula": "rate_table", + "lifeMonths": 60, + "defaultConvention": "half_year", + "rates": [0.20, 0.32, 0.192, 0.1152, 0.1152, 0.0576] + } + ] +} +``` + +A 200% declining-balance MACRS method with the straight-line switch: + +```jsonc +{ + "code": "macrs_gds_200db_5", + "family": "MACRS", + "label": "MACRS GDS 200% DB 5-year", + "formula": "macrs_declining_balance", + "lifeMonths": 60, + "rateMultiplier": 2, + "switchToStraightLine": true, + "defaultConvention": "half_year" +} +``` + +## Creating a tax template + +**In the UI (Tax rules screen, admin/finance):** + +1. Click **New** (blank draft) or **Upload JSON** to load a file into the editor. +2. Fill in metadata (name, jurisdiction, version, effective date) and edit the JSON body in the Monaco editor — invalid JSON disables Save and shows the parse error. +3. Optionally click **Regenerate method catalog** to inject the standard 68 methods, then add/edit custom methods. +4. **Save**, then **Activate** to make it the active set for its jurisdiction. **Export** downloads the JSON; assign it to books on the **Books** screen. + +**Via the API:** + +```bash +TOKEN=... # from /api/auth/login + +# (optional) expand a draft to include the 68-method catalog +curl -s http://localhost:3000/api/tax-rules/expand \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"rules_json":{"jurisdiction":"US-CA","version":"2026.1"}}' + +# create +curl -s http://localhost:3000/api/tax-rules \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{ + "jurisdiction":"US-CA","name":"California starter","version":"2026.1", + "effective_date":"2026-01-01","active":false, + "rules_json":{ "jurisdiction":"US-CA","version":"2026.1","methods":[ /* ... */ ] } + }' + +# activate (deactivates other US-CA sets) +curl -s -X POST http://localhost:3000/api/tax-rules//activate \ + -H "Authorization: Bearer $TOKEN" +``` + +Then assign the rule set to a book: + +```bash +curl -s -X PUT http://localhost:3000/api/books/ \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"tax_rule_set_id": }' +``` + +--- + +# Asset entry templates + +Separate from tax rule sets, **asset entry templates** (`/api/templates`) speed up data entry by pre-filling defaults and adding custom fields. A template has: + +- `defaults` — an object of asset fields applied on selection (e.g. `category`, `useful_life_months`, `depreciation_method`, GL accounts). +- `custom_fields` — an array of field definitions: `{ key, label, type, required, defaultValue }` where `type` is `text | numeric | date | bool`. + +```jsonc +{ + "name": "Laptop or workstation", + "description": "Standard computer equipment, 5-year life", + "defaults": { "category": "Computer Equipment", "useful_life_months": 60, "depreciation_method": "macrs_gds_200db_5" }, + "custom_fields": [ + { "key": "assigned_employee_id", "label": "Employee ID", "type": "text", "required": false }, + { "key": "calibration_due", "label": "Calibration due", "type": "date", "required": false } + ] +} +``` + +--- ## Compliance Note diff --git a/package.json b/package.json index 7386fec..03cd1d8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@lucide/vue": "^1.17.0", "@mdi/font": "^7.4.47", "@vitejs/plugin-vue": "^6.0.7", + "@zxing/browser": "^0.1.5", "bcryptjs": "^3.0.3", "chart.js": "^4.5.1", "concurrently": "^10.0.3", @@ -27,9 +28,12 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "fast-xml-parser": "^5.8.0", + "jsbarcode": "^3.12.3", "jsonwebtoken": "^9.0.3", "moment": "^2.30.1", + "monaco-editor": "^0.52.2", "multer": "^2.1.1", + "nodemailer": "^8.0.10", "papaparse": "^5.5.3", "path": "^0.12.7", "pdfkit": "^0.18.0", diff --git a/server/app.js b/server/app.js index c73c0f7..9cae5b9 100644 --- a/server/app.js +++ b/server/app.js @@ -7,14 +7,22 @@ const { createCorsMiddleware } = require('./middleware/cors'); const { requireAuth } = require('./middleware/auth'); const adminRoutes = require('./routes/admin'); +const alertRoutes = require('./routes/alerts'); const assetRoutes = require('./routes/assets'); const assignmentRoutes = require('./routes/assignments'); const authRoutes = require('./routes/auth'); +const bookRoutes = require('./routes/books'); +const contactRoutes = require('./routes/contacts'); const dashboardRoutes = require('./routes/dashboard'); const dataPortabilityRoutes = require('./routes/dataPortability'); +const disposalRoutes = require('./routes/disposals'); +const leaseRoutes = require('./routes/leases'); +const partRoutes = require('./routes/parts'); +const pmRoutes = require('./routes/pm'); const profileRoutes = require('./routes/profile'); const referenceRoutes = require('./routes/reference'); const reportRoutes = require('./routes/reports'); +const serviceNowRoutes = require('./routes/servicenow'); const taxRuleRoutes = require('./routes/taxRules'); const templateRoutes = require('./routes/templates'); const workdayRoutes = require('./routes/workday'); @@ -36,10 +44,18 @@ function createApp() { app.use('/api', dashboardRoutes); app.use('/api', referenceRoutes); app.use('/api', assetRoutes); + app.use('/api', alertRoutes); + app.use('/api', bookRoutes); + app.use('/api', contactRoutes); + app.use('/api', disposalRoutes); + app.use('/api', leaseRoutes); + app.use('/api', partRoutes); + app.use('/api', pmRoutes); app.use('/api', assignmentRoutes); app.use('/api', templateRoutes); app.use('/api', taxRuleRoutes); app.use('/api', reportRoutes); + app.use('/api', serviceNowRoutes); app.use('/api', dataPortabilityRoutes); app.use('/api', adminRoutes); app.use('/api', workdayRoutes); diff --git a/server/data/irsAssetClasses.js b/server/data/irsAssetClasses.js new file mode 100644 index 0000000..275e174 --- /dev/null +++ b/server/data/irsAssetClasses.js @@ -0,0 +1,342 @@ +// IRS Table 1 — Alphabetical Listing of Commonly Used Assets (MACRS asset classes with +// GDS and ADS recovery periods). Source: IRS Pub. 946 as published by Sage. Reference data +// used to pre-populate the Asset Class Number on asset categories. null = no class number / +// recovery period determined by another method (amortization, income forecasting, etc.). +// Verify against current tax law before relying on these for a filing. + +// IRS Table 1 — Alphabetical Listing of Commonly Used Assets. +const COMMON = [ + { description: 'Adding Machines', class_number: '00.13', gds: 5, ads: 6 }, + { description: 'Agricultural Machinery and Equipment', class_number: '01.1', gds: 7, ads: 10 }, + { description: 'Airplanes, Commercial', class_number: '45.0', gds: 7, ads: 12 }, + { description: 'Airplanes, Noncommercial', class_number: '00.21', gds: 5, ads: 6 }, + { description: 'Appliances in Rental Property', class_number: null, gds: 5, ads: 9 }, + { description: 'Automobiles', class_number: '00.22', gds: 5, ads: 5 }, + { description: 'Barges', class_number: '00.28', gds: 10, ads: 18 }, + { description: 'Billboards', class_number: '57.1', gds: 15, ads: 20 }, + { description: 'Boats', class_number: '00.28', gds: 10, ads: 18 }, + { description: 'Bookcases', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Books (Professional Library)', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Bridges', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Buildings, Residential Rental', class_number: null, gds: 27.5, ads: 30 }, + { description: 'Buildings, if not Residential Rental', class_number: null, gds: 39, ads: 40 }, + { description: 'Buildings, Car Wash', class_number: '57.1', gds: 15, ads: 20 }, + { description: 'Buildings, Farm (not single-purpose structures)', class_number: '01.3', gds: 20, ads: 25 }, + { description: 'Buildings, Service Station', class_number: '57.1', gds: 15, ads: 20 }, + { description: 'Buildings, Telephone Central Office', class_number: '48.11', gds: 20, ads: 45 }, + { description: 'Buses', class_number: '00.23', gds: 5, ads: 9 }, + { description: 'Calculators', class_number: '00.13', gds: 5, ads: 6 }, + { description: 'Canals', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Carpeting', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Cars', class_number: '00.22', gds: 5, ads: 5 }, + { description: 'Car Phones', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Car Wash Buildings', class_number: '57.1', gds: 15, ads: 20 }, + { description: 'Cash Registers', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Cattle, Breeding or Dairy', class_number: '01.21', gds: 5, ads: 7 }, + { description: 'Cell Phones', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Coin-Operated Dispensing Machines', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Communications Equipment (not included elsewhere)', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Computers', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Computer Peripheral Equipment', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Computer Software – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Concrete Ready Mix Trucks', class_number: '00.242', gds: 5, ads: 6 }, + { description: 'Construction Assets', class_number: '15.0', gds: 5, ads: 6 }, + { description: 'Copiers', class_number: '00.13', gds: 5, ads: 6 }, + { description: 'Copyrights – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Cotton Ginning Assets', class_number: '01.11', gds: 7, ads: 12 }, + { description: 'Covenants Not to Compete – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Customer Lists – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Data Entry Devices', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Desks', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Disk Drives', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Disk Files', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Disk Packs', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Docks', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Drainage Facilities', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Duplicating Equipment', class_number: '00.13', gds: 5, ads: 6 }, + { description: 'Elevators, if Residential Rental', class_number: null, gds: 27.5, ads: 30 }, + { description: 'Elevators, if not Residential Rental', class_number: null, gds: 39, ads: 40 }, + { description: 'Escalators, if Residential Rental', class_number: null, gds: 27.5, ads: 30 }, + { description: 'Escalators, if not Residential Rental', class_number: null, gds: 39, ads: 40 }, + { description: 'Farm Buildings (not single-purpose structures)', class_number: '01.3', gds: 20, ads: 25 }, + { description: 'Fax Machines', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Fences for Agriculture', class_number: '01.1', gds: 7, ads: 10 }, + { description: 'Fences not for Agriculture', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'File Cabinets', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Films', class_number: null, gds: null, ads: null }, + { description: 'Fixtures, Office', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Fixtures in Rental Property', class_number: null, gds: 5, ads: 9 }, + { description: 'Franchises – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Furniture, Office', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Furniture in Rental Property', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Goats, Breeding', class_number: '01.24', gds: 5, ads: 5 }, + { description: 'Goodwill – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Grain Bins', class_number: '01.1', gds: 7, ads: 10 }, + { description: 'Helicopters', class_number: '00.21', gds: 5, ads: 6 }, + { description: 'Hogs, Breeding', class_number: '01.23', gds: 3, ads: 3 }, + { description: 'Horses, Breeding or Work (12 years old or less)', class_number: '01.221', gds: 7, ads: 10 }, + { description: 'Horses, Breeding or Work (more than 12 years old)', class_number: '01.222', gds: 3, ads: 10 }, + { description: 'Horses, Race (more than 2 years old at placed in service date)', class_number: '01.223', gds: 3, ads: 12 }, + { description: 'Horses, not Race, Breeding, or Work (more than 12 years old)', class_number: '01.224', gds: 3, ads: 12 }, + { description: 'Horses, Other (not described in any other category)', class_number: '01.225', gds: 7, ads: 12 }, + { description: 'Hotels', class_number: null, gds: 39, ads: 40 }, + { description: 'Land Improvements (not in any other class)', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Landscaping Shrubbery', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Leasehold Improvements', class_number: null, gds: 39, ads: 40 }, + { description: 'Magnetic Tape Feeds', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Mobile Homes, if Residential Rental', class_number: null, gds: 27.5, ads: 40 }, + { description: 'Mobile Homes, if not Residential Rental', class_number: null, gds: 39, ads: 40 }, + { description: 'Motels', class_number: null, gds: 39, ads: 40 }, + { description: 'Motion Pictures', class_number: null, gds: null, ads: null }, + { description: 'Motorsports Entertainment Complex', class_number: null, gds: 7, ads: 40 }, + { description: 'Nonresidential Real Property', class_number: null, gds: 39, ads: 40 }, + { description: 'Office Furniture', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Optical Character Readers', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Ore Trucks', class_number: '00.242', gds: 5, ads: 6 }, + { description: 'Organization Costs – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Patents – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Plotters', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Printers', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Qualified Improvement Property', class_number: null, gds: 15, ads: 20 }, + { description: 'Qualified Restaurant Property', class_number: null, gds: null, ads: null }, + { description: 'Qualified Retail Property', class_number: null, gds: null, ads: null }, + { description: 'Radio Transmitting Towers', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Railroad Cars and Locomotives, Commercial', class_number: '40.1', gds: 7, ads: 14 }, + { description: 'Railroad Cars and Locomotives, if not Commercial', class_number: '00.25', gds: 7, ads: 15 }, + { description: 'Railroad Machinery and Equipment', class_number: '40.1', gds: 7, ads: 14 }, + { description: 'Railroad Structures and Improvements', class_number: '40.2', gds: 20, ads: 30 }, + { description: 'Railroad Track', class_number: '40.4', gds: 7, ads: 10 }, + { description: 'Recreational Vehicles (RV) (Unloaded Weight 13,000 lbs or More)', class_number: '00.242', gds: 5, ads: 6 }, + { description: 'Recreational Vehicles (RV) (Unloaded Weight Less than 13,000 lbs)', class_number: '00.241', gds: 5, ads: 5 }, + { description: 'Rent-to-Own Property', class_number: null, gds: 3, ads: 4 }, + { description: 'Rental Formal Wear', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Rental Tuxedos', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Research and Development', class_number: null, gds: 5, ads: null }, + { description: 'Residential Rental Real Property', class_number: null, gds: 27.5, ads: 30 }, + { description: 'Roads', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Safes', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Service Station Buildings', class_number: '57.1', gds: 15, ads: 20 }, + { description: 'Sewers, Municipal', class_number: '51.', gds: 25, ads: 50 }, + { description: 'Sewers, if not Municipal Sewers', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Sheep, Breeding', class_number: '01.24', gds: 5, ads: 5 }, + { description: 'Ships', class_number: '00.28', gds: 10, ads: 18 }, + { description: 'Shrubbery, Landscaping', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Sidewalks', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Single-Purpose Agricultural Structures', class_number: '01.4', gds: 10, ads: 15 }, + { description: 'Single-Purpose Horticultural Structures', class_number: '01.4', gds: 10, ads: 15 }, + { description: 'Slot Machines', class_number: '79.0', gds: 7, ads: 10 }, + { description: 'Smart Electric Meter', class_number: null, gds: 10, ads: 30 }, + { description: 'Smart Grid System', class_number: null, gds: 10, ads: 30 }, + { description: 'Sound Recordings', class_number: null, gds: null, ads: null }, + { description: 'Syndication Expenses – Capitalized', class_number: null, gds: null, ads: null }, + { description: 'Tape Cassettes', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Tape Drives', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Taxis', class_number: '00.22', gds: 5, ads: 5 }, + { description: 'Telephones', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Telephone Central Office Buildings', class_number: '48.11', gds: 20, ads: 45 }, + { description: 'Telephone Central Office Equipment', class_number: '48.12', gds: 10, ads: 18 }, + { description: 'Telephone Distribution Plants', class_number: '48.14', gds: 15, ads: 24 }, + { description: 'Telephone Systems', class_number: '00.11', gds: 7, ads: 10 }, + { description: 'Teleprinters', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Television Transmitting Towers', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Terminals', class_number: '00.12', gds: 5, ads: 5 }, + { description: 'Tractor Units for Use Over the Road', class_number: '00.26', gds: 3, ads: 4 }, + { description: 'Trade Names – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Trademarks – Amortized', class_number: null, gds: null, ads: null }, + { description: 'Trailer-Mounted Containers', class_number: '00.27', gds: 5, ads: 6 }, + { description: 'Trailers', class_number: '00.27', gds: 5, ads: 6 }, + { description: 'Trees Bearing Fruits or Nuts', class_number: null, gds: 10, ads: 20 }, + { description: 'Trucks, Heavy (13,000 lbs or more)', class_number: '00.242', gds: 5, ads: 6 }, + { description: 'Trucks, Light (less than 13,000 lbs)', class_number: '00.241', gds: 5, ads: 5 }, + { description: 'Tugs (not used in marine construction)', class_number: '00.28', gds: 10, ads: 18 }, + { description: 'Typewriters', class_number: '00.13', gds: 5, ads: 6 }, + { description: 'Vessels', class_number: '00.28', gds: 10, ads: 18 }, + { description: 'Videotapes', class_number: null, gds: null, ads: null }, + { description: 'Vines Bearing Fruit or Nuts', class_number: null, gds: 10, ads: 20 }, + { description: 'Waterways', class_number: '00.3', gds: 15, ads: 20 }, + { description: 'Wharves', class_number: '00.3', gds: 15, ads: 20 } +]; + +// IRS Table 2 — Property Categorized by the Type of Manufacturing Industry. +const INDUSTRY = [ + { description: 'Aerospace Products', class_number: '37.2', gds: 7, ads: 10 }, + { description: 'Airplanes (manufacturing)', class_number: '37.2', gds: 7, ads: 10 }, + { description: 'Amusement Machines', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Apparel (excluding rubber and leather)', class_number: '23.0', gds: 5, ads: 9 }, + { description: 'Appliances', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Art Supplies', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Athletic Goods', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Automobiles (manufacturing)', class_number: '37.11', gds: 7, ads: 12 }, + { description: 'Boat and Ship Building — Dry Docks and Land Improvements', class_number: '37.32', gds: 10, ads: 16 }, + { description: 'Boat and Ship Building — Machinery and Equipment', class_number: '37.31', gds: 7, ads: 12 }, + { description: 'Boat and Ship Building — Special Tools', class_number: '37.33', gds: 5, ads: 6.5 }, + { description: 'Boiler Systems for Steam and Chemical Recovery', class_number: '26.1', gds: 7, ads: 13 }, + { description: 'Brooms and Brushes', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Buses (manufacturing)', class_number: '37.11', gds: 7, ads: 12 }, + { description: 'Carpets', class_number: '22.3', gds: 5, ads: 9 }, + { description: 'Caskets', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Cement (not Concrete)', class_number: '32.2', gds: 15, ads: 20 }, + { description: 'Chemical and Steam Recovery Boiler Systems', class_number: '26.1', gds: 7, ads: 13 }, + { description: 'Chemicals and Allied Products', class_number: '28.0', gds: null, ads: null }, + { description: 'Clay Products', class_number: '32.3', gds: 7, ads: 15 }, + { description: 'Clocks and Watches', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Clothing (excluding rubber and leather)', class_number: '23.0', gds: 5, ads: 9 }, + { description: 'Computers and Peripheral Machines (manufacturing)', class_number: '36.0', gds: 5, ads: 6 }, + { description: 'Concrete and Concrete Products', class_number: '32.3', gds: 7, ads: 15 }, + { description: 'Construction Machinery', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Cooling Systems', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Dental Equipment', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Dental Supplies', class_number: '22.3', gds: 5, ads: 9 }, + { description: 'Dyeing Textile Products', class_number: '22.3', gds: 5, ads: 9 }, + { description: 'Electronic Components, Products, and Systems', class_number: '36.0', gds: 5, ads: 6 }, + { description: 'Farm Machinery (placed in service before 1/1/2018)', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Farm Machinery (placed in service after 12/31/2017)', class_number: '35.0', gds: 5, ads: 10 }, + { description: 'Floor Coverings — Hard Surface', class_number: '22.3', gds: 5, ads: 9 }, + { description: 'Food and Beverage — Other', class_number: '20.4', gds: null, ads: null }, + { description: 'Food and Beverage Special Handling Devices', class_number: '20.5', gds: 3, ads: 4 }, + { description: 'Foundry Products', class_number: '33.3', gds: 7, ads: 14 }, + { description: 'Furniture (wood)', class_number: '24.4', gds: 7, ads: 10 }, + { description: 'Gas — Coal Gasification', class_number: '49.223', gds: 10, ads: 18 }, + { description: 'Gas Production Plants', class_number: '49.23', gds: 7, ads: 14 }, + { description: 'Glass Products', class_number: '32.1', gds: 7, ads: 14 }, + { description: 'Glass Products — Special Tools', class_number: '32.11', gds: 3, ads: 2.5 }, + { description: 'Grain and Grain Mill Products', class_number: '20.1', gds: 10, ads: 17 }, + { description: 'Jewelry', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Knitted Goods', class_number: '22.1', gds: 5, ads: 7.5 }, + { description: 'Leather and Leather Products', class_number: '31.1', gds: 7, ads: 11 }, + { description: 'Lighting Fixtures', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Locomotives', class_number: '37.41', gds: 7, ads: 11.5 }, + { description: 'Machine Tools', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Machinery, Electrical', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Machinery, Nonelectrical', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Mechanical Products', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Medical Equipment', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Medical Supplies', class_number: '22.3', gds: 5, ads: 9 }, + { description: 'Metal Products (Fabricated)', class_number: '34.0', gds: 7, ads: 12 }, + { description: 'Metal Products (Fabricated) — Special Tools', class_number: '34.01', gds: 3, ads: 3 }, + { description: 'Metals, Primary Nonferrous', class_number: '33.2', gds: 7, ads: 14 }, + { description: 'Metals, Primary Nonferrous — Special Tools', class_number: '33.21', gds: 5, ads: 6.5 }, + { description: 'Mining Machinery', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Motion Picture Films and Tapes', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Motor Homes (manufacturing)', class_number: '37.11', gds: 7, ads: 12 }, + { description: 'Motor Vehicles (manufacturing)', class_number: '37.11', gds: 7, ads: 12 }, + { description: 'Motor Vehicles — Special Tools', class_number: '37.12', gds: 3, ads: 3 }, + { description: 'Musical Instruments', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Nonwoven Fabrics', class_number: '22.5', gds: 7, ads: 10 }, + { description: 'Nonwoven Products', class_number: '22.3', gds: 5, ads: 9 }, + { description: 'Office Supplies', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Ophthalmic Goods', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Paper', class_number: '26.1', gds: 7, ads: 13 }, + { description: 'Paper Products (converted)', class_number: '26.2', gds: 7, ads: 10 }, + { description: 'Paperboard Products', class_number: '26.2', gds: 7, ads: 10 }, + { description: 'Pencils and Pens', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Photographic Equipment', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Photographic Supplies', class_number: '28.0', gds: 5, ads: 9.5 }, + { description: 'Plastic Finished Products', class_number: '30.2', gds: 7, ads: 11 }, + { description: 'Plastic Finished Products — Special Tools', class_number: '30.21', gds: 3, ads: 3.5 }, + { description: 'Pollution Control Assets', class_number: '26.1', gds: 7, ads: 13 }, + { description: 'Pulp', class_number: '26.1', gds: 7, ads: 13 }, + { description: 'Pulp Products (converted)', class_number: '26.2', gds: 7, ads: 10 }, + { description: 'Railroad Cars', class_number: '37.42', gds: 7, ads: 12 }, + { description: 'Railroad Locomotives', class_number: '37.41', gds: 7, ads: 11.5 }, + { description: 'Rubber Products', class_number: '30.1', gds: 7, ads: 14 }, + { description: 'Rubber Products — Special Tools and Devices', class_number: '30.11', gds: 3, ads: 4 }, + { description: 'Semiconductor Manufacturing Equipment', class_number: '36.1', gds: 5, ads: 5 }, + { description: 'Ship and Boat Building — Dry Docks and Land Improvements', class_number: '37.32', gds: 10, ads: 16 }, + { description: 'Ship and Boat Building — Machinery and Equipment', class_number: '37.31', gds: 7, ads: 12 }, + { description: 'Ship and Boat Building — Special Tools', class_number: '37.33', gds: 5, ads: 6.5 }, + { description: 'Sporting Goods', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Steam Utility Production and Distribution', class_number: '49.4', gds: 20, ads: 28 }, + { description: 'Steel (Primary) Mill Products', class_number: '33.4', gds: 7, ads: 15 }, + { description: 'Stone Products', class_number: '32.3', gds: 7, ads: 15 }, + { description: 'Sugar and Sugar Products', class_number: '20.2', gds: 10, ads: 18 }, + { description: 'Television Films and Tapes', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Textile Products, Dyeing, Finishing, and Packaging', class_number: '22.3', gds: 5, ads: 9 }, + { description: 'Textured Yarns', class_number: '22.4', gds: 5, ads: 8 }, + { description: 'Thread', class_number: '22.2', gds: 7, ads: 11 }, + { description: 'Tires', class_number: '30.1', gds: 7, ads: 14 }, + { description: 'Tobacco and Tobacco Products', class_number: '21.0', gds: 7, ads: 15 }, + { description: 'Toys', class_number: '39.0', gds: 7, ads: 12 }, + { description: 'Trailers (manufacturing)', class_number: '37.11', gds: 7, ads: 12 }, + { description: 'Trucks (manufacturing)', class_number: '37.11', gds: 7, ads: 12 }, + { description: 'Vegetable Oils and Products', class_number: '20.3', gds: 10, ads: 18 }, + { description: 'Vending Machines', class_number: '35.0', gds: 7, ads: 10 }, + { description: 'Video Recorders', class_number: '36.0', gds: 5, ads: 6 }, + { description: 'Wood Products', class_number: '24.4', gds: 7, ads: 10 }, + { description: 'Woven Fabric', class_number: '22.2', gds: 7, ads: 11 }, + { description: 'Yarn', class_number: '22.2', gds: 7, ads: 11 } +]; + +// IRS Table 3 — Property Categorized by the Type of Business. +const BUSINESS = [ + { description: 'Agriculture', class_number: '01.1', gds: 7, ads: 10 }, + { description: 'Amusement and Theme Parks', class_number: '80.0', gds: 7, ads: 12.5 }, + { description: 'Barber Shop', class_number: '57.0', gds: 7, ads: 10 }, + { description: 'Beauty Shop', class_number: '57.0', gds: 7, ads: 10 }, + { description: 'Bed and Breakfasts', class_number: '57.0', gds: 7, ads: 10 }, + { description: 'Billiard Establishments', class_number: '79.0', gds: 7, ads: 10 }, + { description: 'Bookbinding', class_number: '27.0', gds: 7, ads: 11 }, + { description: 'Bowling Alleys', class_number: '79.0', gds: 7, ads: 10 }, + { description: 'Cable Television', class_number: '48.4+', gds: null, ads: null }, + { description: 'Casinos', class_number: '00.12', gds: 7, ads: 10 }, + { description: 'Communications (Telegraph, Ocean Cable, Satellite)', class_number: '48.3+', gds: null, ads: null }, + { description: 'Concert Halls', class_number: '79.0', gds: 7, ads: 10 }, + { description: 'Construction', class_number: '15.0', gds: 5, ads: 6 }, + { description: 'Distributive Trades and Services', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Electric Generation (industrial)', class_number: '00.4', gds: 15, ads: 22 }, + { description: 'Electric Utility Services', class_number: '49.1+', gds: null, ads: null }, + { description: 'Electrotyping', class_number: '27.0', gds: 7, ads: 11 }, + { description: 'Engraving', class_number: '27.0', gds: 7, ads: 11 }, + { description: 'Gas Deposits — Exploration and Production', class_number: '13.2', gds: 7, ads: 14 }, + { description: 'Gas and Oil — Offshore Drilling', class_number: '13.0', gds: 5, ads: 7.5 }, + { description: 'Gas and Oil — On-Shore Drilling', class_number: '13.1', gds: 5, ads: 6 }, + { description: 'Gas Utility Services', class_number: '49.2+', gds: null, ads: null }, + { description: 'Lithography', class_number: '27.0', gds: 7, ads: 11 }, + { description: 'Logging', class_number: '24.1+', gds: 5, ads: 6 }, + { description: 'Miniature Golf Courses', class_number: '79.0', gds: 7, ads: 10 }, + { description: 'Mining', class_number: '10.0', gds: 7, ads: 10 }, + { description: 'Motorsports Entertainment Complex', class_number: null, gds: 7, ads: 40 }, + { description: 'Oil and Gas — Offshore Drilling', class_number: '13.0', gds: 5, ads: 7.5 }, + { description: 'Oil and Gas — On-Shore Drilling', class_number: '13.1', gds: 5, ads: 6 }, + { description: 'Personal Services', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Petroleum — Exploration and Production', class_number: '13.2', gds: 7, ads: 14 }, + { description: 'Petroleum Refining', class_number: '13.3', gds: 10, ads: 16 }, + { description: 'Pipeline Transportation', class_number: '46.0', gds: 15, ads: 22 }, + { description: 'Printing', class_number: '27.0', gds: 7, ads: 11 }, + { description: 'Professional Services', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Publishing', class_number: '27.0', gds: 7, ads: 11 }, + { description: 'Radio Broadcasting (excluding transmitting towers)', class_number: '48.2', gds: 5, ads: 6 }, + { description: 'Recreation Services (excluding amusement parks)', class_number: '79.0', gds: 7, ads: 10 }, + { description: 'Research and Development (business activity)', class_number: null, gds: 5, ads: null }, + { description: 'Retail Trades and Services', class_number: '57.0', gds: 5, ads: 9 }, + { description: 'Steam Generation (industrial)', class_number: '00.4', gds: 15, ads: 22 }, + { description: 'Steam Utility Production and Distribution (business)', class_number: '49.4', gds: 20, ads: 28 }, + { description: 'Telephone Central Office Buildings (business)', class_number: '48.11', gds: 20, ads: 45 }, + { description: 'Telephone Central Office Switching', class_number: '48.12', gds: 10, ads: 18 }, + { description: 'Telephone Central Office Switching — Computer-Based', class_number: '48.121', gds: 5, ads: 9.5 }, + { description: 'Telephone Station Equipment', class_number: '48.13', gds: 7, ads: 10 }, + { description: 'Telephone Distribution Plants (business)', class_number: '48.14', gds: 15, ads: 24 }, + { description: 'Television Broadcasting (excluding transmitting towers)', class_number: '48.2', gds: 5, ads: 6 }, + { description: 'Theaters', class_number: '79.0', gds: 7, ads: 10 }, + { description: 'Theme and Amusement Parks', class_number: '80.0', gds: 7, ads: 12.5 }, + { description: 'Timber Cutting', class_number: '24.1+', gds: 5, ads: 6 }, + { description: 'Transport (Air) — Passengers and Freight', class_number: '45.0', gds: 7, ads: 12 }, + { description: 'Transport (Motor) — Freight', class_number: '42.0', gds: 5, ads: 8 }, + { description: 'Transport (Motor) — Passengers', class_number: '41.0', gds: 5, ads: 8 }, + { description: 'Transport (Pipeline)', class_number: '46.0', gds: 15, ads: 22 }, + { description: 'Transport (Water) — Passengers and Freight', class_number: '44.0', gds: 15, ads: 20 }, + { description: 'Typesetting', class_number: '27.0', gds: 7, ads: 11 }, + { description: 'Waste Reduction and Resource Recovery Plants', class_number: '49.5', gds: 7, ads: 10 }, + { description: 'Waste Water (Municipal) Treatment Plants', class_number: '50.0', gds: 15, ads: 24 }, + { description: 'Water Utility Services', class_number: '49.3', gds: 25, ads: 50 }, + { description: 'Wholesale Trades and Services', class_number: '57.0', gds: 5, ads: 9 } +]; + +// `table` marks the source list so the picker can label/group entries. +const IRS_ASSET_CLASSES = [ + ...COMMON.map((entry) => ({ ...entry, table: 'common' })), + ...INDUSTRY.map((entry) => ({ ...entry, table: 'industry' })), + ...BUSINESS.map((entry) => ({ ...entry, table: 'business' })) +]; + +module.exports = { IRS_ASSET_CLASSES }; diff --git a/server/db.js b/server/db.js index 184d1f6..357448d 100644 --- a/server/db.js +++ b/server/db.js @@ -84,12 +84,24 @@ function initialize() { name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK(role IN ('admin','finance','operations','viewer')), + role TEXT NOT NULL DEFAULT 'viewer', status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS roles ( + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + capabilities_json TEXT NOT NULL DEFAULT '[]', + is_system INTEGER NOT NULL DEFAULT 0, + locked INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS user_preferences ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, preferences_json TEXT NOT NULL, @@ -306,6 +318,8 @@ function initialize() { client_secret TEXT, workers_path TEXT NOT NULL DEFAULT '/workers', enabled INTEGER NOT NULL DEFAULT 0, + sync_enabled INTEGER NOT NULL DEFAULT 0, + sync_interval_hours INTEGER NOT NULL DEFAULT 24, last_sync_at TEXT, last_sync_status TEXT, created_at TEXT NOT NULL, @@ -335,6 +349,309 @@ function initialize() { created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS disposals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + disposal_date TEXT NOT NULL, + method TEXT NOT NULL DEFAULT 'sale', + property_type TEXT, + partial INTEGER NOT NULL DEFAULT 0, + disposed_cost REAL NOT NULL DEFAULT 0, + disposal_price REAL NOT NULL DEFAULT 0, + disposal_expense REAL NOT NULL DEFAULT 0, + accumulated_depreciation REAL NOT NULL DEFAULT 0, + net_book_value REAL NOT NULL DEFAULT 0, + gain_loss REAL NOT NULL DEFAULT 0, + book_type TEXT NOT NULL DEFAULT 'GAAP', + notes TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS asset_adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + adjustment_date TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'impairment', + amount REAL NOT NULL DEFAULT 0, + book_type TEXT NOT NULL DEFAULT 'GAAP', + reason TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + book_type TEXT NOT NULL DEFAULT 'tax', + enabled INTEGER NOT NULL DEFAULT 1, + is_primary INTEGER NOT NULL DEFAULT 0, + tax_rule_set_id INTEGER REFERENCES tax_rule_sets(id), + default_method TEXT, + default_convention TEXT, + fiscal_year_end_month INTEGER NOT NULL DEFAULT 12, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + reference_type TEXT NOT NULL, + reference_id INTEGER, + asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE, + severity TEXT NOT NULL DEFAULT 'warning', + title TEXT NOT NULL, + message TEXT, + due_date TEXT, + status TEXT NOT NULL DEFAULT 'open', + notified_at TEXT, + acknowledged_by INTEGER REFERENCES users(id), + acknowledged_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(reference_type, reference_id, type) + ); + + CREATE TABLE IF NOT EXISTS pm_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + category TEXT, + frequency_value INTEGER NOT NULL DEFAULT 1, + frequency_unit TEXT NOT NULL DEFAULT 'months', + estimated_minutes INTEGER, + instructions TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pm_plan_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE, + step_order INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL, + details TEXT, + est_minutes INTEGER + ); + + CREATE TABLE IF NOT EXISTS pm_plan_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + part_number TEXT, + description TEXT, + supplier TEXT, + cost REAL NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS asset_pm_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + plan_id INTEGER REFERENCES pm_plans(id), + frequency_value INTEGER NOT NULL DEFAULT 1, + frequency_unit TEXT NOT NULL DEFAULT 'months', + start_date TEXT, + end_date TEXT, + next_due_date TEXT, + last_completed_date TEXT, + assigned_to INTEGER REFERENCES users(id), + active INTEGER NOT NULL DEFAULT 1, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pm_completions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_pm_id INTEGER NOT NULL REFERENCES asset_pm_plans(id) ON DELETE CASCADE, + completed_at TEXT NOT NULL, + completed_by INTEGER REFERENCES users(id), + notes TEXT, + notes_json TEXT, + steps_json TEXT, + cost REAL NOT NULL DEFAULT 0, + components_json TEXT, + signature TEXT, + rating_safety INTEGER, + rating_physical INTEGER, + rating_operating INTEGER, + next_due_date TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pm_completion_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + completion_id INTEGER NOT NULL REFERENCES pm_completions(id) ON DELETE CASCADE, + name TEXT, + mime_type TEXT, + data_base64 TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL DEFAULT 'other', + first_name TEXT, + last_name TEXT, + organization TEXT, + email TEXT, + phone TEXT, + address_line1 TEXT, + address_line2 TEXT, + city TEXT, + state_region TEXT, + postal_code TEXT, + country TEXT, + notes TEXT, + status TEXT NOT NULL DEFAULT 'active', + employee_id TEXT, + tax_id TEXT, + department TEXT, + work_location TEXT, + manager_first_name TEXT, + manager_last_name TEXT, + manager_email TEXT, + start_date TEXT, + rating INTEGER, + credit_term TEXT, + source TEXT NOT NULL DEFAULT 'manual', + workday_worker_id TEXT UNIQUE, + source_payload TEXT, + last_synced_at TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS contact_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS parts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + part_number TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + category TEXT, + manufacturer TEXT, + manufacturer_part_number TEXT, + barcode TEXT, + unit_of_measure TEXT NOT NULL DEFAULT 'each', + unit_cost REAL NOT NULL DEFAULT 0, + reorder_point REAL NOT NULL DEFAULT 0, + reorder_quantity REAL NOT NULL DEFAULT 0, + measurements TEXT, + supplier_contact_id INTEGER REFERENCES contacts(id), + status TEXT NOT NULL DEFAULT 'active', + notes TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS part_stock ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE, + location_contact_id INTEGER REFERENCES contacts(id), + aisle TEXT, + bin TEXT, + quantity REAL NOT NULL DEFAULT 0, + reserved REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS part_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE, + name TEXT, + mime_type TEXT, + data_base64 TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS part_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE, + stock_id INTEGER REFERENCES part_stock(id) ON DELETE SET NULL, + type TEXT NOT NULL DEFAULT 'receive', + quantity REAL NOT NULL DEFAULT 0, + unit_cost REAL, + asset_id INTEGER REFERENCES assets(id) ON DELETE SET NULL, + reference TEXT, + notes TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS asset_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + name TEXT, + mime_type TEXT, + caption TEXT, + data_base64 TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pm_plan_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE, + name TEXT, + mime_type TEXT, + caption TEXT, + data_base64 TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS asset_id_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + pattern TEXT NOT NULL, + next_number INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS asset_classes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_number TEXT, + description TEXT NOT NULL, + gds REAL, + ads REAL, + source TEXT NOT NULL DEFAULT 'custom', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS depreciation_zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + allowance_percent REAL NOT NULL DEFAULT 0, + pis_start TEXT, + pis_end TEXT, + real_property_end TEXT, + max_recovery_years INTEGER, + leasehold_life_years INTEGER, + notes TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id INTEGER REFERENCES users(id), @@ -347,11 +664,100 @@ function initialize() { ); `); + migrate(); seed(); } +// Additive column migrations for databases created before these columns existed. +function ensureColumn(table, column, definition) { + const columns = all(`PRAGMA table_info(${table})`); + if (!columns.some((col) => col.name === column)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } +} + +function migrate() { + ensureColumn('pm_plan_steps', 'est_minutes', 'INTEGER'); + ensureColumn('pm_completions', 'cost', 'REAL NOT NULL DEFAULT 0'); + ensureColumn('pm_completions', 'components_json', 'TEXT'); + ensureColumn('pm_completions', 'notes_json', 'TEXT'); + ensureColumn('pm_completions', 'signature', 'TEXT'); + ensureColumn('pm_completions', 'rating_safety', 'INTEGER'); + ensureColumn('pm_completions', 'rating_physical', 'INTEGER'); + ensureColumn('pm_completions', 'rating_operating', 'INTEGER'); + ensureColumn('workday_connections', 'sync_enabled', 'INTEGER NOT NULL DEFAULT 0'); + ensureColumn('workday_connections', 'sync_interval_hours', 'INTEGER NOT NULL DEFAULT 24'); + ensureColumn('alerts', 'external_ticket_id', 'TEXT'); + ensureColumn('alerts', 'external_ticket_number', 'TEXT'); + ensureColumn('alerts', 'external_ticket_url', 'TEXT'); + ensureColumn('alerts', 'external_ticket_system', 'TEXT'); + ensureColumn('assets', 'servicenow_sys_id', 'TEXT'); + ensureColumn('assets', 'asset_class_number', 'TEXT'); + ensureColumn('assets', 'special_zone', 'TEXT'); + dropUsersRoleCheck(); +} + +// Custom roles require the legacy CHECK(role IN (...)) constraint on `users` to be removed. +// SQLite can't drop a column constraint in place, so rebuild the table when the old DDL is present. +function dropUsersRoleCheck() { + const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'"); + if (!ddl || !/CHECK\s*\(\s*role/i.test(ddl.sql)) return; + db.exec('PRAGMA foreign_keys=OFF'); + db.exec(` + CREATE TABLE users_rebuild ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER REFERENCES teams(id), + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO users_rebuild (id, team_id, name, email, password_hash, role, status, created_at, updated_at) + SELECT id, team_id, name, email, password_hash, role, status, created_at, updated_at FROM users; + DROP TABLE users; + ALTER TABLE users_rebuild RENAME TO users; + `); + db.exec('PRAGMA foreign_keys=ON'); +} + function seed() { const createdAt = now(); + + // Built-in roles (capability sets). Insert missing ones; keep admin's wildcard in sync. + const { DEFAULT_ROLES } = require('./services/accessControl'); + for (const role of DEFAULT_ROLES) { + run( + `INSERT INTO roles (key, name, description, capabilities_json, is_system, locked, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET is_system = excluded.is_system, locked = excluded.locked`, + [role.key, role.name, role.description, json(role.capabilities), role.is_system, role.locked, role.sort_order, createdAt, createdAt] + ); + } + run("UPDATE roles SET capabilities_json = ? WHERE key = 'admin'", [json(['*'])]); + + // Asset class catalog: seed from the IRS reference tables once; afterwards it is + // fully user-managed (edits/deletes persist and are not re-seeded). + if (!one('SELECT id FROM asset_classes LIMIT 1')) { + const { IRS_ASSET_CLASSES } = require('./data/irsAssetClasses'); + for (const item of IRS_ASSET_CLASSES) { + run( + 'INSERT INTO asset_classes (class_number, description, gds, ads, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + [item.class_number || null, item.description, item.gds ?? null, item.ads ?? null, item.table || 'common', createdAt, createdAt] + ); + } + } + + // Special depreciation zones (e.g. New York Liberty Zone). Seeded once, then user-managed. + run( + `INSERT OR IGNORE INTO depreciation_zones + (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, notes, enabled, created_at, updated_at) + VALUES ('ny_liberty', 'New York Liberty Zone', 30, '2001-09-11', '2006-12-31', '2009-12-31', 20, 5, + '30% special depreciation allowance for qualified NY Liberty Zone property; qualified leasehold improvements use a 5-year life; increased Section 179. Verify current tax law before filing.', 1, ?, ?)`, + [createdAt, createdAt] + ); if (!one('SELECT id FROM teams LIMIT 1')) { run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [ 'Finance Operations', @@ -391,7 +797,14 @@ function seed() { ['category', 'Computer Equipment', 'COMP'], ['category', 'Office Furniture', 'FURN'], ['category', 'Vehicles', 'AUTO'], - ['category', 'Leasehold Improvements', 'LHI'] + ['category', 'Leasehold Improvements', 'LHI'], + ['part_category', 'Electrical', null], + ['part_category', 'Plumbing', null], + ['part_category', 'Structural Component', null], + ['part_category', 'Electronic Component', null], + ['part_category', 'Fire Safety', null], + ['part_category', 'Mechanical Safety', null], + ['part_category', 'Other', null] ]; for (const item of refs) { run('INSERT OR IGNORE INTO reference_values (type, name, code, metadata) VALUES (?, ?, ?, ?)', [ @@ -466,12 +879,68 @@ function seed() { ); } + if (!one('SELECT id FROM books LIMIT 1')) { + const federalRule = one("SELECT id FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1"); + const ruleId = federalRule ? federalRule.id : null; + const bookDefs = [ + ['GAAP', 'GAAP / Financial', 'Primary book for financial statements', 'financial', 1, 1, 'straight_line', 'half_year'], + ['FEDERAL', 'Federal Tax', 'US federal income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'], + ['STATE', 'State Tax', 'State income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'], + ['AMT', 'Alternative Minimum Tax', 'AMT depreciation adjustments', 'tax', 1, 0, 'macrs_gds_150db_5', 'half_year'], + ['ACE', 'Adjusted Current Earnings', 'ACE depreciation', 'tax', 1, 0, 'macrs_ads_sl_5', 'half_year'], + ['USER', 'User-defined', 'Custom internal reporting book', 'internal', 0, 0, 'straight_line', 'half_year'] + ]; + bookDefs.forEach((def, index) => { + run( + `INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [def[0], def[1], def[2], def[3], def[4], def[5], ruleId, def[6], def[7], index, createdAt, createdAt] + ); + }); + } + + // The PM book tracks actual preventative-maintenance spend per asset (not depreciation). + run( + `INSERT OR IGNORE INTO books (code, name, description, book_type, enabled, is_primary, sort_order, created_at, updated_at) + VALUES ('PM', 'PM / Maintenance', 'Actual preventative-maintenance spend per asset', 'maintenance', 1, 0, 6, ?, ?)`, + [createdAt, createdAt] + ); + const settings = { server_fqdn: process.env.MIXEDASSETS_SERVER_FQDN || 'localhost', cors_allowed_domains: process.env.MIXEDASSETS_CORS_ORIGINS || 'http://localhost:5173', database_driver: 'node:sqlite', database_path: DB_PATH, - default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER' + default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER', + alerts_enabled: 'false', + alerts_lead_days: '30', + alerts_recipients: '', + smtp_host: '', + smtp_port: '587', + smtp_secure: 'false', + smtp_user: '', + smtp_password: '', + smtp_from: 'MixedAssets ', + webhook_enabled: 'false', + webhook_url: '', + webhook_secret: '', + servicenow_instance_url: '', + servicenow_username: '', + servicenow_password: '', + servicenow_incident_table: 'incident', + servicenow_assignment_group: '', + servicenow_caller_id: '', + servicenow_ticket_enabled: 'false', + servicenow_auto_ticket: 'false', + servicenow_cmdb_table: 'cmdb_ci_hardware', + servicenow_cmdb_query: '', + servicenow_cmdb_limit: '200', + servicenow_cmdb_map: '', + servicenow_last_sync_at: '', + servicenow_last_sync_status: '', + pm_default_frequency_value: '3', + pm_default_frequency_unit: 'months', + pm_lead_days: '7' }; for (const [key, value] of Object.entries(settings)) { run('INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)', [key, String(value), createdAt]); diff --git a/server/depreciation.js b/server/depreciation.js index f034c5b..061511c 100644 --- a/server/depreciation.js +++ b/server/depreciation.js @@ -1,4 +1,14 @@ const { parseJson } = require('./db'); +const { buildDepreciationMethods } = require('./rule-catalog'); +const { getZone } = require('./services/zones'); + +// Built-in method catalog, used as a fallback so codes like MF200/DB200 always resolve +// even if a particular rule set hasn't been re-expanded to include them. +let methodCatalog = null; +function catalog() { + if (!methodCatalog) methodCatalog = buildDepreciationMethods(); + return methodCatalog; +} function money(value) { return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100; @@ -19,25 +29,59 @@ function parseMaybeJson(value, fallback = null) { return value ?? fallback; } -function totalDepreciableBasis(asset, book) { +function businessUseFactor(asset, book) { + return Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100; +} + +// Cost basis before salvage: acquisition value less land, scaled by business-use %. +function grossBasis(asset, book) { const cost = Number(book.cost ?? asset.acquisition_cost ?? 0); const land = Number(asset.land_value || 0); - const salvage = Number(asset.salvage_value || 0); - const businessUse = Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100; - return Math.max(0, (cost - land - salvage) * businessUse); + return Math.max(0, (cost - land) * businessUseFactor(asset, book)); +} + +// Salvage reduces what standard methods recover, but MACRS ignores it (ignoreSalvage flag). +function salvageValue(asset, book, methodRule) { + if (methodRule && methodRule.ignoreSalvage) return 0; + return Math.max(0, Number(asset.salvage_value || 0) * businessUseFactor(asset, book)); } function section179Amount(asset, book) { - return Math.min(totalDepreciableBasis(asset, book), Number(book.section_179_amount || 0)); + return Math.min(grossBasis(asset, book), Number(book.section_179_amount || 0)); } +// Special-zone §168 allowance (e.g. New York Liberty Zone): applies to the federal tax book +// when the asset is flagged for a zone and its placed-in-service date is within the window. +// Returns the allowance % (used as the §168 bonus) or 0. +function zoneAllowance(asset, book) { + if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') return 0; + const zone = getZone(asset.special_zone); + if (!zone || !Number(zone.allowance_percent)) return 0; + const pis = asset.in_service_date || asset.acquired_date || ''; + if (pis && zone.pis_start && pis < zone.pis_start) return 0; + if (pis && zone.pis_end && pis > zone.pis_end) return 0; + return Number(zone.allowance_percent); +} + +// Section 168 (bonus) allowance, applied after Section 179. function bonusAmount(asset, book) { - const after179 = Math.max(0, totalDepreciableBasis(asset, book) - section179Amount(asset, book)); + const after179 = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book)); return after179 * (Number(book.bonus_percent || 0) / 100); } -function depreciableBasis(asset, book) { - return Math.max(0, totalDepreciableBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book)); +// Total amount recoverable over the asset's life (the schedule cap): cost basis less salvage. +function recoverableBasis(asset, book, methodRule) { + return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, methodRule)); +} + +// Amount a straight-line / SYD method depreciates: net of Section 179, bonus, and salvage. +function depreciableBasis(asset, book, methodRule) { + return Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book) - salvageValue(asset, book, methodRule)); +} + +// Cost basis net of salvage (no 179/bonus) — retained for reports and disposals. +function totalDepreciableBasis(asset, book) { + return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, null)); } function conventionFor(book, methodRule) { @@ -83,31 +127,35 @@ function fractionForIndex(index, convention, asset, totalYears) { } function straightLine(asset, book, index, totalYears, methodRule) { - const basis = depreciableBasis(asset, book); + const basis = depreciableBasis(asset, book, methodRule); return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears); } -function sumOfYearsDigits(asset, book, index, totalYears) { - const basis = depreciableBasis(asset, book); +function sumOfYearsDigits(asset, book, index, totalYears, methodRule) { + const basis = depreciableBasis(asset, book, methodRule); const denominator = (totalYears * (totalYears + 1)) / 2; return basis * ((totalYears - index) / denominator); } function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) { - const basis = depreciableBasis(asset, book); - let bookValue = basis; + // The declining-balance rate applies to the full (post-179/bonus) book value; depreciation + // is floored at salvage. MACRS methods set salvage to 0 via ignoreSalvage. + const start = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book)); + const salvage = salvageValue(asset, book, methodRule); + let bookValue = start; let elapsed = 0; const convention = conventionFor(book, methodRule); for (let i = 0; i <= index; i += 1) { const fraction = fractionForIndex(i, convention, asset, totalYears); - if (fraction <= 0 || bookValue <= 0) return 0; + const recoverable = Math.max(0, bookValue - salvage); + if (fraction <= 0 || recoverable <= 0) return 0; const dbAmount = bookValue * (multiplier / totalYears) * fraction; const remainingLife = Math.max(0.0001, totalYears - elapsed); - const slAmount = (bookValue / remainingLife) * fraction; + const slAmount = (recoverable / remainingLife) * fraction; const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount; - const amount = Math.min(bookValue, selected); + const amount = Math.min(recoverable, selected); if (i === index) return amount; bookValue -= amount; @@ -117,7 +165,7 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule } function rateTable(asset, book, index, methodRule) { - const basis = depreciableBasis(asset, book); + const basis = depreciableBasis(asset, book, methodRule); const rates = methodRule.rates || []; if (!rates.length && methodRule.fallbackFormula === 'straight_line') { return straightLine(asset, book, index, Math.max(1, Math.ceil((methodRule.lifeMonths || book.useful_life_months || 60) / 12)), methodRule); @@ -127,15 +175,18 @@ function rateTable(asset, book, index, methodRule) { function getMethodRule(ruleSet, code) { const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {}; - return (rules.methods || []).find((method) => method.code === code) || { - code, - formula: code, - label: code - }; + return (rules.methods || []).find((method) => method.code === code) + || catalog().find((method) => method.code === code) + || { code, formula: code, label: code }; } function annualSchedule(asset, book, ruleSet, options = {}) { const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line'); + // Apply a special-zone §168 allowance when the book has no explicit bonus of its own. + if (!(Number(book.bonus_percent) > 0)) { + const allowance = zoneAllowance(asset, book); + if (allowance > 0) book = { ...book, bonus_percent: allowance }; + } const usefulLifeMonths = Number(methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60); const totalYears = Math.max(1, Math.ceil(usefulLifeMonths / 12)); const placedInServiceYear = yearFromDate(asset.in_service_date || asset.acquired_date); @@ -156,7 +207,7 @@ function annualSchedule(asset, book, ruleSet, options = {}) { if (methodRule.formula === 'rate_table') { depreciation += rateTable(asset, book, index, methodRule); } else if (methodRule.formula === 'sum_of_years_digits') { - depreciation += sumOfYearsDigits(asset, book, index, totalYears); + depreciation += sumOfYearsDigits(asset, book, index, totalYears, methodRule); } else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') { depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule); } else if (methodRule.formula === 'acrs_alternate_straight_line') { @@ -168,8 +219,8 @@ function annualSchedule(asset, book, ruleSet, options = {}) { } } - const basis = totalDepreciableBasis(asset, book); - depreciation = Math.max(0, Math.min(depreciation, Math.max(0, basis - accumulated))); + const cap = recoverableBasis(asset, book, methodRule); + depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated))); accumulated += depreciation; if (year >= startYear) { diff --git a/server/index.js b/server/index.js index 65d2dc0..484796e 100644 --- a/server/index.js +++ b/server/index.js @@ -1,4 +1,6 @@ const { createApp } = require('./app'); +const { runAlertCycle } = require('./services/alerts'); +const { runScheduledSync } = require('./services/workday'); const PORT = Number(process.env.PORT || process.env.MIXEDASSETS_PORT || 3000); const app = createApp(); @@ -6,3 +8,21 @@ const app = createApp(); app.listen(PORT, () => { console.log(`MixedAssets API listening on http://localhost:${PORT}`); }); + +// Periodically scan for due/expiring items and email digests. These are no-ops +// unless alerts are enabled and SMTP is configured. Runs after boot, then twice daily. +function scheduleAlertCycle() { + const tick = () => runAlertCycle(null).catch((error) => console.error('Alert cycle failed:', error.message)); + setTimeout(tick, 30_000).unref(); + setInterval(tick, 12 * 60 * 60 * 1000).unref(); +} + +// Hourly check for a due automatic Workday sync (respects the configured interval). +function scheduleWorkdaySync() { + const tick = () => runScheduledSync().catch((error) => console.error('Workday sync failed:', error.message)); + setTimeout(tick, 60_000).unref(); + setInterval(tick, 60 * 60 * 1000).unref(); +} + +scheduleAlertCycle(); +scheduleWorkdaySync(); diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 962031a..189dc0d 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -41,9 +41,24 @@ function requireRole(...roles) { }; } +// Capability-based guard: the user's role must grant at least one of the listed capabilities. +// Required lazily to avoid a load-time cycle (roles -> db -> ...). +function requireCapability(...capabilities) { + return (req, res, next) => { + const { capabilitiesForRole } = require('../services/roles'); + const { capabilitySetIncludes } = require('../services/accessControl'); + const granted = capabilitiesForRole(req.user.role); + if (!capabilities.some((cap) => capabilitySetIncludes(granted, cap))) { + return res.status(403).json({ error: 'Insufficient access' }); + } + return next(); + }; +} + module.exports = { publicUser, requireAuth, + requireCapability, requireRole, tokenFor }; diff --git a/server/routes/admin.js b/server/routes/admin.js index 66eab59..b5889e3 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -1,8 +1,9 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const { all, audit, now, one, run } = require('../db'); -const { requireRole } = require('../middleware/auth'); -const { isValidRole, roleCapabilities, rolePermissions, roles } = require('../services/accessControl'); +const { requireCapability } = require('../middleware/auth'); +const { CAPABILITIES } = require('../services/accessControl'); +const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles'); const router = express.Router(); @@ -17,15 +18,17 @@ function publicAdminUser(id) { } function validateRole(role) { - if (!isValidRole(role)) throw Object.assign(new Error('Invalid role'), { status: 400 }); + if (!roleExists(role)) throw Object.assign(new Error('Invalid role'), { status: 400 }); } -router.get('/settings', requireRole('admin'), (req, res) => { +router.get('/settings', requireCapability('admin.settings'), (req, res) => { const rows = all('SELECT * FROM app_settings ORDER BY key'); - res.json({ settings: Object.fromEntries(rows.map((row) => [row.key, row.value])) }); + const settings = Object.fromEntries(rows.map((row) => [row.key, row.value])); + delete settings.smtp_password; // managed (masked) via the notifications settings endpoint + res.json({ settings }); }); -router.put('/settings', requireRole('admin'), (req, res) => { +router.put('/settings', requireCapability('admin.settings'), (req, res) => { for (const [key, value] of Object.entries(req.body.settings || req.body)) { run('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [ key, @@ -37,11 +40,11 @@ router.put('/settings', requireRole('admin'), (req, res) => { res.json({ settings: Object.fromEntries(all('SELECT * FROM app_settings ORDER BY key').map((row) => [row.key, row.value])) }); }); -router.get('/users', requireRole('admin'), (req, res) => { +router.get('/users', requireCapability('admin.users'), (req, res) => { res.json({ users: all(`${userSelect} ORDER BY u.name`) }); }); -router.post('/users', requireRole('admin'), (req, res) => { +router.post('/users', requireCapability('admin.users'), (req, res) => { validateRole(req.body.role || 'viewer'); if (req.body.status && !['active', 'inactive'].includes(req.body.status)) return res.status(400).json({ error: 'Invalid status' }); const created = now(); @@ -62,7 +65,7 @@ router.post('/users', requireRole('admin'), (req, res) => { res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) }); }); -router.put('/users/:id', requireRole('admin'), (req, res) => { +router.put('/users/:id', requireCapability('admin.users'), (req, res) => { const before = publicAdminUser(req.params.id); if (!before) return res.status(404).json({ error: 'User not found' }); const nextRole = req.body.role || before.role; @@ -110,11 +113,11 @@ router.put('/users/:id', requireRole('admin'), (req, res) => { return res.json({ user }); }); -router.get('/teams', requireRole('admin'), (req, res) => { +router.get('/teams', requireCapability('admin.users'), (req, res) => { res.json({ teams: all('SELECT * FROM teams ORDER BY name') }); }); -router.post('/teams', requireRole('admin'), (req, res) => { +router.post('/teams', requireCapability('admin.users'), (req, res) => { const result = run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [ req.body.name, req.body.description || null, @@ -124,8 +127,58 @@ router.post('/teams', requireRole('admin'), (req, res) => { res.status(201).json({ team: one('SELECT * FROM teams WHERE id = ?', [result.lastInsertRowid]) }); }); -router.get('/access-control', requireRole('admin'), (req, res) => { - res.json({ roles, rolePermissions, roleCapabilities }); +router.put('/teams/:id', requireCapability('admin.users'), (req, res) => { + const before = one('SELECT * FROM teams WHERE id = ?', [req.params.id]); + if (!before) return res.status(404).json({ error: 'Team not found' }); + run('UPDATE teams SET name = ?, description = ? WHERE id = ?', [ + req.body.name || before.name, + req.body.description ?? before.description, + req.params.id + ]); + audit(req.user.id, 'update', 'team', req.params.id, before, req.body); + return res.json({ team: one('SELECT * FROM teams WHERE id = ?', [req.params.id]) }); +}); + +router.delete('/teams/:id', requireCapability('admin.users'), (req, res) => { + const before = one('SELECT * FROM teams WHERE id = ?', [req.params.id]); + if (!before) return res.status(404).json({ error: 'Team not found' }); + const memberCount = one('SELECT COUNT(*) AS count FROM users WHERE team_id = ?', [req.params.id]).count; + if (memberCount) { + return res.status(400).json({ error: `Reassign the ${memberCount} user(s) on this team before deleting it` }); + } + run('DELETE FROM teams WHERE id = ?', [req.params.id]); + audit(req.user.id, 'delete', 'team', req.params.id, before, null); + return res.status(204).end(); +}); + +router.get('/access-control', requireCapability('admin.users'), (req, res) => { + res.json({ roles: listRoles(), capabilities: CAPABILITIES }); +}); + +// ---- Roles (capability sets, custom roles allowed) -------------------------- + +router.get('/roles', requireCapability('admin.roles'), (req, res) => { + res.json({ roles: listRoles(), capabilities: CAPABILITIES }); +}); + +router.post('/roles', requireCapability('admin.roles'), (req, res, next) => { + try { + res.status(201).json({ role: createRole(req.body, req.user.id) }); + } catch (error) { + next(error); + } +}); + +router.put('/roles/:key', requireCapability('admin.roles'), (req, res) => { + const role = updateRole(req.params.key, req.body, req.user.id); + if (!role) return res.status(404).json({ error: 'Role not found' }); + return res.json({ role }); +}); + +router.delete('/roles/:key', requireCapability('admin.roles'), (req, res) => { + const result = deleteRole(req.params.key, req.user.id); + if (!result.ok) return res.status(result.status).json({ error: result.error }); + return res.status(204).end(); }); module.exports = router; diff --git a/server/routes/alerts.js b/server/routes/alerts.js new file mode 100644 index 0000000..f96e27d --- /dev/null +++ b/server/routes/alerts.js @@ -0,0 +1,73 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { listAlerts, runAlertCycle, scanAlerts, setStatus, summary } = require('../services/alerts'); +const { publicConfig, saveConfig, sendTestEmail } = require('../services/notifications'); +const { sendTestWebhook } = require('../services/webhooks'); +const { submitTicket } = require('../services/servicenow'); + +const router = express.Router(); + +router.get('/alerts', (req, res) => { + res.json({ alerts: listAlerts(req.query), summary: summary() }); +}); + +router.post('/alerts/scan', requireCapability('alerts.manage'), (req, res) => { + const result = scanAlerts(req.user.id); + res.json({ created: result.created.length, openCount: result.openCount, alerts: listAlerts({}), summary: summary() }); +}); + +router.post('/alerts/run', requireCapability('admin.integrations'), async (req, res, next) => { + try { + res.json(await runAlertCycle(req.user.id)); + } catch (error) { + next(error); + } +}); + +router.put('/alerts/:id/acknowledge', requireCapability('alerts.manage'), (req, res) => { + const alert = setStatus(req.params.id, 'acknowledged', req.user.id); + if (!alert) return res.status(404).json({ error: 'Alert not found' }); + return res.json({ alert }); +}); + +router.put('/alerts/:id/dismiss', requireCapability('alerts.manage'), (req, res) => { + const alert = setStatus(req.params.id, 'dismissed', req.user.id); + if (!alert) return res.status(404).json({ error: 'Alert not found' }); + return res.json({ alert }); +}); + +router.post('/alerts/:id/ticket', requireCapability('alerts.manage'), async (req, res, next) => { + try { + const ticket = await submitTicket(req.params.id, req.user.id); + if (!ticket) return res.status(404).json({ error: 'Alert not found' }); + return res.json({ ticket }); + } catch (error) { + next(error); + } +}); + +router.get('/notifications/settings', requireCapability('admin.integrations'), (req, res) => { + res.json({ settings: publicConfig() }); +}); + +router.put('/notifications/settings', requireCapability('admin.integrations'), (req, res) => { + res.json({ settings: saveConfig(req.body, req.user.id) }); +}); + +router.post('/notifications/test', requireCapability('admin.integrations'), async (req, res, next) => { + try { + res.json(await sendTestEmail(req.body.to, req.user.id)); + } catch (error) { + next(error); + } +}); + +router.post('/notifications/webhook-test', requireCapability('admin.integrations'), async (req, res, next) => { + try { + res.json(await sendTestWebhook(req.user.id)); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/server/routes/assets.js b/server/routes/assets.js index 583adc4..77a9010 100644 --- a/server/routes/assets.js +++ b/server/routes/assets.js @@ -2,16 +2,20 @@ const express = require('express'); const multer = require('multer'); const { audit, now, one, run } = require('../db'); const { annualSchedule } = require('../depreciation'); -const { requireRole } = require('../middleware/auth'); +const { requireCapability } = require('../middleware/auth'); const { + addAssetPhoto, createAsset, deleteAsset, + deleteAssetPhoto, hydrateAsset, listAssets, + lookupAssetByCode, massUpdateAssets, - updateAsset + updateAsset, + updateAssetPhoto } = require('../services/assets'); -const { activeRuleSet } = require('../services/reports'); +const { ruleSetForBook } = require('../services/ruleSets'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } }); const router = express.Router(); @@ -20,28 +24,36 @@ router.get('/assets', (req, res) => { res.json({ assets: listAssets(req.query) }); }); -router.post('/assets', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.post('/assets', requireCapability('assets.edit'), (req, res) => { res.status(201).json({ asset: createAsset(req.body, req.user.id) }); }); +router.get('/assets/lookup', (req, res) => { + const code = String(req.query.code || '').trim(); + if (!code) return res.status(400).json({ error: 'Scan code is required' }); + const asset = lookupAssetByCode(code); + if (!asset) return res.status(404).json({ error: `No asset matches "${code}"` }); + return res.json({ asset }); +}); + router.get('/assets/:id', (req, res) => { const asset = hydrateAsset(req.params.id); if (!asset) return res.status(404).json({ error: 'Asset not found' }); return res.json({ asset }); }); -router.put('/assets/:id', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.put('/assets/:id', requireCapability('assets.edit'), (req, res) => { const asset = updateAsset(req.params.id, req.body, req.user.id); if (!asset) return res.status(404).json({ error: 'Asset not found' }); return res.json({ asset }); }); -router.delete('/assets/:id', requireRole('admin', 'finance'), (req, res) => { +router.delete('/assets/:id', requireCapability('assets.delete'), (req, res) => { if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' }); return res.status(204).end(); }); -router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) => { +router.post('/assets/mass-update', requireCapability('assets.bulk'), (req, res) => { const ids = Array.isArray(req.body.ids) ? req.body.ids : []; const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id); if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' }); @@ -51,16 +63,15 @@ router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) = router.get('/assets/:id/depreciation', (req, res) => { const asset = hydrateAsset(req.params.id); if (!asset) return res.status(404).json({ error: 'Asset not found' }); - const rules = activeRuleSet(); const startYear = Number(req.query.startYear || new Date().getFullYear()); const endYear = Number(req.query.endYear || startYear + 5); const schedules = asset.books .filter((book) => book.active) - .flatMap((book) => annualSchedule(asset, book, rules, { startYear, endYear })); + .flatMap((book) => annualSchedule(asset, book, ruleSetForBook(book.book_type), { startYear, endYear })); res.json({ assetId: asset.asset_id, schedules }); }); -router.post('/assets/:id/files', upload.single('file'), requireRole('admin', 'finance', 'operations'), (req, res) => { +router.post('/assets/:id/files', upload.single('file'), requireCapability('assets.edit'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'File is required' }); const result = run( 'INSERT INTO asset_files (asset_id, file_name, mime_type, size, content_base64, uploaded_by, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, ?)', @@ -78,14 +89,32 @@ router.get('/assets/:id/files/:fileId', (req, res) => { return res.send(Buffer.from(file.content_base64, 'base64')); }); -router.delete('/assets/:id/files/:fileId', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.delete('/assets/:id/files/:fileId', requireCapability('assets.edit'), (req, res) => { const result = run('DELETE FROM asset_files WHERE id = ? AND asset_id = ?', [req.params.fileId, req.params.id]); if (!result.changes) return res.status(404).json({ error: 'File not found' }); audit(req.user.id, 'delete', 'asset_file', req.params.fileId, null, { asset_id: req.params.id }); return res.status(204).end(); }); -router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operations'), (req, res) => { +// Identification photos (base64 data URLs, with optional captions) +router.post('/assets/:id/photos', requireCapability('assets.edit'), (req, res) => { + const photo = addAssetPhoto(req.params.id, req.body, req.user.id); + if (!photo) return res.status(404).json({ error: 'Asset not found' }); + return res.status(201).json({ photo }); +}); + +router.put('/assets/:id/photos/:photoId', requireCapability('assets.edit'), (req, res) => { + const photo = updateAssetPhoto(req.params.id, req.params.photoId, req.body, req.user.id); + if (!photo) return res.status(404).json({ error: 'Photo not found' }); + return res.json({ photo }); +}); + +router.delete('/assets/:id/photos/:photoId', requireCapability('assets.edit'), (req, res) => { + if (!deleteAssetPhoto(req.params.id, req.params.photoId, req.user.id)) return res.status(404).json({ error: 'Photo not found' }); + return res.status(204).end(); +}); + +router.post('/assets/:id/warranties', requireCapability('assets.edit'), (req, res) => { const b = req.body; const result = run( `INSERT INTO warranties ( @@ -107,14 +136,14 @@ router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operation }); }); -router.delete('/assets/:id/warranties/:warrantyId', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.delete('/assets/:id/warranties/:warrantyId', requireCapability('assets.edit'), (req, res) => { const result = run('DELETE FROM warranties WHERE id = ? AND asset_id = ?', [req.params.warrantyId, req.params.id]); if (!result.changes) return res.status(404).json({ error: 'Warranty not found' }); audit(req.user.id, 'delete', 'warranty', req.params.warrantyId, null, { asset_id: req.params.id }); return res.status(204).end(); }); -router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.post('/assets/:id/tasks', requireCapability('assets.edit'), (req, res) => { const created = now(); const result = run( 'INSERT INTO tasks (asset_id, title, type, status, due_date, assigned_to, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', @@ -124,7 +153,7 @@ router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) }); }); -router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.put('/assets/:id/tasks/:taskId', requireCapability('assets.edit'), (req, res) => { const before = one('SELECT * FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]); if (!before) return res.status(404).json({ error: 'Task not found' }); run( @@ -143,14 +172,14 @@ router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operati return res.json({ task: one('SELECT * FROM tasks WHERE id = ?', [req.params.taskId]) }); }); -router.delete('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.delete('/assets/:id/tasks/:taskId', requireCapability('assets.edit'), (req, res) => { const result = run('DELETE FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]); if (!result.changes) return res.status(404).json({ error: 'Task not found' }); audit(req.user.id, 'delete', 'task', req.params.taskId, null, { asset_id: req.params.id }); return res.status(204).end(); }); -router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.post('/assets/:id/notes', requireCapability('assets.edit'), (req, res) => { if (!req.body.body) return res.status(400).json({ error: 'Note body is required' }); const result = run( 'INSERT INTO asset_notes (asset_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', @@ -165,7 +194,7 @@ router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'), }); }); -router.delete('/assets/:id/notes/:noteId', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.delete('/assets/:id/notes/:noteId', requireCapability('assets.edit'), (req, res) => { const result = run('DELETE FROM asset_notes WHERE id = ? AND asset_id = ?', [req.params.noteId, req.params.id]); if (!result.changes) return res.status(404).json({ error: 'Note not found' }); audit(req.user.id, 'delete', 'asset_note', req.params.noteId, null, { asset_id: req.params.id }); diff --git a/server/routes/assignments.js b/server/routes/assignments.js index 9138167..404d488 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -1,5 +1,5 @@ const express = require('express'); -const { requireRole } = require('../middleware/auth'); +const { requireCapability } = require('../middleware/auth'); const { assignAsset, assignmentRows, @@ -15,7 +15,7 @@ router.get('/employees', (req, res) => { res.json({ employees: listEmployees(req.query) }); }); -router.post('/employees', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.post('/employees', requireCapability('assets.assign'), (req, res) => { res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) }); }); @@ -30,7 +30,7 @@ router.get('/assignments', (req, res) => { }); }); -router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req, res, next) => { +router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => { try { res.status(201).json({ assignment: assignAsset(req.body, req.user.id) }); } catch (error) { @@ -38,7 +38,7 @@ router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req, } }); -router.put('/assignments/:id/release', requireRole('admin', 'finance', 'operations'), (req, res) => { +router.put('/assignments/:id/release', requireCapability('assets.assign'), (req, res) => { const assignment = releaseAssignment(req.params.id, req.body, req.user.id); if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); return res.json({ assignment }); diff --git a/server/routes/auth.js b/server/routes/auth.js index fce211a..2fd529c 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -2,6 +2,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const { audit, one } = require('../db'); const { publicUser, tokenFor } = require('../middleware/auth'); +const { capabilitiesForRole } = require('../services/roles'); const router = express.Router(); @@ -20,7 +21,7 @@ router.post('/auth/login', (req, res) => { return res.status(401).json({ error: 'Invalid email or password' }); } audit(user.id, 'login', 'user', user.id, null, { email: user.email }); - return res.json({ token: tokenFor(user), user: publicUser(user) }); + return res.json({ token: tokenFor(user), user: publicUser(user), capabilities: capabilitiesForRole(user.role) }); }); module.exports = router; diff --git a/server/routes/books.js b/server/routes/books.js new file mode 100644 index 0000000..6d0f42e --- /dev/null +++ b/server/routes/books.js @@ -0,0 +1,46 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook } = require('../services/books'); +const { exportReport } = require('../services/reportExport'); + +const router = express.Router(); + +router.get('/books', (req, res) => { + res.json(listBooks()); +}); + +router.post('/books', requireCapability('finance.manage'), (req, res) => { + res.status(201).json({ book: createBook(req.body, req.user.id) }); +}); + +router.put('/books/:id', requireCapability('finance.manage'), (req, res) => { + const book = updateBook(req.params.id, req.body, req.user.id); + if (!book) return res.status(404).json({ error: 'Book not found' }); + return res.json({ book }); +}); + +router.delete('/books/:id', requireCapability('finance.manage'), (req, res) => { + if (!deleteBook(req.params.id, req.user.id)) return res.status(404).json({ error: 'Book not found' }); + return res.status(204).end(); +}); + +router.get('/books/:code/ledger', (req, res) => { + const year = Number(req.query.year) || new Date().getFullYear(); + const ledger = bookLedger(req.params.code, year); + if (!ledger) return res.status(404).json({ error: 'Book not found' }); + return res.json({ ledger }); +}); + +router.post('/books/:code/ledger/export', async (req, res) => { + const year = Number(req.body.year) || new Date().getFullYear(); + const report = ledgerReport(req.params.code, year); + if (!report) return res.status(404).json({ error: 'Book not found' }); + const format = String(req.body.format || 'csv').toLowerCase(); + const output = await exportReport(report, format); + const extension = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf' }[format] || 'txt'; + res.setHeader('Content-Type', output.contentType); + res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.params.code}-ledger-${year}.${extension}"`); + return res.send(output.body); +}); + +module.exports = router; diff --git a/server/routes/contacts.js b/server/routes/contacts.js new file mode 100644 index 0000000..cc9a8e8 --- /dev/null +++ b/server/routes/contacts.js @@ -0,0 +1,53 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { + addNote, + createContact, + deleteContact, + deleteNote, + getContact, + listContacts, + updateContact +} = require('../services/contacts'); + +const router = express.Router(); +const editor = requireCapability('contacts.manage'); + +router.get('/contacts', (req, res) => { + res.json({ contacts: listContacts(req.query) }); +}); + +router.get('/contacts/:id', (req, res) => { + const contact = getContact(req.params.id); + if (!contact) return res.status(404).json({ error: 'Contact not found' }); + return res.json({ contact }); +}); + +router.post('/contacts', editor, (req, res) => { + res.status(201).json({ contact: createContact(req.body, req.user.id) }); +}); + +router.put('/contacts/:id', editor, (req, res) => { + const contact = updateContact(req.params.id, req.body, req.user.id); + if (!contact) return res.status(404).json({ error: 'Contact not found' }); + return res.json({ contact }); +}); + +router.delete('/contacts/:id', requireCapability('contacts.manage'), (req, res) => { + if (!deleteContact(req.params.id, req.user.id)) return res.status(404).json({ error: 'Contact not found' }); + return res.status(204).end(); +}); + +router.post('/contacts/:id/notes', editor, (req, res) => { + if (!req.body.body) return res.status(400).json({ error: 'Note body is required' }); + const note = addNote(req.params.id, req.body.body, req.user.id); + if (!note) return res.status(404).json({ error: 'Contact not found' }); + return res.status(201).json({ note }); +}); + +router.delete('/contacts/:id/notes/:noteId', editor, (req, res) => { + if (!deleteNote(req.params.id, req.params.noteId, req.user.id)) return res.status(404).json({ error: 'Note not found' }); + return res.status(204).end(); +}); + +module.exports = router; diff --git a/server/routes/dataPortability.js b/server/routes/dataPortability.js index 2c524d9..8c6cf43 100644 --- a/server/routes/dataPortability.js +++ b/server/routes/dataPortability.js @@ -1,7 +1,7 @@ const express = require('express'); const multer = require('multer'); const { audit } = require('../db'); -const { requireRole } = require('../middleware/auth'); +const { requireCapability } = require('../middleware/auth'); const { upsertImportedAssets } = require('../services/assets'); const { assetExport, extensionForUpload, rowsFromImport } = require('../services/importExport'); @@ -16,7 +16,7 @@ router.get('/export/assets', async (req, res) => { return res.send(output.body); }); -router.post('/import/assets', requireRole('admin', 'finance'), upload.single('file'), async (req, res) => { +router.post('/import/assets', requireCapability('assets.bulk'), upload.single('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'File is required' }); const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer); const imported = upsertImportedAssets(rows, req.user.id); diff --git a/server/routes/disposals.js b/server/routes/disposals.js new file mode 100644 index 0000000..c45a7e1 --- /dev/null +++ b/server/routes/disposals.js @@ -0,0 +1,44 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { + deleteAdjustment, + previewDisposal, + recordAdjustment, + recordDisposal, + reverseDisposal +} = require('../services/disposals'); + +const router = express.Router(); + +router.post('/assets/:id/disposal/preview', (req, res) => { + const preview = previewDisposal(req.params.id, req.body); + if (!preview) return res.status(404).json({ error: 'Asset not found' }); + return res.json({ preview }); +}); + +router.post('/assets/:id/disposals', requireCapability('finance.manage'), (req, res) => { + const result = recordDisposal(req.params.id, req.body, req.user.id); + if (!result) return res.status(404).json({ error: 'Asset not found' }); + return res.status(201).json(result); +}); + +router.delete('/assets/:id/disposals/:disposalId', requireCapability('finance.manage'), (req, res) => { + const asset = reverseDisposal(req.params.id, req.params.disposalId, req.user.id); + if (!asset) return res.status(404).json({ error: 'Disposal not found' }); + return res.json({ asset }); +}); + +router.post('/assets/:id/adjustments', requireCapability('finance.manage'), (req, res) => { + const adjustment = recordAdjustment(req.params.id, req.body, req.user.id); + if (!adjustment) return res.status(404).json({ error: 'Asset not found' }); + return res.status(201).json({ adjustment }); +}); + +router.delete('/assets/:id/adjustments/:adjustmentId', requireCapability('finance.manage'), (req, res) => { + if (!deleteAdjustment(req.params.id, req.params.adjustmentId, req.user.id)) { + return res.status(404).json({ error: 'Adjustment not found' }); + } + return res.status(204).end(); +}); + +module.exports = router; diff --git a/server/routes/leases.js b/server/routes/leases.js new file mode 100644 index 0000000..0d3785b --- /dev/null +++ b/server/routes/leases.js @@ -0,0 +1,39 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { + createLease, + deleteLease, + getLease, + leaseSchedule, + listLeases, + updateLease +} = require('../services/leases'); + +const router = express.Router(); + +router.get('/leases', (req, res) => { + res.json({ leases: listLeases(req.query) }); +}); + +router.post('/leases', requireCapability('finance.manage'), (req, res) => { + res.status(201).json({ lease: createLease(req.body, req.user.id) }); +}); + +router.put('/leases/:id', requireCapability('finance.manage'), (req, res) => { + const lease = updateLease(req.params.id, req.body, req.user.id); + if (!lease) return res.status(404).json({ error: 'Lease not found' }); + return res.json({ lease }); +}); + +router.delete('/leases/:id', requireCapability('finance.manage'), (req, res) => { + if (!deleteLease(req.params.id, req.user.id)) return res.status(404).json({ error: 'Lease not found' }); + return res.status(204).end(); +}); + +router.get('/leases/:id/schedule', (req, res) => { + const lease = getLease(req.params.id); + if (!lease) return res.status(404).json({ error: 'Lease not found' }); + return res.json({ lease, schedule: leaseSchedule(lease) }); +}); + +module.exports = router; diff --git a/server/routes/parts.js b/server/routes/parts.js new file mode 100644 index 0000000..b02f007 --- /dev/null +++ b/server/routes/parts.js @@ -0,0 +1,77 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { + addPhoto, + createPart, + deletePart, + deletePhoto, + deleteStock, + getPart, + listParts, + recordTransaction, + saveStock, + updatePart +} = require('../services/parts'); + +const router = express.Router(); +const editor = requireCapability('parts.manage'); + +router.get('/parts', (req, res) => { + res.json({ parts: listParts(req.query) }); +}); + +router.get('/parts/:id', (req, res) => { + const part = getPart(req.params.id); + if (!part) return res.status(404).json({ error: 'Part not found' }); + return res.json({ part }); +}); + +router.post('/parts', editor, (req, res) => { + res.status(201).json({ part: createPart(req.body, req.user.id) }); +}); + +router.put('/parts/:id', editor, (req, res) => { + const part = updatePart(req.params.id, req.body, req.user.id); + if (!part) return res.status(404).json({ error: 'Part not found' }); + return res.json({ part }); +}); + +router.delete('/parts/:id', requireCapability('parts.manage'), (req, res) => { + if (!deletePart(req.params.id, req.user.id)) return res.status(404).json({ error: 'Part not found' }); + return res.status(204).end(); +}); + +// Stock locations (aisle/bin + on-hand/reserved per location) +router.post('/parts/:id/stock', editor, (req, res) => { + const part = saveStock(req.params.id, req.body, req.user.id); + if (!part) return res.status(404).json({ error: 'Part or stock location not found' }); + return res.status(201).json({ part }); +}); + +router.delete('/parts/:id/stock/:stockId', editor, (req, res) => { + const part = deleteStock(req.params.id, req.params.stockId, req.user.id); + if (!part) return res.status(404).json({ error: 'Stock location not found' }); + return res.json({ part }); +}); + +// Stock movements (receive/issue/adjust/reserve/unreserve) +router.post('/parts/:id/transactions', editor, (req, res) => { + const part = recordTransaction(req.params.id, req.body, req.user.id); + if (!part) return res.status(404).json({ error: 'Part not found' }); + return res.status(201).json({ part }); +}); + +// Photos +router.post('/parts/:id/photos', editor, (req, res) => { + const part = addPhoto(req.params.id, req.body, req.user.id); + if (!part) return res.status(404).json({ error: 'Part not found' }); + return res.status(201).json({ part }); +}); + +router.delete('/parts/:id/photos/:photoId', editor, (req, res) => { + const part = deletePhoto(req.params.id, req.params.photoId, req.user.id); + if (!part) return res.status(404).json({ error: 'Photo not found' }); + return res.json({ part }); +}); + +module.exports = router; diff --git a/server/routes/pm.js b/server/routes/pm.js new file mode 100644 index 0000000..723a0f5 --- /dev/null +++ b/server/routes/pm.js @@ -0,0 +1,96 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { + assetPmRows, + attachPlan, + completePm, + createPlan, + deletePlan, + detachAssetPm, + getCompletion, + getPlan, + getSettings, + listCompletions, + listPlans, + saveSettings, + updateAssetPm, + updatePlan +} = require('../services/pm'); + +const router = express.Router(); +const planEditor = requireCapability('pm.manage'); // create/edit PM plan templates & defaults +const assetPm = requireCapability('pm.complete'); // attach to assets & complete services + +// PM plan templates +router.get('/pm-plans', (req, res) => { + res.json({ plans: listPlans() }); +}); + +router.get('/pm-plans/:id', (req, res) => { + const plan = getPlan(req.params.id); + if (!plan) return res.status(404).json({ error: 'PM plan not found' }); + return res.json({ plan }); +}); + +router.post('/pm-plans', planEditor, (req, res) => { + res.status(201).json({ plan: createPlan(req.body, req.user.id) }); +}); + +router.put('/pm-plans/:id', planEditor, (req, res) => { + const plan = updatePlan(req.params.id, req.body, req.user.id); + if (!plan) return res.status(404).json({ error: 'PM plan not found' }); + return res.json({ plan }); +}); + +router.delete('/pm-plans/:id', planEditor, (req, res) => { + if (!deletePlan(req.params.id, req.user.id)) return res.status(404).json({ error: 'PM plan not found' }); + return res.status(204).end(); +}); + +// PM defaults (readable by any authenticated user; pm.manage can change) +router.get('/pm-settings', (req, res) => { + res.json({ settings: getSettings() }); +}); + +router.put('/pm-settings', planEditor, (req, res) => { + res.json({ settings: saveSettings(req.body, req.user.id) }); +}); + +// Completed PM forms +router.get('/pm-completions', (req, res) => { + res.json({ completions: listCompletions(req.query) }); +}); + +router.get('/pm-completions/:id', (req, res) => { + const completion = getCompletion(req.params.id); + if (!completion) return res.status(404).json({ error: 'Completion not found' }); + return res.json({ completion }); +}); + +// Per-asset PM schedules +router.get('/assets/:id/pm', (req, res) => { + res.json({ pm: assetPmRows(req.params.id) }); +}); + +router.post('/assets/:id/pm', assetPm, (req, res) => { + res.status(201).json({ pm: attachPlan(req.params.id, req.body, req.user.id) }); +}); + +router.put('/assets/:id/pm/:apId', assetPm, (req, res) => { + const pm = updateAssetPm(req.params.id, req.params.apId, req.body, req.user.id); + if (!pm) return res.status(404).json({ error: 'PM schedule not found' }); + return res.json({ pm }); +}); + +router.delete('/assets/:id/pm/:apId', assetPm, (req, res) => { + if (!detachAssetPm(req.params.id, req.params.apId, req.user.id)) return res.status(404).json({ error: 'PM schedule not found' }); + return res.status(204).end(); +}); + +router.post('/assets/:id/pm/:apId/complete', assetPm, (req, res) => { + const pm = completePm(req.params.id, req.params.apId, req.body, req.user.id); + if (!pm) return res.status(404).json({ error: 'PM schedule not found' }); + return res.json({ pm }); +}); + +module.exports = router; diff --git a/server/routes/reference.js b/server/routes/reference.js index 25f635d..580cb83 100644 --- a/server/routes/reference.js +++ b/server/routes/reference.js @@ -1,14 +1,75 @@ const express = require('express'); -const { all, audit, now, one, parseJson, run } = require('../db'); -const { requireRole } = require('../middleware/auth'); +const { all, audit, json, now, one, parseJson, run } = require('../db'); +const { requireCapability } = require('../middleware/auth'); +const { formatAssetId } = require('../services/assets'); +const assetClasses = require('../services/assetClasses'); +const zones = require('../services/zones'); const router = express.Router(); +// Special depreciation zones (e.g. New York Liberty Zone). GET is reference data used by the +// asset form; mutations require config.manage. +router.get('/depreciation-zones', (req, res) => { + res.json({ zones: zones.list() }); +}); + +router.post('/depreciation-zones', requireCapability('config.manage'), (req, res, next) => { + try { + res.status(201).json({ zone: zones.createZone(req.body, req.user.id) }); + } catch (error) { + next(error); + } +}); + +router.put('/depreciation-zones/:code', requireCapability('config.manage'), (req, res, next) => { + try { + const zone = zones.updateZone(req.params.code, req.body, req.user.id); + if (!zone) return res.status(404).json({ error: 'Zone not found' }); + return res.json({ zone }); + } catch (error) { + return next(error); + } +}); + +router.delete('/depreciation-zones/:code', requireCapability('config.manage'), (req, res) => { + if (!zones.deleteZone(req.params.code, req.user.id)) return res.status(404).json({ error: 'Zone not found' }); + return res.status(204).end(); +}); + +// Asset class catalog (seeded from the IRS tables, then user-managed). GET is reference +// data used by the category picker; mutations require config.manage. +router.get('/asset-classes', (req, res) => { + res.json({ assetClasses: assetClasses.list() }); +}); + +router.post('/asset-classes', requireCapability('config.manage'), (req, res, next) => { + try { + res.status(201).json({ assetClass: assetClasses.createClass(req.body, req.user.id) }); + } catch (error) { + next(error); + } +}); + +router.put('/asset-classes/:id', requireCapability('config.manage'), (req, res, next) => { + try { + const assetClass = assetClasses.updateClass(req.params.id, req.body, req.user.id); + if (!assetClass) return res.status(404).json({ error: 'Asset class not found' }); + return res.json({ assetClass }); + } catch (error) { + return next(error); + } +}); + +router.delete('/asset-classes/:id', requireCapability('config.manage'), (req, res) => { + if (!assetClasses.deleteClass(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset class not found' }); + return res.status(204).end(); +}); + router.get('/entities', (req, res) => { res.json({ entities: all('SELECT * FROM entities ORDER BY name') }); }); -router.post('/entities', requireRole('admin', 'finance'), (req, res) => { +router.post('/entities', requireCapability('config.manage'), (req, res) => { const created = now(); const result = run( 'INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', @@ -31,4 +92,325 @@ router.get('/references', (req, res) => { res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) }); }); +// ---- Asset categories (managed reference_values of type 'category') ---------- +// Asset category is stored on each asset by name, so a rename cascades to assets. + +function categoryRow(row) { + const meta = parseJson(row.metadata, {}); + const templateId = meta.id_template_id || null; + const template = templateId ? one('SELECT name, pattern FROM asset_id_templates WHERE id = ?', [templateId]) : null; + return { + id: row.id, + name: row.name, + code: row.code, + asset_class_number: meta.asset_class_number || null, + id_template_id: template ? templateId : null, + id_template_name: template ? template.name : null, + id_template_pattern: template ? template.pattern : null, + asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [row.name]).c + }; +} + +const categoryEditor = requireCapability('config.manage'); + +router.get('/asset-categories', (req, res) => { + const rows = all("SELECT * FROM reference_values WHERE type = 'category' ORDER BY name"); + res.json({ categories: rows.map(categoryRow) }); +}); + +router.post('/asset-categories', categoryEditor, (req, res) => { + const name = String(req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Category name is required' }); + if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) { + return res.status(400).json({ error: 'That category already exists' }); + } + const result = run( + "INSERT INTO reference_values (type, name, code, metadata) VALUES ('category', ?, ?, ?)", + [name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_number: req.body.asset_class_number || null })] + ); + audit(req.user.id, 'create', 'asset_category', result.lastInsertRowid, null, { name }); + return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) }); +}); + +router.put('/asset-categories/:id', categoryEditor, (req, res) => { + const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]); + if (!before) return res.status(404).json({ error: 'Category not found' }); + const name = String(req.body.name ?? before.name).trim(); + if (!name) return res.status(400).json({ error: 'Category name is required' }); + if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) { + return res.status(400).json({ error: 'That category already exists' }); + } + const meta = parseJson(before.metadata, {}); + if (req.body.id_template_id !== undefined) meta.id_template_id = req.body.id_template_id || null; + if (req.body.asset_class_number !== undefined) meta.asset_class_number = req.body.asset_class_number || null; + run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]); + let renamed = 0; + if (name !== before.name) { + renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0; + } + // The class number is shared by every asset in the category — cascade any change. + if (req.body.asset_class_number !== undefined) { + run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ?', [meta.asset_class_number, now(), name]); + } + audit(req.user.id, 'update', 'asset_category', req.params.id, before, { name, renamed_assets: renamed }); + return res.json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed }); +}); + +router.delete('/asset-categories/:id', categoryEditor, (req, res) => { + const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]); + if (!before) return res.status(404).json({ error: 'Category not found' }); + const affected = one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [before.name]).c; + run('DELETE FROM reference_values WHERE id = ?', [req.params.id]); + audit(req.user.id, 'delete', 'asset_category', req.params.id, before, { asset_count: affected }); + return res.json({ deleted: true, asset_count: affected }); +}); + +// ---- Asset departments (managed reference_values of type 'department') ------- +// Department is stored on each asset by name, so a rename cascades to assets. + +function departmentRow(row) { + return { + id: row.id, + name: row.name, + code: row.code, + asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [row.name]).c + }; +} + +router.get('/asset-departments', (req, res) => { + const rows = all("SELECT * FROM reference_values WHERE type = 'department' ORDER BY name"); + res.json({ departments: rows.map(departmentRow) }); +}); + +router.post('/asset-departments', categoryEditor, (req, res) => { + const name = String(req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Department name is required' }); + if (one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) { + return res.status(400).json({ error: 'That department already exists' }); + } + const result = run( + "INSERT INTO reference_values (type, name, code, metadata) VALUES ('department', ?, ?, '{}')", + [name, req.body.code || null] + ); + audit(req.user.id, 'create', 'asset_department', result.lastInsertRowid, null, { name }); + return res.status(201).json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) }); +}); + +router.put('/asset-departments/:id', categoryEditor, (req, res) => { + const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]); + if (!before) return res.status(404).json({ error: 'Department not found' }); + const name = String(req.body.name ?? before.name).trim(); + if (!name) return res.status(400).json({ error: 'Department name is required' }); + if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) { + return res.status(400).json({ error: 'That department already exists' }); + } + run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]); + let renamed = 0; + if (name !== before.name) { + renamed = run('UPDATE assets SET department = ?, updated_at = ? WHERE department = ?', [name, now(), before.name]).changes || 0; + } + audit(req.user.id, 'update', 'asset_department', req.params.id, before, { name, renamed_assets: renamed }); + return res.json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed }); +}); + +router.delete('/asset-departments/:id', categoryEditor, (req, res) => { + const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]); + if (!before) return res.status(404).json({ error: 'Department not found' }); + const affected = one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [before.name]).c; + run('DELETE FROM reference_values WHERE id = ?', [req.params.id]); + audit(req.user.id, 'delete', 'asset_department', req.params.id, before, { asset_count: affected }); + return res.json({ deleted: true, asset_count: affected }); +}); + +// ---- PM categories (managed reference_values of type 'pm_category') ---------- +// Separate from asset categories. Stored on each PM plan by name, so a rename cascades. + +function pmCategoryRow(row) { + return { + id: row.id, + name: row.name, + code: row.code, + plan_count: one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [row.name]).c + }; +} + +router.get('/pm-categories', (req, res) => { + const rows = all("SELECT * FROM reference_values WHERE type = 'pm_category' ORDER BY name"); + res.json({ categories: rows.map(pmCategoryRow) }); +}); + +router.post('/pm-categories', categoryEditor, (req, res) => { + const name = String(req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Category name is required' }); + if (one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) { + return res.status(400).json({ error: 'That PM category already exists' }); + } + const result = run( + "INSERT INTO reference_values (type, name, code, metadata) VALUES ('pm_category', ?, ?, '{}')", + [name, req.body.code || null] + ); + audit(req.user.id, 'create', 'pm_category', result.lastInsertRowid, null, { name }); + return res.status(201).json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) }); +}); + +router.put('/pm-categories/:id', categoryEditor, (req, res) => { + const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]); + if (!before) return res.status(404).json({ error: 'PM category not found' }); + const name = String(req.body.name ?? before.name).trim(); + if (!name) return res.status(400).json({ error: 'Category name is required' }); + if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) { + return res.status(400).json({ error: 'That PM category already exists' }); + } + run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]); + let renamed = 0; + if (name !== before.name) { + renamed = run('UPDATE pm_plans SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0; + } + audit(req.user.id, 'update', 'pm_category', req.params.id, before, { name, renamed_plans: renamed }); + return res.json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_plans: renamed }); +}); + +router.delete('/pm-categories/:id', categoryEditor, (req, res) => { + const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]); + if (!before) return res.status(404).json({ error: 'PM category not found' }); + const affected = one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [before.name]).c; + run('DELETE FROM reference_values WHERE id = ?', [req.params.id]); + audit(req.user.id, 'delete', 'pm_category', req.params.id, before, { plan_count: affected }); + return res.json({ deleted: true, plan_count: affected }); +}); + +// ---- Part categories (managed reference_values of type 'part_category') ------ +// Separate from asset/PM categories. Stored on each part by name, so a rename cascades. + +function partCategoryRow(row) { + return { + id: row.id, + name: row.name, + code: row.code, + part_count: one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [row.name]).c + }; +} + +router.get('/part-categories', (req, res) => { + const rows = all("SELECT * FROM reference_values WHERE type = 'part_category' ORDER BY name"); + res.json({ categories: rows.map(partCategoryRow) }); +}); + +router.post('/part-categories', categoryEditor, (req, res) => { + const name = String(req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Category name is required' }); + if (one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) { + return res.status(400).json({ error: 'That part category already exists' }); + } + const result = run( + "INSERT INTO reference_values (type, name, code, metadata) VALUES ('part_category', ?, ?, '{}')", + [name, req.body.code || null] + ); + audit(req.user.id, 'create', 'part_category', result.lastInsertRowid, null, { name }); + return res.status(201).json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) }); +}); + +router.put('/part-categories/:id', categoryEditor, (req, res) => { + const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]); + if (!before) return res.status(404).json({ error: 'Part category not found' }); + const name = String(req.body.name ?? before.name).trim(); + if (!name) return res.status(400).json({ error: 'Category name is required' }); + if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) { + return res.status(400).json({ error: 'That part category already exists' }); + } + run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]); + let renamed = 0; + if (name !== before.name) { + renamed = run('UPDATE parts SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0; + } + audit(req.user.id, 'update', 'part_category', req.params.id, before, { name, renamed_parts: renamed }); + return res.json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_parts: renamed }); +}); + +router.delete('/part-categories/:id', categoryEditor, (req, res) => { + const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]); + if (!before) return res.status(404).json({ error: 'Part category not found' }); + const affected = one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [before.name]).c; + run('DELETE FROM reference_values WHERE id = ?', [req.params.id]); + audit(req.user.id, 'delete', 'part_category', req.params.id, before, { part_count: affected }); + return res.json({ deleted: true, part_count: affected }); +}); + +// ---- Asset ID templates ----------------------------------------------------- +// Naming patterns (e.g. 'DT-######') used to auto-assign asset tags. The first run of +// '#' is replaced with a zero-padded, auto-incrementing number. Assigned to categories. + +function idTemplateCategoryCount(templateId) { + return all("SELECT metadata FROM reference_values WHERE type = 'category'") + .filter((row) => parseJson(row.metadata, {}).id_template_id === templateId).length; +} + +function idTemplateRow(row) { + return { + id: row.id, + name: row.name, + pattern: row.pattern, + next_number: row.next_number, + preview: formatAssetId(row.pattern, row.next_number), + category_count: idTemplateCategoryCount(row.id) + }; +} + +router.get('/asset-id-templates', (req, res) => { + res.json({ templates: all('SELECT * FROM asset_id_templates ORDER BY name').map(idTemplateRow) }); +}); + +router.post('/asset-id-templates', categoryEditor, (req, res) => { + const name = String(req.body.name || '').trim(); + const pattern = String(req.body.pattern || '').trim(); + if (!name) return res.status(400).json({ error: 'A template name is required' }); + if (!pattern.includes('#')) return res.status(400).json({ error: 'Pattern must include at least one # for the number' }); + const next = Math.max(1, Number(req.body.next_number) || 1); + const ts = now(); + const result = run( + 'INSERT INTO asset_id_templates (name, pattern, next_number, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', + [name, pattern, next, ts, ts] + ); + audit(req.user.id, 'create', 'asset_id_template', result.lastInsertRowid, null, { name, pattern }); + return res.status(201).json({ template: idTemplateRow(one('SELECT * FROM asset_id_templates WHERE id = ?', [result.lastInsertRowid])) }); +}); + +router.put('/asset-id-templates/:id', categoryEditor, (req, res) => { + const before = one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id]); + if (!before) return res.status(404).json({ error: 'Template not found' }); + const pattern = req.body.pattern !== undefined ? String(req.body.pattern).trim() : before.pattern; + if (!pattern.includes('#')) return res.status(400).json({ error: 'Pattern must include at least one # for the number' }); + run( + 'UPDATE asset_id_templates SET name = ?, pattern = ?, next_number = ?, updated_at = ? WHERE id = ?', + [ + req.body.name !== undefined ? String(req.body.name).trim() || before.name : before.name, + pattern, + req.body.next_number !== undefined ? Math.max(1, Number(req.body.next_number) || 1) : before.next_number, + now(), + req.params.id + ] + ); + audit(req.user.id, 'update', 'asset_id_template', req.params.id, before, req.body); + return res.json({ template: idTemplateRow(one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id])) }); +}); + +router.delete('/asset-id-templates/:id', categoryEditor, (req, res) => { + const before = one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id]); + if (!before) return res.status(404).json({ error: 'Template not found' }); + const id = Number(req.params.id); + // Unassign from any categories that referenced it. + let unassigned = 0; + for (const row of all("SELECT id, metadata FROM reference_values WHERE type = 'category'")) { + const meta = parseJson(row.metadata, {}); + if (meta.id_template_id === id) { + meta.id_template_id = null; + run('UPDATE reference_values SET metadata = ? WHERE id = ?', [json(meta), row.id]); + unassigned += 1; + } + } + run('DELETE FROM asset_id_templates WHERE id = ?', [id]); + audit(req.user.id, 'delete', 'asset_id_template', id, before, { unassigned_categories: unassigned }); + return res.json({ deleted: true, category_count: unassigned }); +}); + module.exports = router; diff --git a/server/routes/reports.js b/server/routes/reports.js index 95bda17..c3a5385 100644 --- a/server/routes/reports.js +++ b/server/routes/reports.js @@ -1,12 +1,61 @@ const express = require('express'); +const { all, audit, json, now, one, parseJson, run } = require('../db'); +const { requireCapability } = require('../middleware/auth'); const { depreciationReport, monthlyDepreciationReport, writeDepreciationPdf } = require('../services/reports'); +const { catalog, runReport } = require('../services/reportEngine'); +const { exportReport } = require('../services/reportExport'); const router = express.Router(); +const EXPORT_EXTENSIONS = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf', json: 'json' }; + +router.get('/reports/catalog', (req, res) => { + res.json(catalog()); +}); + +router.post('/reports/run', (req, res) => { + res.json({ report: runReport(req.body.type, req.body.options || {}) }); +}); + +router.post('/reports/export', async (req, res) => { + const format = String(req.body.format || 'csv').toLowerCase(); + const report = runReport(req.body.type, req.body.options || {}); + const output = await exportReport(report, format); + const extension = EXPORT_EXTENSIONS[format] || 'txt'; + res.setHeader('Content-Type', output.contentType); + res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.body.type}.${extension}"`); + res.send(output.body); +}); + +router.get('/saved-reports', (req, res) => { + const reports = all( + `SELECT s.*, u.name AS created_by_name FROM saved_reports s + LEFT JOIN users u ON u.id = s.created_by ORDER BY s.name` + ).map((row) => ({ ...row, options_json: parseJson(row.options_json, {}) })); + res.json({ reports }); +}); + +router.post('/saved-reports', requireCapability('reports.save'), (req, res) => { + const result = run( + 'INSERT INTO saved_reports (name, report_type, options_json, created_by, created_at) VALUES (?, ?, ?, ?, ?)', + [req.body.name || 'Saved report', req.body.report_type, json(req.body.options || {}), req.user.id, now()] + ); + audit(req.user.id, 'create', 'saved_report', result.lastInsertRowid, null, req.body); + const saved = one('SELECT * FROM saved_reports WHERE id = ?', [result.lastInsertRowid]); + res.status(201).json({ report: { ...saved, options_json: parseJson(saved.options_json, {}) } }); +}); + +router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res) => { + const result = run('DELETE FROM saved_reports WHERE id = ?', [req.params.id]); + if (!result.changes) return res.status(404).json({ error: 'Saved report not found' }); + audit(req.user.id, 'delete', 'saved_report', req.params.id, null, null); + return res.status(204).end(); +}); + router.get('/reports/depreciation', (req, res) => { const year = Number(req.query.year || new Date().getFullYear()); const bookType = req.query.book || 'GAAP'; diff --git a/server/routes/servicenow.js b/server/routes/servicenow.js new file mode 100644 index 0000000..6bd9a49 --- /dev/null +++ b/server/routes/servicenow.js @@ -0,0 +1,31 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { publicConfig, saveConfig, syncCmdb, testConnection } = require('../services/servicenow'); + +const router = express.Router(); + +router.get('/servicenow/settings', requireCapability('admin.integrations'), (req, res) => { + res.json({ settings: publicConfig() }); +}); + +router.put('/servicenow/settings', requireCapability('admin.integrations'), (req, res) => { + res.json({ settings: saveConfig(req.body, req.user.id) }); +}); + +router.post('/servicenow/test', requireCapability('admin.integrations'), async (req, res, next) => { + try { + res.json(await testConnection(req.user.id)); + } catch (error) { + next(error); + } +}); + +router.post('/servicenow/cmdb-sync', requireCapability('assets.bulk'), async (req, res, next) => { + try { + res.json(await syncCmdb(req.user.id)); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/server/routes/taxRules.js b/server/routes/taxRules.js index 62ebe8a..c4f06b7 100644 --- a/server/routes/taxRules.js +++ b/server/routes/taxRules.js @@ -1,34 +1,120 @@ const express = require('express'); -const { all, audit, now, one, parseJson, run } = require('../db'); -const { requireRole } = require('../middleware/auth'); +const { all, audit, now, one, parseJson, run, tx } = require('../db'); +const { requireCapability } = require('../middleware/auth'); +const { expandRuleSet } = require('../rule-catalog'); const router = express.Router(); +function coerceRules(value) { + if (value === undefined || value === null) return {}; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + const error = new Error('rules_json is not valid JSON'); + error.status = 400; + throw error; + } + } + if (typeof value !== 'object') { + const error = new Error('rules_json must be a JSON object'); + error.status = 400; + throw error; + } + return value; +} + +function serialize(row) { + return { ...row, active: Boolean(row.active), rules_json: parseJson(row.rules_json, {}) }; +} + router.get('/tax-rules', (req, res) => { - const ruleSets = all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map((row) => ({ - ...row, - active: Boolean(row.active), - rules_json: parseJson(row.rules_json, {}) - })); + const ruleSets = all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map(serialize); res.json({ ruleSets }); }); -router.post('/tax-rules', requireRole('admin', 'finance'), (req, res) => { - const result = run( - 'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [ - req.body.jurisdiction, - req.body.name, - req.body.effective_date, - req.body.version, - JSON.stringify(req.body.rules_json || req.body.rules || {}), - req.body.active === false ? 0 : 1, - req.body.source_note || null, - now() - ] - ); - audit(req.user.id, 'create', 'tax_rule_set', result.lastInsertRowid, null, req.body); - res.status(201).json({ ruleSet: one('SELECT * FROM tax_rule_sets WHERE id = ?', [result.lastInsertRowid]) }); +router.post('/tax-rules', requireCapability('finance.manage'), (req, res, next) => { + try { + const rules = coerceRules(req.body.rules_json ?? req.body.rules); + const result = run( + 'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + req.body.jurisdiction || rules.jurisdiction || 'US-FED', + req.body.name || rules.name || 'Untitled rule set', + req.body.effective_date || rules.effectiveDate || rules.effective_date || new Date().toISOString().slice(0, 10), + req.body.version || rules.version || '1.0', + JSON.stringify(rules), + req.body.active === false ? 0 : 1, + req.body.source_note || rules.sourceNote || null, + now() + ] + ); + audit(req.user.id, 'create', 'tax_rule_set', result.lastInsertRowid, null, { ...req.body, rules_json: '[stored]' }); + res.status(201).json({ ruleSet: serialize(one('SELECT * FROM tax_rule_sets WHERE id = ?', [result.lastInsertRowid])) }); + } catch (error) { + next(error); + } +}); + +router.put('/tax-rules/:id', requireCapability('finance.manage'), (req, res, next) => { + try { + const before = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]); + if (!before) return res.status(404).json({ error: 'Rule set not found' }); + const rules = req.body.rules_json !== undefined || req.body.rules !== undefined + ? coerceRules(req.body.rules_json ?? req.body.rules) + : parseJson(before.rules_json, {}); + run( + `UPDATE tax_rule_sets SET + jurisdiction = ?, name = ?, effective_date = ?, version = ?, rules_json = ?, active = ?, source_note = ? + WHERE id = ?`, + [ + req.body.jurisdiction ?? before.jurisdiction, + req.body.name ?? before.name, + req.body.effective_date ?? before.effective_date, + req.body.version ?? before.version, + JSON.stringify(rules), + req.body.active === undefined ? before.active : (req.body.active ? 1 : 0), + req.body.source_note ?? before.source_note, + req.params.id + ] + ); + audit(req.user.id, 'update', 'tax_rule_set', req.params.id, { ...before, rules_json: '[stored]' }, { ...req.body, rules_json: '[stored]' }); + return res.json({ ruleSet: serialize(one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id])) }); + } catch (error) { + if (/UNIQUE/.test(error.message || '')) { + return res.status(400).json({ error: 'A rule set with this jurisdiction and version already exists' }); + } + return next(error); + } +}); + +router.delete('/tax-rules/:id', requireCapability('finance.manage'), (req, res) => { + const before = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]); + if (!before) return res.status(404).json({ error: 'Rule set not found' }); + run('DELETE FROM tax_rule_sets WHERE id = ?', [req.params.id]); + audit(req.user.id, 'delete', 'tax_rule_set', req.params.id, { ...before, rules_json: '[stored]' }, null); + return res.status(204).end(); +}); + +router.post('/tax-rules/:id/activate', requireCapability('finance.manage'), (req, res) => { + const target = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]); + if (!target) return res.status(404).json({ error: 'Rule set not found' }); + tx(() => { + run('UPDATE tax_rule_sets SET active = 0 WHERE jurisdiction = ? AND id != ?', [target.jurisdiction, target.id]); + run('UPDATE tax_rule_sets SET active = 1 WHERE id = ?', [target.id]); + }); + audit(req.user.id, 'activate', 'tax_rule_set', target.id, null, { jurisdiction: target.jurisdiction }); + return res.json({ ruleSets: all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map(serialize) }); +}); + +// Merge the generated 68-method depreciation catalog into a rule set (works on unsaved drafts too). +router.post('/tax-rules/expand', requireCapability('finance.manage'), (req, res, next) => { + try { + const rules = coerceRules(req.body.rules_json ?? req.body.rules); + return res.json({ rules_json: expandRuleSet(rules) }); + } catch (error) { + return next(error); + } }); module.exports = router; diff --git a/server/routes/templates.js b/server/routes/templates.js index c63dafe..61dadb5 100644 --- a/server/routes/templates.js +++ b/server/routes/templates.js @@ -1,11 +1,14 @@ const express = require('express'); const { all, audit, json, now, one, parseJson, run } = require('../db'); -const { requireRole } = require('../middleware/auth'); +const { requireCapability } = require('../middleware/auth'); const router = express.Router(); router.get('/templates', (req, res) => { - const templates = all('SELECT * FROM asset_templates ORDER BY name').map((row) => ({ + const templates = all( + `SELECT t.*, (SELECT COUNT(*) FROM assets a WHERE a.template_id = t.id) AS asset_count + FROM asset_templates t ORDER BY t.name` + ).map((row) => ({ ...row, defaults: parseJson(row.defaults, {}), custom_fields: parseJson(row.custom_fields, []) @@ -13,7 +16,7 @@ router.get('/templates', (req, res) => { res.json({ templates }); }); -router.post('/templates', requireRole('admin', 'finance'), (req, res) => { +router.post('/templates', requireCapability('config.manage'), (req, res) => { const created = now(); const result = run( 'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', @@ -23,4 +26,34 @@ router.post('/templates', requireRole('admin', 'finance'), (req, res) => { res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) }); }); +router.put('/templates/:id', requireCapability('config.manage'), (req, res) => { + const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]); + if (!before) return res.status(404).json({ error: 'Template not found' }); + run( + 'UPDATE asset_templates SET name = ?, description = ?, defaults = ?, custom_fields = ?, updated_at = ? WHERE id = ?', + [ + req.body.name ?? before.name, + req.body.description ?? before.description, + json(req.body.defaults || {}), + json(req.body.custom_fields || []), + now(), + req.params.id + ] + ); + audit(req.user.id, 'update', 'asset_template', req.params.id, before, req.body); + return res.json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]) }); +}); + +// Deleting a template unlinks it from any assets (their stored field values are kept) +// and removes it from the picker for future assets. +router.delete('/templates/:id', requireCapability('config.manage'), (req, res) => { + const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]); + if (!before) return res.status(404).json({ error: 'Template not found' }); + const affected = one('SELECT COUNT(*) AS c FROM assets WHERE template_id = ?', [req.params.id]).c; + run('UPDATE assets SET template_id = NULL, updated_at = ? WHERE template_id = ?', [now(), req.params.id]); + run('DELETE FROM asset_templates WHERE id = ?', [req.params.id]); + audit(req.user.id, 'delete', 'asset_template', req.params.id, before, { unlinked_assets: affected }); + return res.json({ deleted: true, unlinked_assets: affected }); +}); + module.exports = router; diff --git a/server/routes/workday.js b/server/routes/workday.js index b48aecf..705e2e1 100644 --- a/server/routes/workday.js +++ b/server/routes/workday.js @@ -1,5 +1,5 @@ const express = require('express'); -const { requireRole } = require('../middleware/auth'); +const { requireCapability } = require('../middleware/auth'); const { getConnection, importWorkersFromPayload, @@ -9,15 +9,15 @@ const { const router = express.Router(); -router.get('/workday/connection', requireRole('admin'), (req, res) => { +router.get('/workday/connection', requireCapability('admin.integrations'), (req, res) => { res.json({ connection: getConnection() }); }); -router.put('/workday/connection', requireRole('admin'), (req, res) => { +router.put('/workday/connection', requireCapability('admin.integrations'), (req, res) => { res.json({ connection: saveConnection(req.body, req.user.id) }); }); -router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next) => { +router.post('/workday/sync-workers', requireCapability('admin.integrations'), async (req, res, next) => { try { res.json(await syncWorkers(req.user.id)); } catch (error) { @@ -25,7 +25,7 @@ router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next } }); -router.post('/workday/import-workers', requireRole('admin'), (req, res) => { +router.post('/workday/import-workers', requireCapability('admin.integrations'), (req, res) => { res.json(importWorkersFromPayload(req.body.workers || req.body, req.user.id)); }); diff --git a/server/rule-catalog.js b/server/rule-catalog.js index 5c9dcef..f1d3297 100644 --- a/server/rule-catalog.js +++ b/server/rule-catalog.js @@ -111,8 +111,35 @@ function buildSpecialMethods() { ]; } +// Sage-style 200% declining-balance method codes. The MACRS family (MF/MT/MI) ignores +// salvage value and recovers the full basis; the standard family (DB/DH/DD) honors salvage +// (depreciates only down to it). All use a 200% rate and switch to straight-line. +function build200Methods() { + return [ + method('MF200', 'MACRS', 'MF200 — MACRS Formula, 200% DB (GDS)', 'macrs_declining_balance', { + system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true + }), + method('MT200', 'MACRS', 'MT200 — MACRS Tables, 200% DB (GDS)', 'macrs_declining_balance', { + system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true, usesTables: true + }), + method('MI200', 'MACRS', 'MI200 — MACRS 200% DB, Indian Reservation', 'macrs_declining_balance', { + system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true, indianReservation: true + }), + method('DB200', 'Declining Balance', 'DB200 — 200% Declining Balance to SL (uses book convention)', 'declining_balance', { + rateMultiplier: 2, switchToStraightLine: true + }), + method('DH200', 'Declining Balance', 'DH200 — 200% Declining Balance to SL, Half-Year', 'declining_balance', { + rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year' + }), + method('DD200', 'Declining Balance', 'DD200 — 200% Declining Balance to SL, Full-Year', 'declining_balance', { + rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'none' + }) + ]; +} + function buildDepreciationMethods() { return [ + ...build200Methods(), ...buildMacrsMethods(), ...buildAcrsMethods(), ...buildGaapMethods(), diff --git a/server/services/accessControl.js b/server/services/accessControl.js index 6de311f..d4a1051 100644 --- a/server/services/accessControl.js +++ b/server/services/accessControl.js @@ -1,47 +1,81 @@ -const roles = ['admin', 'finance', 'operations', 'viewer']; +// Capability catalog — the granular permissions the application understands. Routes are +// guarded by these keys (see middleware/auth requireCapability); roles are sets of them. +// This is pure data (no DB) so it can be required anywhere without circular dependencies. -const rolePermissions = { - admin: [ - 'Manage users, teams, roles, settings, and integrations', - 'Create, edit, delete, import, export, and assign assets', - 'Manage templates, tax rules, reports, and Workday sync' - ], - finance: [ - 'Create, edit, delete, import, and export assets', - 'Manage templates, tax rules, reports, and depreciation workflows', - 'Assign and release assets' - ], - operations: [ - 'Create and edit assets', - 'Assign and release assets', - 'Manage asset tasks, files, employees, and custody details' - ], - viewer: [ - 'View assets, assignments, employees, templates, tax rules, and reports', - 'No create, update, delete, import, export, or integration access' - ] -}; - -const roleCapabilities = [ - { key: 'view_assets', label: 'View assets', roles: ['admin', 'finance', 'operations', 'viewer'] }, - { key: 'edit_assets', label: 'Create/edit assets', roles: ['admin', 'finance', 'operations'] }, - { key: 'delete_assets', label: 'Delete assets', roles: ['admin', 'finance'] }, - { key: 'import_export', label: 'Import/export data', roles: ['admin', 'finance'] }, - { key: 'assign_assets', label: 'Assign assets', roles: ['admin', 'finance', 'operations'] }, - { key: 'templates', label: 'Manage templates', roles: ['admin', 'finance'] }, - { key: 'tax_rules', label: 'Manage tax rules', roles: ['admin', 'finance'] }, - { key: 'workday', label: 'Manage Workday connector', roles: ['admin'] }, - { key: 'users', label: 'Manage users and teams', roles: ['admin'] }, - { key: 'settings', label: 'Manage app settings', roles: ['admin'] } +const CAPABILITIES = [ + // Assets + { key: 'assets.view', label: 'View assets', group: 'Assets' }, + { key: 'assets.edit', label: 'Create & edit assets (files, photos, warranties, tasks, notes)', group: 'Assets' }, + { key: 'assets.delete', label: 'Delete assets', group: 'Assets' }, + { key: 'assets.bulk', label: 'Import, export, mass-update & CMDB sync', group: 'Assets' }, + { key: 'assets.assign', label: 'Assign assets & manage employees', group: 'Assets' }, + // Maintenance + { key: 'pm.view', label: 'View maintenance', group: 'Maintenance' }, + { key: 'pm.manage', label: 'Create & edit PM plans and defaults', group: 'Maintenance' }, + { key: 'pm.complete', label: 'Attach & complete PM on assets', group: 'Maintenance' }, + { key: 'parts.manage', label: 'Manage parts inventory', group: 'Maintenance' }, + { key: 'alerts.manage', label: 'Work alerts (scan, acknowledge, dismiss, ticket)', group: 'Maintenance' }, + // Contacts + { key: 'contacts.manage', label: 'Manage contacts & CRM', group: 'Contacts' }, + // Finance + { key: 'finance.view', label: 'View reports, books & tax rules', group: 'Finance' }, + { key: 'finance.manage', label: 'Manage books, tax rules, disposals & leases', group: 'Finance' }, + { key: 'reports.save', label: 'Save & delete reports', group: 'Finance' }, + // Configuration + { key: 'config.manage', label: 'Manage templates, categories & ID templates', group: 'Configuration' }, + // Administration + { key: 'admin.users', label: 'Manage users & teams', group: 'Administration' }, + { key: 'admin.roles', label: 'Manage roles', group: 'Administration' }, + { key: 'admin.settings', label: 'Manage application settings', group: 'Administration' }, + { key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' } ]; -function isValidRole(role) { - return roles.includes(role); +const CAPABILITY_KEYS = CAPABILITIES.map((c) => c.key); + +// '*' is a wildcard granting every capability (used by the admin role and future-proofs new ones). +const WILDCARD = '*'; + +const FINANCE_CAPS = [ + 'assets.view', 'assets.edit', 'assets.delete', 'assets.bulk', 'assets.assign', + 'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage', + 'contacts.manage', 'finance.view', 'finance.manage', 'reports.save', 'config.manage' +]; + +const OPERATIONS_CAPS = [ + 'assets.view', 'assets.edit', 'assets.assign', + 'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage', + 'contacts.manage', 'reports.save' +]; + +// Built-in roles. is_system roles cannot be deleted (admin/built-ins) but their +// capabilities can be edited, except admin which always keeps the wildcard. +const DEFAULT_ROLES = [ + { key: 'admin', name: 'Administrator', description: 'Full system access, including users, roles, settings, and integrations.', capabilities: [WILDCARD], is_system: 1, locked: 1, sort_order: 0 }, + { key: 'finance', name: 'Finance', description: 'Full asset, maintenance, and financial management; no system administration.', capabilities: FINANCE_CAPS, is_system: 1, locked: 0, sort_order: 1 }, + { key: 'operations', name: 'Operations', description: 'Day-to-day asset, maintenance, parts, contacts, and alert work.', capabilities: OPERATIONS_CAPS, is_system: 1, locked: 0, sort_order: 2 }, + { key: 'pm_admin', name: 'PM Admin', description: 'Manages preventative maintenance: plans, defaults, completion, parts, and alerts.', capabilities: ['assets.view', 'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage', 'reports.save'], is_system: 1, locked: 0, sort_order: 3 }, + { key: 'pm_user', name: 'PM User', description: 'Completes preventative maintenance and views assets, parts, and alerts.', capabilities: ['assets.view', 'pm.view', 'pm.complete', 'alerts.manage'], is_system: 1, locked: 0, sort_order: 4 }, + { key: 'viewer', name: 'Viewer', description: 'Read-only access to assets, maintenance, and reports.', capabilities: ['assets.view', 'pm.view', 'finance.view'], is_system: 1, locked: 0, sort_order: 5 } +]; + +// Does a capability set satisfy a required capability? Honors the wildcard. +function capabilitySetIncludes(capabilities, required) { + if (!Array.isArray(capabilities)) return false; + return capabilities.includes(WILDCARD) || capabilities.includes(required); +} + +// Normalize an arbitrary capability list to known keys (admin/wildcard preserved). +function sanitizeCapabilities(list) { + if (Array.isArray(list) && list.includes(WILDCARD)) return [WILDCARD]; + const set = new Set((Array.isArray(list) ? list : []).filter((key) => CAPABILITY_KEYS.includes(key))); + return CAPABILITY_KEYS.filter((key) => set.has(key)); } module.exports = { - isValidRole, - roleCapabilities, - rolePermissions, - roles + CAPABILITIES, + CAPABILITY_KEYS, + DEFAULT_ROLES, + WILDCARD, + capabilitySetIncludes, + sanitizeCapabilities }; diff --git a/server/services/accessControl.test.js b/server/services/accessControl.test.js new file mode 100644 index 0000000..e3a75f5 --- /dev/null +++ b/server/services/accessControl.test.js @@ -0,0 +1,45 @@ +/* + * Tests for the capability catalog and default roles used by the RBAC system. + * Roles are stored in the database and editable at runtime; this validates the + * static catalog/defaults and the pure capability-matching helpers. + */ + +const accessControl = require('./accessControl'); + +describe('access control', () => { + test('capability catalog is well-formed', () => { + const { CAPABILITIES, CAPABILITY_KEYS } = accessControl; + expect(CAPABILITIES.length).toBeGreaterThan(0); + for (const cap of CAPABILITIES) { + expect(typeof cap.key).toBe('string'); + expect(typeof cap.label).toBe('string'); + expect(typeof cap.group).toBe('string'); + } + // Keys are unique. + expect(new Set(CAPABILITY_KEYS).size).toBe(CAPABILITY_KEYS.length); + }); + + test('default roles include the built-ins and PM roles', () => { + const keys = accessControl.DEFAULT_ROLES.map((r) => r.key); + for (const key of ['admin', 'finance', 'operations', 'pm_admin', 'pm_user', 'viewer']) { + expect(keys).toContain(key); + } + const admin = accessControl.DEFAULT_ROLES.find((r) => r.key === 'admin'); + expect(admin.capabilities).toEqual(['*']); + + const pmUser = accessControl.DEFAULT_ROLES.find((r) => r.key === 'pm_user'); + expect(pmUser.capabilities).toContain('pm.complete'); + expect(pmUser.capabilities).not.toContain('pm.manage'); + }); + + test('capabilitySetIncludes honors the wildcard', () => { + expect(accessControl.capabilitySetIncludes(['*'], 'anything')).toBe(true); + expect(accessControl.capabilitySetIncludes(['assets.view'], 'assets.view')).toBe(true); + expect(accessControl.capabilitySetIncludes(['assets.view'], 'assets.delete')).toBe(false); + }); + + test('sanitizeCapabilities drops unknown keys and collapses wildcard', () => { + expect(accessControl.sanitizeCapabilities(['assets.view', 'made.up'])).toEqual(['assets.view']); + expect(accessControl.sanitizeCapabilities(['assets.view', '*'])).toEqual(['*']); + }); +}); diff --git a/server/services/alerts.js b/server/services/alerts.js new file mode 100644 index 0000000..982b45b --- /dev/null +++ b/server/services/alerts.js @@ -0,0 +1,213 @@ +const { all, audit, now, one, run } = require('../db'); +const { getConfig, sendMail } = require('./notifications'); +const { deliverAlerts } = require('./webhooks'); +const servicenow = require('./servicenow'); +const { pmAlertCandidates } = require('./pm'); + +function todayStr() { + return new Date().toISOString().slice(0, 10); +} + +function daysUntil(dateStr) { + return Math.round((new Date(`${dateStr}T00:00:00`) - new Date(`${todayStr()}T00:00:00`)) / 86400000); +} + +function mk(type, referenceType, referenceId, assetId, severity, title, message, dueDate) { + return { type, reference_type: referenceType, reference_id: referenceId, asset_id: assetId, severity, title, message, due_date: dueDate }; +} + +// Compute the current set of due/overdue/expiring items that warrant an alert. +function candidates(leadDays) { + const today = todayStr(); + const horizon = new Date(Date.now() + leadDays * 86400000).toISOString().slice(0, 10); + const list = []; + + const tasks = all( + `SELECT t.*, a.asset_id AS asset_code FROM tasks t LEFT JOIN assets a ON a.id = t.asset_id + WHERE t.status != 'complete' AND t.due_date IS NOT NULL` + ); + for (const task of tasks) { + const where = task.asset_code ? ` on ${task.asset_code}` : ''; + if (task.due_date < today) { + list.push(mk('maintenance_overdue', 'task', task.id, task.asset_id, 'critical', `Overdue: ${task.title}`, `"${task.title}"${where} was due ${task.due_date}.`, task.due_date)); + } else if (task.due_date <= horizon) { + list.push(mk('maintenance_due', 'task', task.id, task.asset_id, 'warning', `Upcoming: ${task.title}`, `"${task.title}"${where} is due ${task.due_date}.`, task.due_date)); + } + } + + const warranties = all( + `SELECT w.*, a.asset_id AS asset_code FROM warranties w LEFT JOIN assets a ON a.id = w.asset_id + WHERE w.expiration_date IS NOT NULL` + ); + for (const w of warranties) { + const label = w.provider || 'Warranty'; + const where = w.asset_code ? ` on ${w.asset_code}` : ''; + if (w.expiration_date < today) { + list.push(mk('warranty_expired', 'warranty', w.id, w.asset_id, 'critical', 'Warranty expired', `${label}${where} expired ${w.expiration_date}.`, w.expiration_date)); + } else if (w.expiration_date <= horizon) { + const sev = daysUntil(w.expiration_date) <= 7 ? 'critical' : 'warning'; + list.push(mk('warranty_expiring', 'warranty', w.id, w.asset_id, sev, 'Warranty expiring', `${label}${where} expires ${w.expiration_date}.`, w.expiration_date)); + } + } + + const leases = all( + `SELECT l.*, a.asset_id AS asset_code FROM leases l LEFT JOIN assets a ON a.id = l.asset_id + WHERE l.end_date IS NOT NULL` + ); + for (const l of leases) { + if (l.end_date < today || l.end_date > horizon) continue; + const where = l.asset_code ? ` on ${l.asset_code}` : ''; + const sev = daysUntil(l.end_date) <= 7 ? 'critical' : 'warning'; + list.push(mk('lease_expiring', 'lease', l.id, l.asset_id, sev, 'Lease ending', `Lease with ${l.lessor || 'lessor'}${where} ends ${l.end_date}.`, l.end_date)); + } + + list.push(...pmAlertCandidates()); + + return list; +} + +// Reconcile computed candidates against stored alerts: create new, refresh existing, +// resolve ones that no longer apply, and respect dismissals. +function scanAlerts(userId) { + const cfg = getConfig(); + const ts = now(); + const cands = candidates(cfg.leadDays); + const seen = new Set(); + const created = []; + + for (const c of cands) { + seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`); + const existing = one('SELECT * FROM alerts WHERE reference_type = ? AND reference_id = ? AND type = ?', [c.reference_type, c.reference_id, c.type]); + if (existing) { + if (existing.status === 'dismissed') continue; + run( + `UPDATE alerts SET asset_id = ?, severity = ?, title = ?, message = ?, due_date = ?, + status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ? + WHERE id = ?`, + [c.asset_id, c.severity, c.title, c.message, c.due_date, ts, existing.id] + ); + } else { + const result = run( + `INSERT INTO alerts (type, reference_type, reference_id, asset_id, severity, title, message, due_date, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`, + [c.type, c.reference_type, c.reference_id, c.asset_id, c.severity, c.title, c.message, c.due_date, ts, ts] + ); + created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid])); + } + } + + for (const alert of all("SELECT * FROM alerts WHERE status IN ('open', 'acknowledged')")) { + if (!seen.has(`${alert.reference_type}:${alert.reference_id}:${alert.type}`)) { + run("UPDATE alerts SET status = 'resolved', updated_at = ? WHERE id = ?", [ts, alert.id]); + } + } + + if (userId) audit(userId, 'scan', 'alerts', null, null, { created: created.length }); + return { created, openCount: one("SELECT COUNT(*) AS c FROM alerts WHERE status = 'open'").c }; +} + +function listAlerts(query = {}) { + return all( + `SELECT al.*, a.asset_id AS asset_code, a.description AS asset_description, u.name AS acknowledged_by_name + FROM alerts al + LEFT JOIN assets a ON a.id = al.asset_id + LEFT JOIN users u ON u.id = al.acknowledged_by + WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?) + ORDER BY CASE al.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, al.due_date IS NULL, al.due_date`, + [query.status || null, query.status || null, query.type || null, query.type || null] + ); +} + +function summary() { + const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity"); + const open = rows.filter((r) => r.status === 'open'); + return { + open: open.reduce((s, r) => s + r.count, 0), + critical: open.filter((r) => r.severity === 'critical').reduce((s, r) => s + r.count, 0), + acknowledged: rows.filter((r) => r.status === 'acknowledged').reduce((s, r) => s + r.count, 0) + }; +} + +function setStatus(id, status, userId) { + const before = one('SELECT * FROM alerts WHERE id = ?', [id]); + if (!before) return null; + run( + 'UPDATE alerts SET status = ?, acknowledged_by = ?, acknowledged_at = ?, updated_at = ? WHERE id = ?', + [ + status, + status === 'acknowledged' ? userId : before.acknowledged_by, + status === 'acknowledged' ? now() : before.acknowledged_at, + now(), + id + ] + ); + audit(userId, status, 'alert', id, before, { status }); + return one('SELECT * FROM alerts WHERE id = ?', [id]); +} + +function digestHtml(alerts) { + const rows = alerts.map((a) => + `${a.severity.toUpperCase()}` + + `${a.title}${a.message || ''}` + + `${a.due_date || ''}` + ).join(''); + return `

MixedAssets alerts

${alerts.length} item(s) need attention.

` + + `` + + `` + + `${rows}
SeverityAlertDetailDue
`; +} + +function digestText(alerts) { + return `MixedAssets alerts (${alerts.length}):\n` + + alerts.map((a) => `- [${a.severity}] ${a.title} — ${a.message || ''} ${a.due_date ? `(due ${a.due_date})` : ''}`).join('\n'); +} + +// Scan, then notify on newly raised, not-yet-notified open alerts: an email digest +// (when SMTP is enabled) and/or a per-alert JSON webhook POST (when enabled). An alert +// is marked notified once any channel has handled it, so it is delivered at most once. +async function runAlertCycle(userId) { + const { created } = scanAlerts(userId); + const cfg = getConfig(); + const result = { created: created.length, emailed: false, webhooked: false }; + + const snCfg = servicenow.getConfig(); + const emailReady = cfg.enabled && cfg.host && cfg.recipients.length; + const webhookReady = cfg.webhookEnabled && cfg.webhookUrl; + const ticketReady = snCfg.ticketEnabled && snCfg.autoTicket && snCfg.instanceUrl; + if (!emailReady && !webhookReady && !ticketReady) return result; + + const pending = all( + `SELECT al.*, a.asset_id AS asset_code + FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id + WHERE al.status = 'open' AND al.notified_at IS NULL` + ); + if (!pending.length) return result; + + if (emailReady) { + await sendMail({ + subject: `MixedAssets: ${pending.length} new alert(s)`, + html: digestHtml(pending), + text: digestText(pending) + }); + result.emailed = true; + result.count = pending.length; + } + + if (webhookReady) { + const delivery = await deliverAlerts(pending); + result.webhooked = delivery.attempted; + result.delivered = delivery.delivered; + result.failed = delivery.failed; + } + + if (ticketReady) { + const tickets = await servicenow.autoTicketAlerts(pending, userId); + result.ticketed = tickets.created; + } + + const ts = now(); + for (const alert of pending) run('UPDATE alerts SET notified_at = ? WHERE id = ?', [ts, alert.id]); + return result; +} + +module.exports = { listAlerts, runAlertCycle, scanAlerts, setStatus, summary }; diff --git a/server/services/alerts.test.js b/server/services/alerts.test.js new file mode 100644 index 0000000..0ca9906 --- /dev/null +++ b/server/services/alerts.test.js @@ -0,0 +1,30 @@ +let alerts = require('./alerts') + +describe('alerts', () => { + test('creates and lists alerts correctly', () => { + const c1 = { + type: 'test_alert', + reference_type: 'asset', + reference_id: 1, + severity: 'warning', + title: 'Test Alert 1', + message: 'This is a test alert for asset 1' + }; + const c2 = { + type: 'test_alert', + reference_type: 'asset', + reference_id: 2, + severity: 'critical', + title: 'Test Alert 2', + message: 'This is a test alert for asset 2' + }; + const result = alerts.scan([c1, c2], 1); + expect(result.created.length).toBe(2); + expect(result.openCount).toBeGreaterThanOrEqual(2); + + const list = alerts.listAlerts({ status: 'open', type: 'test_alert' }); + expect(list.length).toBeGreaterThanOrEqual(2); + expect(list.some(a => a.reference_id === 1 && a.severity === 'warning')).toBe(true); + expect(list.some(a => a.reference_id === 2 && a.severity === 'critical')).toBe(true); + }); +}); diff --git a/server/services/assetClasses.js b/server/services/assetClasses.js new file mode 100644 index 0000000..6680f56 --- /dev/null +++ b/server/services/assetClasses.js @@ -0,0 +1,93 @@ +const { all, audit, now, one, parseJson, run } = require('../db'); + +const SOURCES = ['common', 'industry', 'business', 'custom']; + +// How many asset categories reference a given class number (categories store it in metadata). +function categoryUsage() { + const counts = {}; + for (const row of all("SELECT metadata FROM reference_values WHERE type = 'category'")) { + const num = parseJson(row.metadata, {}).asset_class_number; + if (num) counts[num] = (counts[num] || 0) + 1; + } + return counts; +} + +function fromRow(row, usage) { + if (!row) return null; + return { + id: row.id, + class_number: row.class_number, + description: row.description, + gds: row.gds, + ads: row.ads, + source: row.source, + table: row.source, // back-compat alias used by the category picker + category_count: row.class_number ? (usage[row.class_number] || 0) : 0 + }; +} + +function list() { + const usage = categoryUsage(); + return all('SELECT * FROM asset_classes ORDER BY description').map((row) => fromRow(row, usage)); +} + +function getClass(id) { + return fromRow(one('SELECT * FROM asset_classes WHERE id = ?', [id]), categoryUsage()); +} + +function normalize(body) { + const num = (value) => { + if (value === '' || value === null || value === undefined) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + }; + return { + class_number: body.class_number ? String(body.class_number).trim() : null, + description: String(body.description || '').trim(), + gds: num(body.gds), + ads: num(body.ads), + source: SOURCES.includes(body.source) ? body.source : 'custom' + }; +} + +function createClass(body, userId) { + const input = normalize(body); + if (!input.description) throw Object.assign(new Error('A description is required'), { status: 400 }); + const ts = now(); + const result = run( + 'INSERT INTO asset_classes (class_number, description, gds, ads, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + [input.class_number, input.description, input.gds, input.ads, input.source, ts, ts] + ); + audit(userId, 'create', 'asset_class', result.lastInsertRowid, null, input); + return getClass(result.lastInsertRowid); +} + +function updateClass(id, body, userId) { + const before = one('SELECT * FROM asset_classes WHERE id = ?', [id]); + if (!before) return null; + const input = normalize({ ...before, ...body }); + if (!input.description) throw Object.assign(new Error('A description is required'), { status: 400 }); + run( + 'UPDATE asset_classes SET class_number = ?, description = ?, gds = ?, ads = ?, source = ?, updated_at = ? WHERE id = ?', + [input.class_number, input.description, input.gds, input.ads, input.source, now(), id] + ); + audit(userId, 'update', 'asset_class', id, before, input); + return getClass(id); +} + +function deleteClass(id, userId) { + const before = one('SELECT * FROM asset_classes WHERE id = ?', [id]); + if (!before) return false; + run('DELETE FROM asset_classes WHERE id = ?', [id]); + audit(userId, 'delete', 'asset_class', id, before, null); + return true; +} + +module.exports = { + SOURCES, + createClass, + deleteClass, + getClass, + list, + updateClass +}; diff --git a/server/services/assets.js b/server/services/assets.js index 37ed21a..fa5a9a4 100644 --- a/server/services/assets.js +++ b/server/services/assets.js @@ -1,4 +1,5 @@ const { all, audit, json, now, one, parseJson, run, tx } = require('../db'); +const { assetPmRows } = require('./pm'); const assetColumns = [ 'asset_id', @@ -35,12 +36,24 @@ const assetColumns = [ 'disposal_expense', 'disposal_type', 'property_type', + 'special_zone', 'notes', 'custom_fields' ]; const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER']; +// Depreciation books currently enabled in the registry (excludes the maintenance/PM book). +function enabledBookCodes() { + try { + const rows = all("SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' ORDER BY sort_order, id"); + if (rows.length) return rows.map((row) => row.code); + } catch { + // books table may not exist on a very old database + } + return defaultBooks; +} + function assetFromRow(row) { if (!row) return null; return { @@ -51,6 +64,36 @@ function assetFromRow(row) { }; } +function assetPhotos(id) { + return all('SELECT id, name, mime_type, caption, data_base64 FROM asset_photos WHERE asset_id = ? ORDER BY sort_order, id', [id]) + .map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, caption: p.caption, data: p.data_base64 })); +} + +function addAssetPhoto(assetId, body, userId) { + if (!one('SELECT id FROM assets WHERE id = ?', [assetId])) return null; + if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 }); + const result = run( + 'INSERT INTO asset_photos (asset_id, name, mime_type, caption, data_base64, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + [assetId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.caption || null, body.data, userId, now()] + ); + audit(userId, 'create', 'asset_photo', result.lastInsertRowid, null, { asset_id: assetId, name: body.name }); + return one('SELECT id, name, mime_type, caption FROM asset_photos WHERE id = ?', [result.lastInsertRowid]); +} + +function updateAssetPhoto(assetId, photoId, body, userId) { + const result = run('UPDATE asset_photos SET caption = ? WHERE id = ? AND asset_id = ?', [body.caption || null, photoId, assetId]); + if (!result.changes) return null; + audit(userId, 'update', 'asset_photo', photoId, null, { asset_id: assetId }); + return one('SELECT id, name, mime_type, caption FROM asset_photos WHERE id = ?', [photoId]); +} + +function deleteAssetPhoto(assetId, photoId, userId) { + const result = run('DELETE FROM asset_photos WHERE id = ? AND asset_id = ?', [photoId, assetId]); + if (!result.changes) return false; + audit(userId, 'delete', 'asset_photo', photoId, null, { asset_id: assetId }); + return true; +} + function hydrateAsset(id) { const asset = assetFromRow(one('SELECT * FROM assets WHERE id = ?', [id])); if (!asset) return null; @@ -60,9 +103,13 @@ function hydrateAsset(id) { manual_depreciation: parseJson(book.manual_depreciation, {}) })); asset.leases = all('SELECT * FROM leases WHERE asset_id = ? ORDER BY start_date DESC', [id]); + asset.disposals = all('SELECT * FROM disposals WHERE asset_id = ? ORDER BY disposal_date DESC, id DESC', [id]); + asset.adjustments = all('SELECT * FROM asset_adjustments WHERE asset_id = ? ORDER BY adjustment_date DESC, id DESC', [id]); + asset.pm = assetPmRows(id); asset.tasks = all('SELECT * FROM tasks WHERE asset_id = ? ORDER BY due_date IS NULL, due_date', [id]); asset.warranties = all('SELECT id, provider, phone, start_date, expiration_date, warranty_limit, actual_usage, coverage_details, document_name, created_at FROM warranties WHERE asset_id = ?', [id]); asset.files = all('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE asset_id = ?', [id]); + asset.photos = assetPhotos(id); asset.notes_log = all( `SELECT n.id, n.body, n.created_at, u.name AS author FROM asset_notes n @@ -74,12 +121,75 @@ function hydrateAsset(id) { return asset; } +function lookupAssetByCode(code) { + const value = String(code || '').trim(); + if (!value) return null; + const row = one( + `SELECT id FROM assets + WHERE barcode_value = ? COLLATE NOCASE + OR asset_id = ? COLLATE NOCASE + OR serial_number = ? COLLATE NOCASE + ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id + LIMIT 1`, + [value, value, value, value] + ); + return row ? hydrateAsset(row.id) : null; +} + function nextAssetId() { const latest = one("SELECT asset_id FROM assets WHERE asset_id LIKE 'MA-%' ORDER BY id DESC LIMIT 1"); const number = latest ? Number(String(latest.asset_id).replace('MA-', '')) + 1 : 1; return `MA-${String(number).padStart(5, '0')}`; } +// Replace the first run of '#' in a pattern with the zero-padded number. +// e.g. formatAssetId('DT-######', 42) -> 'DT-000042' +function formatAssetId(pattern, n) { + return String(pattern).replace(/#+/, (hashes) => String(n).padStart(hashes.length, '0')); +} + +// If the asset's category has an assigned ID template, generate the next unique tag from it +// and advance the template's counter. Returns null when the category has no template. +function nextAssetIdFromCategory(categoryName) { + if (!categoryName) return null; + let templateId = null; + try { + const category = one("SELECT metadata FROM reference_values WHERE type = 'category' AND name = ?", [categoryName]); + templateId = category ? parseJson(category.metadata, {}).id_template_id : null; + } catch { + return null; + } + if (!templateId) return null; + const template = one('SELECT * FROM asset_id_templates WHERE id = ?', [templateId]); + if (!template || !/#/.test(template.pattern || '')) return null; + let n = Number(template.next_number) || 1; + let candidate = formatAssetId(template.pattern, n); + while (one('SELECT id FROM assets WHERE asset_id = ?', [candidate])) { + n += 1; + candidate = formatAssetId(template.pattern, n); + } + run('UPDATE asset_id_templates SET next_number = ?, updated_at = ? WHERE id = ?', [n + 1, now(), templateId]); + return candidate; +} + +// Resolve the asset tag for a new asset: an explicit value wins; otherwise use the +// category's ID template, falling back to the default MA-##### sequence. +function resolveNewAssetId(input) { + if (input.asset_id) return input.asset_id; + return nextAssetIdFromCategory(input.category) || nextAssetId(); +} + +// The Asset Class Number is a property of the category — every asset in the category shares it. +function assetClassNumberForCategory(categoryName) { + if (!categoryName) return null; + try { + const category = one("SELECT metadata FROM reference_values WHERE type = 'category' AND name = ?", [categoryName]); + return category ? (parseJson(category.metadata, {}).asset_class_number || null) : null; + } catch { + return null; + } +} + function normalizeAssetInput(body) { const input = {}; for (const column of assetColumns) { @@ -88,7 +198,7 @@ function normalizeAssetInput(body) { input.description = input.description || body.name || 'Untitled asset'; input.category = input.category || 'Uncategorized'; input.status = input.status || 'in_service'; - input.asset_id = input.asset_id || nextAssetId(); + input.asset_id = input.asset_id || null; // resolved by the caller (template or default sequence) input.entity_id = input.entity_id || one('SELECT id FROM entities ORDER BY id LIMIT 1')?.id; input.acquisition_cost = Number(input.acquisition_cost || 0); input.useful_life_months = Number(input.useful_life_months || 60); @@ -103,7 +213,7 @@ function normalizeAssetInput(body) { } function createBooks(assetId, asset, books = []) { - const selectedBooks = books.length ? books : defaultBooks.map((book) => ({ book_type: book })); + const selectedBooks = books.length ? books : enabledBookCodes().map((book) => ({ book_type: book })); for (const book of selectedBooks) { run( `INSERT OR REPLACE INTO asset_books ( @@ -156,12 +266,14 @@ function createAsset(body, userId) { const created = now(); const input = normalizeAssetInput(body); const result = tx(() => { + input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence const insert = run( `INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at) VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`, [...assetColumns.map((column) => input[column] ?? null), userId, created, created] ); createBooks(insert.lastInsertRowid, input, body.books || []); + run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]); return insert; }); const asset = hydrateAsset(result.lastInsertRowid); @@ -174,6 +286,10 @@ function updateAsset(id, body, userId) { if (!before) return null; const input = normalizeAssetInput({ ...before, ...body }); + // When depreciation-critical fields change, the caller chooses when the change applies. + // 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation; + // 'going_forward' keeps depreciation already recorded (the stored prior depreciation). + if (body.depreciation_apply === 'placed_in_service') input.prior_depreciation = 0; const updated = now(); tx(() => { run( @@ -181,6 +297,7 @@ function updateAsset(id, body, userId) { [...assetColumns.map((column) => input[column] ?? null), updated, id] ); if (Array.isArray(body.books)) createBooks(id, input, body.books); + run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]); }); const asset = hydrateAsset(id); @@ -231,11 +348,13 @@ function upsertImportedAssets(rows, userId) { tx(() => { for (const row of rows) { const input = normalizeAssetInput(row); + if (!input.asset_id) input.asset_id = resolveNewAssetId(input); const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]); + const classNumber = assetClassNumberForCategory(input.category); if (existing) { run( - `UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`, - [...assetColumns.map((column) => input[column] ?? null), now(), existing.id] + `UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, asset_class_number = ?, updated_at = ? WHERE id = ?`, + [...assetColumns.map((column) => input[column] ?? null), classNumber, now(), existing.id] ); } else { const result = run( @@ -244,6 +363,7 @@ function upsertImportedAssets(rows, userId) { [...assetColumns.map((column) => input[column] ?? null), userId, now(), now()] ); createBooks(result.lastInsertRowid, input, []); + run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]); } imported += 1; } @@ -252,6 +372,7 @@ function upsertImportedAssets(rows, userId) { } module.exports = { + addAssetPhoto, assetColumns, assetExportRows, assetFromRow, @@ -259,8 +380,12 @@ module.exports = { createBooks, defaultBooks, deleteAsset, + deleteAssetPhoto, + formatAssetId, hydrateAsset, + updateAssetPhoto, listAssets, + lookupAssetByCode, massUpdateAssets, normalizeAssetInput, upsertImportedAssets, diff --git a/server/services/assets.test.js b/server/services/assets.test.js new file mode 100644 index 0000000..4480325 --- /dev/null +++ b/server/services/assets.test.js @@ -0,0 +1,10 @@ +let assets = require('./assets') + +describe('assets', () => { + test('looks up asset by code correctly', () => { + const asset = assets.lookupAssetByCode('TESTCODE123'); + expect(asset).toBeDefined(); + expect(asset.asset_id).toBe(1); + expect(asset.code).toBe('TESTCODE123'); + }); +}); \ No newline at end of file diff --git a/server/services/assignments.test.js b/server/services/assignments.test.js new file mode 100644 index 0000000..97ca8f3 --- /dev/null +++ b/server/services/assignments.test.js @@ -0,0 +1,41 @@ +let assignments = require('./assignments') + +describe('assignments', () => { + test('assigns asset to employee correctly', () => { + const body = { + asset_id: 1, + employee_id: 1, + department: 'IT', + location: 'Headquarters', + notes: 'Assigned for testing' + }; + const userId = 1; + const assignment = assignments.assignAsset(body, userId); + expect(assignment).toBeDefined(); + expect(assignment.asset_id).toBe(body.asset_id); + expect(assignment.employee_id).toBe(body.employee_id); + expect(assignment.department).toBe(body.department); + expect(assignment.location).toBe(body.location); + expect(assignment.status).toBe('assigned'); + }); + + test('releases asset assignment correctly', () => { + const body = { + release_reason: 'No longer needed', + notes: 'Released after testing' + }; + const userId = 1; + const assignment = assignments.assignAsset({ + asset_id: 1, + employee_id: 1, + department: 'IT', + location: 'Headquarters' + }, userId); + const released = assignments.releaseAssignment(assignment.id, body, userId); + expect(released).toBeDefined(); + expect(released.id).toBe(assignment.id); + expect(released.status).toBe('released'); + expect(released.release_reason).toBe(body.release_reason); + expect(released.notes).toBe(body.notes); + }); +}); diff --git a/server/services/books.js b/server/services/books.js new file mode 100644 index 0000000..63341a5 --- /dev/null +++ b/server/services/books.js @@ -0,0 +1,221 @@ +const { all, audit, now, one, run } = require('../db'); +const { money } = require('../depreciation'); +const { ruleSetForBook } = require('./ruleSets'); +const { computeYear } = require('./reportEngine'); +const { assetFromRow } = require('./assets'); +const { pmBookLedger } = require('./pm'); + +const DEFAULT_ACCOUNTS = { + asset: '1500', + accumulated: '1590', + expense: '6400' +}; + +function bookFromRow(row) { + return { + ...row, + enabled: Boolean(row.enabled), + is_primary: Boolean(row.is_primary) + }; +} + +function listBooks() { + const books = all( + `SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version, + (SELECT COUNT(*) FROM asset_books ab WHERE ab.book_type = b.code AND ab.active = 1) AS asset_count + FROM books b + LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id + ORDER BY b.sort_order, b.id` + ).map(bookFromRow); + const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name') + .map((row) => ({ ...row, active: Boolean(row.active) })); + return { books, ruleSets }; +} + +function getBook(code) { + return bookFromRow(one('SELECT * FROM books WHERE code = ? OR id = ?', [code, code]) || null) || null; +} + +function createBook(body, userId) { + const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 20); + if (!code) throw Object.assign(new Error('Book code is required'), { status: 400 }); + if (one('SELECT id FROM books WHERE code = ?', [code])) { + throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 }); + } + const ts = now(); + const next = one('SELECT MAX(sort_order) AS max FROM books'); + const result = run( + `INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`, + [ + code, + body.name || code, + body.description || null, + ['financial', 'tax', 'internal'].includes(body.book_type) ? body.book_type : 'internal', + body.enabled === false ? 0 : 1, + body.tax_rule_set_id || null, + body.default_method || 'straight_line', + body.default_convention || 'half_year', + Number(body.fiscal_year_end_month) || 12, + (next?.max || 0) + 1, + ts, ts + ] + ); + audit(userId, 'create', 'book', result.lastInsertRowid, null, { code, name: body.name }); + return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid])); +} + +function deleteBook(id, userId) { + const before = one('SELECT * FROM books WHERE id = ?', [id]); + if (!before) return false; + if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 }); + if (before.book_type === 'maintenance') throw Object.assign(new Error('The PM book cannot be deleted'), { status: 400 }); + const used = one('SELECT COUNT(*) AS count FROM asset_books WHERE book_type = ?', [before.code]).count; + if (used) throw Object.assign(new Error(`This book is used by ${used} asset record(s); disable it instead`), { status: 400 }); + run('DELETE FROM books WHERE id = ?', [id]); + audit(userId, 'delete', 'book', id, before, null); + return true; +} + +function updateBook(id, body, userId) { + const before = one('SELECT * FROM books WHERE id = ?', [id]); + if (!before) return null; + run( + `UPDATE books SET + name = ?, description = ?, book_type = ?, enabled = ?, tax_rule_set_id = ?, + default_method = ?, default_convention = ?, fiscal_year_end_month = ?, updated_at = ? + WHERE id = ?`, + [ + body.name ?? before.name, + body.description ?? before.description, + body.book_type ?? before.book_type, + body.enabled === undefined ? before.enabled : (body.enabled ? 1 : 0), + body.tax_rule_set_id === undefined ? before.tax_rule_set_id : (body.tax_rule_set_id || null), + body.default_method ?? before.default_method, + body.default_convention ?? before.default_convention, + body.fiscal_year_end_month ?? before.fiscal_year_end_month, + now(), + id + ] + ); + audit(userId, 'update', 'book', id, before, body); + return bookFromRow(one('SELECT * FROM books WHERE id = ?', [id])); +} + +// General-ledger view of a book: per-asset basis, accumulated depreciation and +// current-year expense rolled up to GL accounts, plus an asset-level detail. +function bookLedger(code, year) { + const book = getBook(code); + if (!book) return null; + if (book.book_type === 'maintenance') { + return { ...pmBookLedger(year), book }; + } + const rules = ruleSetForBook(code); + const rows = all( + `SELECT a.*, b.cost AS book_cost, b.depreciation_method, b.convention, + b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent, + b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation + FROM assets a JOIN asset_books b ON b.asset_id = a.id + WHERE b.book_type = ? AND b.active = 1 + ORDER BY a.asset_id`, + [code] + ); + + const accountMap = new Map(); + const addLine = (account, type, debit, credit) => { + const key = `${type}::${account}`; + const entry = accountMap.get(key) || { account, type, debit: 0, credit: 0 }; + entry.debit += debit; + entry.credit += credit; + accountMap.set(key, entry); + }; + + let totalCost = 0; + let totalAccum = 0; + let totalDepr = 0; + const assets = rows.map((row) => { + const asset = assetFromRow(row); + const bk = { + book_type: code, + cost: row.book_cost, + depreciation_method: row.depreciation_method, + convention: row.convention, + useful_life_months: row.book_life, + section_179_amount: row.section_179_amount, + bonus_percent: row.bonus_percent, + business_use_percent: row.book_bup, + investment_use_percent: row.book_ivp, + manual_depreciation: row.manual_depreciation + }; + const c = computeYear(asset, bk, rules, year); + const assetAccount = asset.gl_asset_account || DEFAULT_ACCOUNTS.asset; + const accumAccount = asset.gl_accumulated_account || DEFAULT_ACCOUNTS.accumulated; + const expenseAccount = asset.gl_expense_account || DEFAULT_ACCOUNTS.expense; + + addLine(assetAccount, 'Asset cost', c.cost, 0); + addLine(accumAccount, 'Accumulated depreciation', 0, c.accumulated_end); + if (c.depreciation) addLine(expenseAccount, 'Depreciation expense', c.depreciation, 0); + + totalCost += c.cost; + totalAccum += c.accumulated_end; + totalDepr += c.depreciation; + + return { + asset_id: asset.asset_id, + description: asset.description, + asset_account: assetAccount, + accumulated_account: accumAccount, + expense_account: expenseAccount, + cost: c.cost, + depreciation: c.depreciation, + accumulated: c.accumulated_end, + nbv: c.nbv_end + }; + }); + + const accounts = [...accountMap.values()] + .map((entry) => ({ account: entry.account, type: entry.type, debit: money(entry.debit), credit: money(entry.credit) })) + .sort((a, b) => a.account.localeCompare(b.account) || a.type.localeCompare(b.type)); + + return { + book, + year, + rule_set: { id: book.tax_rule_set_id, name: book.tax_rule_set_name, version: book.tax_rule_set_version }, + summary: { + assets: assets.length, + cost: money(totalCost), + accumulated: money(totalAccum), + depreciation: money(totalDepr), + nbv: money(totalCost - totalAccum) + }, + accounts, + accountTotals: { debit: money(accounts.reduce((s, a) => s + a.debit, 0)), credit: money(accounts.reduce((s, a) => s + a.credit, 0)) }, + assets + }; +} + +// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export. +function ledgerReport(code, year) { + const ledger = bookLedger(code, year); + if (!ledger) return null; + return { + title: `${ledger.book.name} general ledger — ${year}`, + generatedAt: now(), + options: { book: code, year }, + columns: [ + { key: 'account', label: 'GL account', format: 'text' }, + { key: 'type', label: 'Account type', format: 'text' }, + { key: 'debit', label: 'Debit', format: 'currency' }, + { key: 'credit', label: 'Credit', format: 'currency' } + ], + rows: ledger.accounts, + totals: ledger.accountTotals, + meta: { + note: ledger.kind === 'maintenance' + ? `${ledger.summary.assets} asset(s) · ${ledger.summary.completions} service(s) · total PM spend ${ledger.summary.cost}` + : `Cost ${ledger.summary.cost} · Accumulated ${ledger.summary.accumulated} · NBV ${ledger.summary.nbv} · ${year} depreciation ${ledger.summary.depreciation}` + } + }; +} + +module.exports = { bookLedger, createBook, deleteBook, getBook, ledgerReport, listBooks, updateBook }; diff --git a/server/services/contacts.js b/server/services/contacts.js new file mode 100644 index 0000000..6ded6a0 --- /dev/null +++ b/server/services/contacts.js @@ -0,0 +1,175 @@ +const { all, audit, json, now, one, parseJson, run } = require('../db'); + +const TYPES = ['employee', 'vendor', 'service_provider', 'contractor', 'work_location', 'other']; +const CREDIT_TERMS = ['na', 'net_30', 'net_60', 'net_90', 'net_180']; + +const COLUMNS = [ + 'type', 'first_name', 'last_name', 'organization', 'email', 'phone', + 'address_line1', 'address_line2', 'city', 'state_region', 'postal_code', 'country', + 'notes', 'status', 'employee_id', 'tax_id', 'department', 'work_location', + 'manager_first_name', 'manager_last_name', 'manager_email', 'start_date', + 'rating', 'credit_term', 'source', 'workday_worker_id' +]; + +function contactNotes(contactId) { + return all( + `SELECT n.id, n.body, n.created_at, u.name AS author + FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by + WHERE n.contact_id = ? ORDER BY n.id DESC`, + [contactId] + ); +} + +function displayName(row) { + const person = [row.first_name, row.last_name].filter(Boolean).join(' ').trim(); + return row.organization || person || row.email || `Contact ${row.id}`; +} + +function fromRow(row, withNotes = false) { + if (!row) return null; + const contact = { ...row, display_name: displayName(row) }; + if (withNotes) contact.contact_notes = contactNotes(row.id); + return contact; +} + +function normalize(body) { + const input = {}; + for (const column of COLUMNS) { + if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column]; + } + input.type = TYPES.includes(input.type) ? input.type : 'other'; + input.status = input.status || 'active'; + input.source = input.source || 'manual'; + if (input.credit_term && !CREDIT_TERMS.includes(input.credit_term)) input.credit_term = 'na'; + if (input.rating != null && input.rating !== '') input.rating = Math.max(0, Math.min(5, Number(input.rating))); + else input.rating = null; + if (input.workday_worker_id === '') input.workday_worker_id = null; + return input; +} + +function listContacts(query = {}) { + return all( + `SELECT * FROM contacts + WHERE (? IS NULL OR type = ?) + AND (? IS NULL OR status = ?) + AND (? IS NULL OR ( + first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR + organization LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR + employee_id LIKE '%' || ? || '%' + )) + ORDER BY type, organization, last_name, first_name`, + [ + query.type || null, query.type || null, + query.status || null, query.status || null, + query.search || null, query.search || null, query.search || null, + query.search || null, query.search || null, query.search || null + ] + ).map((row) => fromRow(row)); +} + +function getContact(id) { + return fromRow(one('SELECT * FROM contacts WHERE id = ?', [id]), true); +} + +function createContact(body, userId) { + const input = normalize(body); + const ts = now(); + const cols = COLUMNS.filter((c) => input[c] !== undefined); + const result = run( + `INSERT INTO contacts (${cols.join(', ')}, created_by, created_at, updated_at) + VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`, + [...cols.map((c) => input[c]), userId, ts, ts] + ); + audit(userId, 'create', 'contact', result.lastInsertRowid, null, { type: input.type, name: displayName(input) }); + return getContact(result.lastInsertRowid); +} + +function updateContact(id, body, userId) { + const before = one('SELECT * FROM contacts WHERE id = ?', [id]); + if (!before) return null; + const input = normalize({ ...before, ...body }); + run( + `UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`, + [...COLUMNS.map((c) => input[c] ?? null), now(), id] + ); + audit(userId, 'update', 'contact', id, before, body); + return getContact(id); +} + +function deleteContact(id, userId) { + const before = one('SELECT * FROM contacts WHERE id = ?', [id]); + if (!before) return false; + run('DELETE FROM contacts WHERE id = ?', [id]); + audit(userId, 'delete', 'contact', id, before, null); + return true; +} + +function addNote(contactId, bodyText, userId) { + if (!one('SELECT id FROM contacts WHERE id = ?', [contactId])) return null; + const result = run('INSERT INTO contact_notes (contact_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', [contactId, bodyText, userId, now()]); + audit(userId, 'create', 'contact_note', result.lastInsertRowid, null, { contact_id: contactId }); + return one('SELECT n.id, n.body, n.created_at, u.name AS author FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?', [result.lastInsertRowid]); +} + +function deleteNote(contactId, noteId, userId) { + const result = run('DELETE FROM contact_notes WHERE id = ? AND contact_id = ?', [noteId, contactId]); + if (!result.changes) return false; + audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId }); + return true; +} + +// Upsert an employee contact from a normalized Workday worker record. +function upsertWorkdayContact(worker) { + const ts = now(); + const existing = worker.workday_worker_id + ? one('SELECT id FROM contacts WHERE workday_worker_id = ?', [worker.workday_worker_id]) + : (worker.email ? one('SELECT id FROM contacts WHERE type = ? AND lower(email) = lower(?)', ['employee', worker.email]) : null); + + const fields = { + type: 'employee', + first_name: worker.first_name || null, + last_name: worker.last_name || null, + organization: null, + email: worker.email || null, + department: worker.department || null, + work_location: worker.location || null, + employee_id: worker.employee_number || null, + manager_first_name: worker.manager_first_name || null, + manager_last_name: worker.manager_last_name || null, + manager_email: worker.manager_email || null, + start_date: worker.start_date || null, + status: worker.status || 'active', + source: 'workday', + workday_worker_id: worker.workday_worker_id || null, + source_payload: json(worker.source_payload || worker) + }; + + if (existing) { + const cols = Object.keys(fields); + run( + `UPDATE contacts SET ${cols.map((c) => `${c} = COALESCE(?, ${c})`).join(', ')}, last_synced_at = ?, updated_at = ? WHERE id = ?`, + [...cols.map((c) => fields[c]), ts, ts, existing.id] + ); + return existing.id; + } + const cols = Object.keys(fields); + const result = run( + `INSERT INTO contacts (${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`, + [...cols.map((c) => fields[c]), ts, ts, ts] + ); + return result.lastInsertRowid; +} + +module.exports = { + CREDIT_TERMS, + TYPES, + addNote, + createContact, + deleteContact, + deleteNote, + getContact, + listContacts, + parsePayload: (value) => parseJson(value, null), + updateContact, + upsertWorkdayContact +}; diff --git a/server/services/disposals.js b/server/services/disposals.js new file mode 100644 index 0000000..6a24503 --- /dev/null +++ b/server/services/disposals.js @@ -0,0 +1,187 @@ +const { all, audit, now, one, run, tx } = require('../db'); +const { annualSchedule, money } = require('../depreciation'); +const { ruleSetForBook } = require('./ruleSets'); +const { hydrateAsset } = require('./assets'); + +const DISPOSAL_METHODS = ['sale', 'exchange', 'involuntary_conversion', 'like_kind', 'abandonment']; + +function yearOf(value) { + if (!value) return new Date().getFullYear(); + return new Date(`${value}T00:00:00`).getFullYear(); +} + +function netAdjustments(assetId, bookType) { + // Impairments and write-downs reduce carrying value; write-ups/revaluations restore it. + const rows = all('SELECT type, amount FROM asset_adjustments WHERE asset_id = ? AND book_type = ?', [assetId, bookType]); + return rows.reduce((sum, row) => { + const sign = row.type === 'write_up' || row.type === 'revaluation' ? -1 : 1; + return sum + sign * Number(row.amount || 0); + }, 0); +} + +function bookFor(asset, bookType) { + return asset.books.find((book) => book.book_type === bookType) || asset.books[0] || { book_type: bookType }; +} + +function computeDisposal(asset, input = {}) { + const bookType = input.book_type || 'GAAP'; + const book = bookFor(asset, bookType); + const rules = ruleSetForBook(bookType); + const disposalDate = input.disposal_date || new Date().toISOString().slice(0, 10); + const year = yearOf(disposalDate); + + const totalCost = Number(book.cost ?? asset.acquisition_cost ?? 0); + const partial = Boolean(input.partial); + const disposedCost = partial ? Math.min(totalCost, Number(input.disposed_cost || 0)) : totalCost; + const proportion = totalCost > 0 ? disposedCost / totalCost : 1; + + const row = annualSchedule(asset, book, rules, { startYear: year, endYear: year })[0] || {}; + const accumulatedFull = Number(row.accumulated || 0); + const nbvFull = row.netBookValue != null ? Number(row.netBookValue) : Math.max(0, totalCost - accumulatedFull); + + const accumulated = money(accumulatedFull * proportion); + const impairment = money(netAdjustments(asset.id, bookType) * proportion); + const netBookValue = money(Math.max(0, nbvFull * proportion - impairment)); + + const price = Number(input.disposal_price || 0); + const expense = Number(input.disposal_expense || 0); + const realized = money(price - expense); + const gainLoss = money(realized - netBookValue); + + return { + book_type: bookType, + disposal_date: disposalDate, + method: DISPOSAL_METHODS.includes(input.method) ? input.method : 'sale', + property_type: input.property_type || asset.property_type || null, + partial: partial ? 1 : 0, + disposed_cost: money(disposedCost), + total_cost: money(totalCost), + disposal_price: money(price), + disposal_expense: money(expense), + accumulated_depreciation: accumulated, + impairment, + net_book_value: netBookValue, + realized, + gain_loss: gainLoss + }; +} + +function previewDisposal(assetId, input) { + const asset = hydrateAsset(assetId); + if (!asset) return null; + return computeDisposal(asset, input); +} + +function recordDisposal(assetId, input, userId) { + const asset = hydrateAsset(assetId); + if (!asset) return null; + const result = computeDisposal(asset, input); + const timestamp = now(); + + return tx(() => { + const inserted = run( + `INSERT INTO disposals ( + asset_id, disposal_date, method, property_type, partial, disposed_cost, + disposal_price, disposal_expense, accumulated_depreciation, net_book_value, + gain_loss, book_type, notes, created_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + assetId, result.disposal_date, result.method, result.property_type, result.partial, + result.disposed_cost, result.disposal_price, result.disposal_expense, + result.accumulated_depreciation, result.net_book_value, result.gain_loss, + result.book_type, input.notes || null, userId, timestamp + ] + ); + + if (result.partial) { + const totalCost = result.total_cost; + const remainingProportion = totalCost > 0 ? Math.max(0, (totalCost - result.disposed_cost) / totalCost) : 1; + run('UPDATE assets SET acquisition_cost = acquisition_cost - ?, updated_at = ? WHERE id = ?', [ + result.disposed_cost, + timestamp, + assetId + ]); + run('UPDATE asset_books SET cost = cost * ? WHERE asset_id = ? AND cost IS NOT NULL', [remainingProportion, assetId]); + } else { + run( + `UPDATE assets SET + status = 'disposed', disposal_date = ?, disposal_price = ?, disposal_expense = ?, + disposal_type = ?, property_type = ?, updated_at = ? + WHERE id = ?`, + [result.disposal_date, result.disposal_price, result.disposal_expense, result.method, result.property_type, timestamp, assetId] + ); + } + + const disposal = one('SELECT * FROM disposals WHERE id = ?', [inserted.lastInsertRowid]); + audit(userId, 'dispose', 'asset', assetId, null, disposal); + return { disposal, asset: hydrateAsset(assetId) }; + }); +} + +function reverseDisposal(assetId, disposalId, userId) { + const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]); + if (!disposal) return null; + const timestamp = now(); + + return tx(() => { + run('DELETE FROM disposals WHERE id = ?', [disposalId]); + if (disposal.partial) { + run('UPDATE assets SET acquisition_cost = acquisition_cost + ?, updated_at = ? WHERE id = ?', [ + disposal.disposed_cost, + timestamp, + assetId + ]); + } else if (!one('SELECT id FROM disposals WHERE asset_id = ? AND partial = 0', [assetId])) { + run( + `UPDATE assets SET + status = 'in_service', disposal_date = NULL, disposal_price = NULL, + disposal_expense = NULL, disposal_type = NULL, updated_at = ? + WHERE id = ?`, + [timestamp, assetId] + ); + } + audit(userId, 'reverse_disposal', 'asset', assetId, disposal, null); + return hydrateAsset(assetId); + }); +} + +function recordAdjustment(assetId, input, userId) { + const asset = one('SELECT id FROM assets WHERE id = ?', [assetId]); + if (!asset) return null; + const timestamp = now(); + const result = run( + `INSERT INTO asset_adjustments (asset_id, adjustment_date, type, amount, book_type, reason, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + assetId, + input.adjustment_date || timestamp.slice(0, 10), + input.type || 'impairment', + Number(input.amount || 0), + input.book_type || 'GAAP', + input.reason || null, + userId, + timestamp + ] + ); + const adjustment = one('SELECT * FROM asset_adjustments WHERE id = ?', [result.lastInsertRowid]); + audit(userId, 'adjust', 'asset', assetId, null, adjustment); + return adjustment; +} + +function deleteAdjustment(assetId, adjustmentId, userId) { + const result = run('DELETE FROM asset_adjustments WHERE id = ? AND asset_id = ?', [adjustmentId, assetId]); + if (!result.changes) return false; + audit(userId, 'delete', 'asset_adjustment', adjustmentId, null, { asset_id: assetId }); + return true; +} + +module.exports = { + DISPOSAL_METHODS, + computeDisposal, + deleteAdjustment, + netAdjustments, + previewDisposal, + recordAdjustment, + recordDisposal, + reverseDisposal +}; diff --git a/server/services/leases.js b/server/services/leases.js new file mode 100644 index 0000000..53a8761 --- /dev/null +++ b/server/services/leases.js @@ -0,0 +1,146 @@ +const { all, audit, now, one, run } = require('../db'); +const { money } = require('../depreciation'); + +const FREQUENCIES = { monthly: 12, quarterly: 4, semiannual: 2, annual: 1 }; + +function periodsPerYear(frequency) { + return FREQUENCIES[frequency] || 12; +} + +function addMonths(date, count) { + const next = new Date(date.getTime()); + next.setMonth(next.getMonth() + count); + return next; +} + +function listLeases(query = {}) { + return all( + `SELECT l.*, a.asset_id AS asset_code, a.description AS asset_description + FROM leases l + LEFT JOIN assets a ON a.id = l.asset_id + WHERE (? IS NULL OR l.asset_id = ?) + ORDER BY l.start_date DESC, l.id DESC`, + [query.asset_id || null, query.asset_id || null] + ); +} + +function getLease(id) { + return one('SELECT * FROM leases WHERE id = ?', [id]); +} + +function createLease(input, userId) { + const timestamp = now(); + const result = run( + `INSERT INTO leases (asset_id, lessor, contract_value, start_date, end_date, discount_rate, payment_frequency, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + input.asset_id || null, + input.lessor || null, + Number(input.contract_value || 0), + input.start_date || null, + input.end_date || null, + Number(input.discount_rate || 0), + input.payment_frequency || 'monthly', + input.notes || null, + timestamp, + timestamp + ] + ); + audit(userId, 'create', 'lease', result.lastInsertRowid, null, input); + return getLease(result.lastInsertRowid); +} + +function updateLease(id, input, userId) { + const before = getLease(id); + if (!before) return null; + run( + `UPDATE leases SET + lessor = ?, contract_value = ?, start_date = ?, end_date = ?, discount_rate = ?, + payment_frequency = ?, notes = ?, updated_at = ? + WHERE id = ?`, + [ + input.lessor ?? before.lessor, + Number(input.contract_value ?? before.contract_value), + input.start_date ?? before.start_date, + input.end_date ?? before.end_date, + Number(input.discount_rate ?? before.discount_rate), + input.payment_frequency ?? before.payment_frequency, + input.notes ?? before.notes, + now(), + id + ] + ); + audit(userId, 'update', 'lease', id, before, input); + return getLease(id); +} + +function deleteLease(id, userId) { + const before = getLease(id); + if (!before) return false; + run('DELETE FROM leases WHERE id = ?', [id]); + audit(userId, 'delete', 'lease', id, before, null); + return true; +} + +// Straight-line ROU amortization with an ASC 842-style liability schedule. +function leaseSchedule(lease) { + if (!lease) return null; + const ppy = periodsPerYear(lease.payment_frequency); + const monthsPerPeriod = 12 / ppy; + const start = lease.start_date ? new Date(`${lease.start_date}T00:00:00`) : new Date(); + const end = lease.end_date ? new Date(`${lease.end_date}T00:00:00`) : addMonths(start, 12); + + const totalMonths = Math.max(monthsPerPeriod, (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth())); + const periods = Math.max(1, Math.round(totalMonths / monthsPerPeriod)); + + const contractValue = Number(lease.contract_value || 0); + const payment = contractValue / periods; + const rate = Number(lease.discount_rate || 0) / 100 / ppy; + const presentValue = rate > 0 ? payment * (1 - (1 + rate) ** -periods) / rate : contractValue; + + const rouAmortization = presentValue / periods; + let liability = presentValue; + let rouBalance = presentValue; + let totalInterest = 0; + const rows = []; + + for (let period = 1; period <= periods; period += 1) { + const interest = liability * rate; + const principal = payment - interest; + liability = Math.max(0, liability - principal); + rouBalance = Math.max(0, rouBalance - rouAmortization); + totalInterest += interest; + rows.push({ + period, + date: addMonths(start, period * monthsPerPeriod).toISOString().slice(0, 10), + payment: money(payment), + interest: money(interest), + principal: money(principal), + liability_balance: money(liability), + rou_amortization: money(rouAmortization), + rou_balance: money(rouBalance) + }); + } + + return { + summary: { + periods, + frequency: lease.payment_frequency, + periodic_payment: money(payment), + present_value: money(presentValue), + total_payments: money(payment * periods), + total_interest: money(totalInterest) + }, + rows + }; +} + +module.exports = { + FREQUENCIES, + createLease, + deleteLease, + getLease, + leaseSchedule, + listLeases, + updateLease +}; diff --git a/server/services/notifications.js b/server/services/notifications.js new file mode 100644 index 0000000..788b24c --- /dev/null +++ b/server/services/notifications.js @@ -0,0 +1,118 @@ +const nodemailer = require('nodemailer'); +const { all, audit, now, run } = require('../db'); + +function readSettings() { + const rows = all('SELECT key, value FROM app_settings'); + return Object.fromEntries(rows.map((row) => [row.key, row.value])); +} + +// Raw config (includes password) — for sending mail only. +function getConfig() { + const s = readSettings(); + return { + host: s.smtp_host || '', + port: Number(s.smtp_port || 587), + secure: s.smtp_secure === 'true', + user: s.smtp_user || '', + password: s.smtp_password || '', + from: s.smtp_from || s.smtp_user || '', + enabled: s.alerts_enabled === 'true', + leadDays: Number(s.alerts_lead_days || 30), + recipients: String(s.alerts_recipients || '').split(',').map((x) => x.trim()).filter(Boolean), + webhookEnabled: s.webhook_enabled === 'true', + webhookUrl: s.webhook_url || '', + webhookSecret: s.webhook_secret || '' + }; +} + +// Safe config for the API/UI — password is never returned, only whether it is set. +function publicConfig() { + const c = getConfig(); + return { + smtp_host: c.host, + smtp_port: c.port, + smtp_secure: c.secure, + smtp_user: c.user, + smtp_from: c.from, + smtp_password_set: Boolean(c.password), + alerts_enabled: c.enabled, + alerts_lead_days: c.leadDays, + alerts_recipients: c.recipients.join(', '), + configured: Boolean(c.host), + webhook_enabled: c.webhookEnabled, + webhook_url: c.webhookUrl, + webhook_secret_set: Boolean(c.webhookSecret), + webhook_configured: Boolean(c.webhookUrl) + }; +} + +function setValue(key, value) { + run( + 'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', + [key, String(value), now()] + ); +} + +function saveConfig(body, userId) { + if (body.smtp_host !== undefined) setValue('smtp_host', body.smtp_host || ''); + if (body.smtp_port !== undefined) setValue('smtp_port', Number(body.smtp_port) || 587); + if (body.smtp_secure !== undefined) setValue('smtp_secure', body.smtp_secure ? 'true' : 'false'); + if (body.smtp_user !== undefined) setValue('smtp_user', body.smtp_user || ''); + if (body.smtp_from !== undefined) setValue('smtp_from', body.smtp_from || ''); + if (body.smtp_password) setValue('smtp_password', body.smtp_password); // only overwrite when a new value is supplied + if (body.alerts_enabled !== undefined) setValue('alerts_enabled', body.alerts_enabled ? 'true' : 'false'); + if (body.alerts_lead_days !== undefined) setValue('alerts_lead_days', Number(body.alerts_lead_days) || 30); + if (body.alerts_recipients !== undefined) setValue('alerts_recipients', body.alerts_recipients || ''); + if (body.webhook_enabled !== undefined) setValue('webhook_enabled', body.webhook_enabled ? 'true' : 'false'); + if (body.webhook_url !== undefined) setValue('webhook_url', body.webhook_url || ''); + if (body.webhook_secret_clear) setValue('webhook_secret', ''); + else if (body.webhook_secret) setValue('webhook_secret', body.webhook_secret); // only overwrite when a new value is supplied + audit(userId, 'update', 'notification_settings', 'smtp', null, { + ...body, + smtp_password: body.smtp_password ? '[set]' : undefined, + webhook_secret: body.webhook_secret ? '[set]' : undefined + }); + return publicConfig(); +} + +function getTransport() { + const c = getConfig(); + if (!c.host) { + throw Object.assign(new Error('SMTP host is not configured'), { status: 400 }); + } + return nodemailer.createTransport({ + host: c.host, + port: c.port, + secure: c.secure, + auth: c.user ? { user: c.user, pass: c.password } : undefined + }); +} + +async function sendMail({ to, subject, html, text }) { + const c = getConfig(); + const recipients = to ? (Array.isArray(to) ? to : [to]) : c.recipients; + if (!recipients.length) { + throw Object.assign(new Error('No recipients are configured'), { status: 400 }); + } + const transport = getTransport(); + return transport.sendMail({ + from: c.from || c.user, + to: recipients.join(', '), + subject, + html, + text + }); +} + +async function sendTestEmail(to, userId) { + const info = await sendMail({ + to: to ? [to] : null, + subject: 'MixedAssets test email', + html: '

Your MixedAssets SMTP settings are working. This is a test message.

', + text: 'Your MixedAssets SMTP settings are working. This is a test message.' + }); + audit(userId, 'send', 'test_email', null, null, { to }); + return { messageId: info.messageId, accepted: info.accepted }; +} + +module.exports = { getConfig, publicConfig, saveConfig, sendMail, sendTestEmail }; diff --git a/server/services/parts.js b/server/services/parts.js new file mode 100644 index 0000000..3996ce5 --- /dev/null +++ b/server/services/parts.js @@ -0,0 +1,312 @@ +const { all, audit, now, one, run, tx } = require('../db'); + +const STATUSES = ['active', 'inactive', 'discontinued']; +// Stock movements. receive/issue/adjust change on-hand; reserve/unreserve change the +// reserved (committed) quantity. `adjust` sets the on-hand count to an absolute value. +const TRANSACTION_TYPES = ['receive', 'issue', 'adjust', 'reserve', 'unreserve']; + +const COLUMNS = [ + 'part_number', 'name', 'description', 'category', 'manufacturer', 'manufacturer_part_number', + 'barcode', 'unit_of_measure', 'unit_cost', 'reorder_point', 'reorder_quantity', 'measurements', + 'supplier_contact_id', 'status', 'notes' +]; + +function round2(value) { + return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100; +} + +function num(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function contactName(row, prefix) { + if (!row || !row[`${prefix}_id`]) return null; + const org = row[`${prefix}_org`]; + const person = [row[`${prefix}_first`], row[`${prefix}_last`]].filter(Boolean).join(' ').trim(); + return org || person || null; +} + +function stockRows(partId) { + return all( + `SELECT s.*, c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last + FROM part_stock s LEFT JOIN contacts c ON c.id = s.location_contact_id + WHERE s.part_id = ? ORDER BY c.organization, s.aisle, s.bin, s.id`, + [partId] + ).map((s) => ({ + id: s.id, + part_id: s.part_id, + location_contact_id: s.location_contact_id, + location_name: s.location_org || [s.location_first, s.location_last].filter(Boolean).join(' ').trim() || 'Unassigned', + aisle: s.aisle, + bin: s.bin, + quantity: round2(s.quantity), + reserved: round2(s.reserved), + available: round2(s.quantity - s.reserved) + })); +} + +function partPhotos(partId) { + return all('SELECT id, name, mime_type, data_base64 FROM part_photos WHERE part_id = ? ORDER BY id', [partId]) + .map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 })); +} + +function partTransactions(partId, limit = 100) { + return all( + `SELECT t.id, t.type, t.quantity, t.unit_cost, t.reference, t.notes, t.created_at, t.asset_id, + u.name AS created_by_name, a.asset_id AS asset_code, + c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last, + s.aisle, s.bin + FROM part_transactions t + LEFT JOIN users u ON u.id = t.created_by + LEFT JOIN assets a ON a.id = t.asset_id + LEFT JOIN part_stock s ON s.id = t.stock_id + LEFT JOIN contacts c ON c.id = s.location_contact_id + WHERE t.part_id = ? ORDER BY t.id DESC LIMIT ?`, + [partId, limit] + ).map((t) => ({ + id: t.id, + type: t.type, + quantity: round2(t.quantity), + unit_cost: t.unit_cost == null ? null : round2(t.unit_cost), + reference: t.reference, + notes: t.notes, + created_at: t.created_at, + created_by_name: t.created_by_name, + asset_code: t.asset_code, + location_name: t.location_org || [t.location_first, t.location_last].filter(Boolean).join(' ').trim() || null, + aisle: t.aisle, + bin: t.bin + })); +} + +function totalsFor(stock) { + const on_hand = round2(stock.reduce((sum, s) => sum + s.quantity, 0)); + const reserved = round2(stock.reduce((sum, s) => sum + s.reserved, 0)); + return { on_hand, reserved, available: round2(on_hand - reserved), location_count: stock.length }; +} + +function decorate(row, totals) { + const onHand = totals.on_hand; + return { + ...row, + supplier_name: contactName(row, 'supplier'), + on_hand: onHand, + reserved: totals.reserved, + available: totals.available, + location_count: totals.location_count, + stock_value: round2(onHand * num(row.unit_cost)), + low_stock: num(row.reorder_point) > 0 && onHand <= num(row.reorder_point) + }; +} + +function listParts(query = {}) { + const rows = all( + `SELECT p.*, + sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last, + COALESCE(st.on_hand, 0) AS on_hand, COALESCE(st.reserved, 0) AS reserved, COALESCE(st.locations, 0) AS location_count + FROM parts p + LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id + LEFT JOIN ( + SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations + FROM part_stock GROUP BY part_id + ) st ON st.part_id = p.id + WHERE (? IS NULL OR p.status = ?) + AND (? IS NULL OR p.category = ?) + AND (? IS NULL OR ( + p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR + p.description LIKE '%' || ? || '%' OR p.barcode LIKE '%' || ? || '%' OR + p.manufacturer_part_number LIKE '%' || ? || '%' + )) + ORDER BY p.name`, + [ + query.status || null, query.status || null, + query.category || null, query.category || null, + query.search || null, query.search || null, query.search || null, + query.search || null, query.search || null + ] + ).map((row) => { + const onHand = round2(row.on_hand); + const reserved = round2(row.reserved); + return decorate(row, { on_hand: onHand, reserved, available: round2(onHand - reserved), location_count: row.location_count }); + }); + if (query.low_stock === 'true' || query.low_stock === true) return rows.filter((r) => r.low_stock); + return rows; +} + +function getPart(id) { + const row = one( + `SELECT p.*, sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last + FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ?`, + [id] + ); + if (!row) return null; + const stock = stockRows(id); + const part = decorate(row, totalsFor(stock)); + part.stock = stock; + part.photos = partPhotos(id); + part.transactions = partTransactions(id); + return part; +} + +function normalize(body) { + const input = {}; + for (const column of COLUMNS) { + if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column]; + } + input.status = STATUSES.includes(input.status) ? input.status : 'active'; + input.unit_of_measure = input.unit_of_measure || 'each'; + for (const numeric of ['unit_cost', 'reorder_point', 'reorder_quantity']) { + if (input[numeric] !== undefined) input[numeric] = num(input[numeric]); + } + if (input.supplier_contact_id === '' || input.supplier_contact_id === undefined) input.supplier_contact_id = null; + return input; +} + +function createPart(body, userId) { + const input = normalize(body); + if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 }); + if (one('SELECT id FROM parts WHERE part_number = ?', [input.part_number])) { + throw Object.assign(new Error('That part number already exists'), { status: 400 }); + } + const ts = now(); + const cols = COLUMNS.filter((c) => input[c] !== undefined); + const result = run( + `INSERT INTO parts (${cols.join(', ')}, created_by, created_at, updated_at) + VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`, + [...cols.map((c) => input[c]), userId, ts, ts] + ); + audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number }); + return getPart(result.lastInsertRowid); +} + +function updatePart(id, body, userId) { + const before = one('SELECT * FROM parts WHERE id = ?', [id]); + if (!before) return null; + const input = normalize({ ...before, ...body }); + if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND id <> ?', [input.part_number, id])) { + throw Object.assign(new Error('That part number already exists'), { status: 400 }); + } + run( + `UPDATE parts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`, + [...COLUMNS.map((c) => input[c] ?? null), now(), id] + ); + audit(userId, 'update', 'part', id, before, body); + return getPart(id); +} + +function deletePart(id, userId) { + const before = one('SELECT * FROM parts WHERE id = ?', [id]); + if (!before) return false; + run('DELETE FROM parts WHERE id = ?', [id]); + audit(userId, 'delete', 'part', id, before, null); + return true; +} + +// ---- Stock locations ------------------------------------------------------- + +function saveStock(partId, body, userId) { + if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null; + const ts = now(); + const location = body.location_contact_id || null; + const aisle = body.aisle || null; + const bin = body.bin || null; + const quantity = num(body.quantity); + const reserved = Math.max(0, Math.min(quantity, num(body.reserved))); + if (body.id) { + const existing = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.id, partId]); + if (!existing) return null; + run( + 'UPDATE part_stock SET location_contact_id = ?, aisle = ?, bin = ?, quantity = ?, reserved = ?, updated_at = ? WHERE id = ?', + [location, aisle, bin, quantity, reserved, ts, body.id] + ); + audit(userId, 'update', 'part_stock', body.id, existing, body); + } else { + const result = run( + 'INSERT INTO part_stock (part_id, location_contact_id, aisle, bin, quantity, reserved, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [partId, location, aisle, bin, quantity, reserved, ts, ts] + ); + audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId }); + } + return getPart(partId); +} + +function deleteStock(partId, stockId, userId) { + const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]); + if (!result.changes) return null; + audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null); + return getPart(partId); +} + +// ---- Stock movements ------------------------------------------------------- + +function recordTransaction(partId, body, userId) { + if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null; + const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive'; + const stock = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.stock_id, partId]); + if (!stock) throw Object.assign(new Error('Select a stock location for this movement'), { status: 400 }); + const qty = Math.abs(num(body.quantity)); + if (!qty && type !== 'adjust') throw Object.assign(new Error('Quantity must be greater than zero'), { status: 400 }); + + let quantity = stock.quantity; + let reserved = stock.reserved; + switch (type) { + case 'receive': quantity += qty; break; + case 'issue': quantity = Math.max(0, quantity - qty); break; + case 'adjust': quantity = num(body.quantity); break; // absolute count correction + case 'reserve': reserved = Math.min(quantity, reserved + qty); break; + case 'unreserve': reserved = Math.max(0, reserved - qty); break; + default: break; + } + reserved = Math.min(reserved, quantity); + + return tx(() => { + run('UPDATE part_stock SET quantity = ?, reserved = ?, updated_at = ? WHERE id = ?', [quantity, reserved, now(), stock.id]); + run( + `INSERT INTO part_transactions (part_id, stock_id, type, quantity, unit_cost, asset_id, reference, notes, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + partId, stock.id, type, type === 'adjust' ? num(body.quantity) : qty, + body.unit_cost != null && body.unit_cost !== '' ? num(body.unit_cost) : null, + body.asset_id || null, body.reference || null, body.notes || null, userId, now() + ] + ); + audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id }); + return getPart(partId); + }); +} + +// ---- Photos ---------------------------------------------------------------- + +function addPhoto(partId, body, userId) { + if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null; + if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 }); + run( + 'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)', + [partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()] + ); + audit(userId, 'create', 'part_photo', partId, null, { name: body.name }); + return getPart(partId); +} + +function deletePhoto(partId, photoId, userId) { + const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]); + if (!result.changes) return null; + audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null); + return getPart(partId); +} + +module.exports = { + STATUSES, + TRANSACTION_TYPES, + addPhoto, + createPart, + deletePart, + deletePhoto, + deleteStock, + getPart, + listParts, + recordTransaction, + saveStock, + updatePart +}; diff --git a/server/services/pm.js b/server/services/pm.js new file mode 100644 index 0000000..3eabf8c --- /dev/null +++ b/server/services/pm.js @@ -0,0 +1,467 @@ +const { all, audit, json, now, one, parseJson, run, tx } = require('../db'); + +const FREQUENCY_UNITS = ['days', 'weeks', 'months', 'quarters', 'semiannual', 'years']; + +function todayStr() { + return new Date().toISOString().slice(0, 10); +} + +function addInterval(dateStr, value, unit) { + const date = new Date(`${dateStr || todayStr()}T00:00:00`); + const v = Math.max(1, Number(value) || 1); + switch (unit) { + case 'days': date.setDate(date.getDate() + v); break; + case 'weeks': date.setDate(date.getDate() + v * 7); break; + case 'quarters': date.setMonth(date.getMonth() + v * 3); break; + case 'semiannual': date.setMonth(date.getMonth() + v * 6); break; + case 'years': date.setFullYear(date.getFullYear() + v); break; + case 'months': + default: date.setMonth(date.getMonth() + v); break; + } + return date.toISOString().slice(0, 10); +} + +// ---- PM plan templates ----------------------------------------------------- + +function round2(value) { + return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100; +} + +function yearOf(value) { + return value ? Number(String(value).slice(0, 4)) : null; +} + +function planSteps(planId) { + return all('SELECT id, step_order, title, details, est_minutes FROM pm_plan_steps WHERE plan_id = ? ORDER BY step_order, id', [planId]); +} + +function planComponents(planId) { + return all('SELECT id, sort_order, part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [planId]); +} + +// Guidance photos for a plan. `withData` is false for list views (omits the base64 blob). +function planPhotos(planId, withData = true) { + const columns = withData ? 'id, name, mime_type, caption, data_base64, sort_order' : 'id, name, mime_type, caption, sort_order'; + return all(`SELECT ${columns} FROM pm_plan_photos WHERE plan_id = ? ORDER BY sort_order, id`, [planId]) + .map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, caption: p.caption, ...(withData ? { data: p.data_base64 } : {}) })); +} + +function stepsMinutes(steps) { + return (steps || []).reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0); +} + +function componentsTotal(components) { + return round2((components || []).reduce((sum, component) => sum + (Number(component.cost) || 0), 0)); +} + +function planFromRow(row, { withPhotoData = true } = {}) { + if (!row) return null; + const components = planComponents(row.id); + const photos = planPhotos(row.id, withPhotoData); + return { ...row, steps: planSteps(row.id), components, components_total: componentsTotal(components), photos, photo_count: photos.length }; +} + +function listPlans() { + return all('SELECT * FROM pm_plans ORDER BY name').map((row) => planFromRow(row, { withPhotoData: false })); +} + +function getPlan(id) { + return planFromRow(one('SELECT * FROM pm_plans WHERE id = ?', [id])); +} + +function writeSteps(planId, steps) { + run('DELETE FROM pm_plan_steps WHERE plan_id = ?', [planId]); + (steps || []).forEach((step, index) => { + const title = (step.title || '').trim(); + if (!title) return; + const est = step.est_minutes === '' || step.est_minutes == null ? null : Number(step.est_minutes); + run('INSERT INTO pm_plan_steps (plan_id, step_order, title, details, est_minutes) VALUES (?, ?, ?, ?, ?)', [planId, index, title, step.details || null, est]); + }); +} + +function writeComponents(planId, components) { + run('DELETE FROM pm_plan_components WHERE plan_id = ?', [planId]); + (components || []).forEach((component, index) => { + const part = (component.part_number || '').trim(); + const description = (component.description || '').trim(); + if (!part && !description && !Number(component.cost)) return; + run( + 'INSERT INTO pm_plan_components (plan_id, sort_order, part_number, description, supplier, cost) VALUES (?, ?, ?, ?, ?, ?)', + [planId, index, component.part_number || null, component.description || null, component.supplier || null, Number(component.cost) || 0] + ); + }); +} + +// Reconcile guidance photos: existing items (carry an id) keep their stored blob and +// only update caption/order; new items (carry data) are inserted; missing ones are removed. +function writePhotos(planId, photos) { + const existing = all('SELECT id FROM pm_plan_photos WHERE plan_id = ?', [planId]).map((r) => r.id); + const keep = new Set(); + (photos || []).forEach((photo, index) => { + if (photo.id && existing.includes(photo.id)) { + keep.add(photo.id); + run('UPDATE pm_plan_photos SET caption = ?, sort_order = ? WHERE id = ? AND plan_id = ?', [photo.caption || null, index, photo.id, planId]); + } else if (photo.data) { + const result = run( + 'INSERT INTO pm_plan_photos (plan_id, name, mime_type, caption, data_base64, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + [planId, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.caption || null, photo.data, index, now()] + ); + keep.add(result.lastInsertRowid); + } + }); + for (const id of existing) { + if (!keep.has(id)) run('DELETE FROM pm_plan_photos WHERE id = ?', [id]); + } +} + +// Estimated minutes are derived from the step times when steps are supplied. +function resolveEstimatedMinutes(body, fallback) { + if (Array.isArray(body.steps)) { + const total = stepsMinutes(body.steps); + if (total > 0) return total; + } + if (body.estimated_minutes != null && body.estimated_minutes !== '') return Number(body.estimated_minutes); + return fallback ?? null; +} + +function createPlan(body, userId) { + const ts = now(); + return tx(() => { + const result = run( + `INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + body.name || 'PM plan', body.description || null, body.category || null, + Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months', + resolveEstimatedMinutes(body, null), body.instructions || null, + userId, ts, ts + ] + ); + writeSteps(result.lastInsertRowid, body.steps); + writeComponents(result.lastInsertRowid, body.components); + writePhotos(result.lastInsertRowid, body.photos); + audit(userId, 'create', 'pm_plan', result.lastInsertRowid, null, { name: body.name }); + return getPlan(result.lastInsertRowid); + }); +} + +function updatePlan(id, body, userId) { + const before = one('SELECT * FROM pm_plans WHERE id = ?', [id]); + if (!before) return null; + return tx(() => { + run( + `UPDATE pm_plans SET name = ?, description = ?, category = ?, frequency_value = ?, frequency_unit = ?, + estimated_minutes = ?, instructions = ?, updated_at = ? WHERE id = ?`, + [ + body.name ?? before.name, body.description ?? before.description, body.category ?? before.category, + body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value, + FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit, + resolveEstimatedMinutes(body, before.estimated_minutes), + body.instructions ?? before.instructions, now(), id + ] + ); + if (Array.isArray(body.steps)) writeSteps(id, body.steps); + if (Array.isArray(body.components)) writeComponents(id, body.components); + if (Array.isArray(body.photos)) writePhotos(id, body.photos); + audit(userId, 'update', 'pm_plan', id, before, { name: body.name }); + return getPlan(id); + }); +} + +function deletePlan(id, userId) { + const before = one('SELECT * FROM pm_plans WHERE id = ?', [id]); + if (!before) return false; + run('DELETE FROM pm_plans WHERE id = ?', [id]); + audit(userId, 'delete', 'pm_plan', id, before, null); + return true; +} + +// ---- Per-asset PM schedules ------------------------------------------------ + +function assetPmRows(assetId) { + return all( + `SELECT ap.*, p.name AS plan_name, p.description AS plan_description, p.instructions AS plan_instructions, + u.name AS assigned_to_name + FROM asset_pm_plans ap + LEFT JOIN pm_plans p ON p.id = ap.plan_id + LEFT JOIN users u ON u.id = ap.assigned_to + WHERE ap.asset_id = ? + ORDER BY ap.active DESC, ap.next_due_date IS NULL, ap.next_due_date`, + [assetId] + ).map((row) => { + const components = row.plan_id ? planComponents(row.plan_id) : []; + const completions = all( + `SELECT c.id, c.completed_at, c.notes, c.notes_json, c.steps_json, c.cost, c.components_json, + c.rating_safety, c.rating_physical, c.rating_operating, + CASE WHEN c.signature IS NOT NULL THEN 1 ELSE 0 END AS has_signature, c.next_due_date, + u.name AS completed_by_name, + (SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photo_count + FROM pm_completions c LEFT JOIN users u ON u.id = c.completed_by + WHERE c.asset_pm_id = ? ORDER BY c.completed_at DESC LIMIT 12`, + [row.id] + ).map((c) => ({ + ...c, + has_signature: Boolean(c.has_signature), + notes: parseJson(c.notes_json, c.notes ? [c.notes] : []), + steps: parseJson(c.steps_json, []), + components: parseJson(c.components_json, []), + ratings: { safety: c.rating_safety, physical: c.rating_physical, operating: c.rating_operating } + })); + const totalCost = round2(completions.reduce((sum, c) => sum + Number(c.cost || 0), 0)); + return { + ...row, + active: Boolean(row.active), + steps: row.plan_id ? planSteps(row.plan_id) : [], + plan_photos: row.plan_id ? planPhotos(row.plan_id) : [], + components, + expected_cost: componentsTotal(components), + total_cost: totalCost, + last_cost: completions[0] ? round2(completions[0].cost) : 0, + completions + }; + }); +} + +function attachPlan(assetId, body, userId) { + const ts = now(); + const plan = body.plan_id ? one('SELECT * FROM pm_plans WHERE id = ?', [body.plan_id]) : null; + const value = Number(body.frequency_value) || (plan ? plan.frequency_value : 1); + const unit = FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : (plan ? plan.frequency_unit : 'months'); + const startDate = body.start_date || todayStr(); + const nextDue = body.next_due_date || startDate; + const result = run( + `INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, + [assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, ts, ts] + ); + audit(userId, 'attach', 'asset_pm', result.lastInsertRowid, null, { asset_id: assetId, plan_id: body.plan_id }); + return one('SELECT * FROM asset_pm_plans WHERE id = ?', [result.lastInsertRowid]); +} + +function updateAssetPm(assetId, apId, body, userId) { + const before = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]); + if (!before) return null; + run( + `UPDATE asset_pm_plans SET frequency_value = ?, frequency_unit = ?, start_date = ?, end_date = ?, + next_due_date = ?, assigned_to = ?, active = ?, notes = ?, updated_at = ? WHERE id = ?`, + [ + body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value, + FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit, + body.start_date ?? before.start_date, + body.end_date !== undefined ? body.end_date : before.end_date, + body.next_due_date ?? before.next_due_date, + body.assigned_to !== undefined ? body.assigned_to : before.assigned_to, + body.active === undefined ? before.active : (body.active ? 1 : 0), + body.notes ?? before.notes, + now(), apId + ] + ); + audit(userId, 'update', 'asset_pm', apId, before, body); + return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]); +} + +function detachAssetPm(assetId, apId, userId) { + const result = run('DELETE FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]); + if (!result.changes) return false; + audit(userId, 'detach', 'asset_pm', apId, null, { asset_id: assetId }); + return true; +} + +function clampRating(value) { + if (value == null || value === '') return null; + return Math.max(1, Math.min(5, Number(value))); +} + +function completePm(assetId, apId, body, userId) { + const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]); + if (!ap) return null; + if (!body.signature) throw Object.assign(new Error('A signature is required to complete a PM'), { status: 400 }); + + const ts = now(); + const completedDate = (body.completed_at || ts).slice(0, 10); + let nextDue = addInterval(completedDate, ap.frequency_value, ap.frequency_unit); + let active = ap.active; + if (ap.end_date && nextDue > ap.end_date) { + nextDue = null; + active = 0; // schedule complete — past the asset's maintenance end date + } + const planComps = ap.plan_id ? planComponents(ap.plan_id) : []; + const components = Array.isArray(body.components) ? body.components : planComps; + const cost = body.cost != null && body.cost !== '' ? round2(body.cost) : componentsTotal(planComps); + const notesList = Array.isArray(body.notes_list) ? body.notes_list.filter((n) => String(n || '').trim()) : (body.notes ? [body.notes] : []); + const ratings = body.ratings || {}; + const photos = Array.isArray(body.photos) ? body.photos.filter((p) => p && p.data) : []; + + return tx(() => { + const inserted = run( + `INSERT INTO pm_completions (asset_pm_id, completed_at, completed_by, notes, notes_json, steps_json, cost, components_json, + signature, rating_safety, rating_physical, rating_operating, next_due_date, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + apId, ts, userId, notesList.join('\n') || null, json(notesList), json(body.steps || []), cost, json(components), + body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), nextDue, ts + ] + ); + for (const photo of photos) { + run('INSERT INTO pm_completion_photos (completion_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)', [ + inserted.lastInsertRowid, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.data, ts + ]); + } + run('UPDATE asset_pm_plans SET last_completed_date = ?, next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [completedDate, nextDue, active, ts, apId]); + audit(userId, 'complete', 'asset_pm', apId, null, { next_due_date: nextDue, completion_id: inserted.lastInsertRowid }); + return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]); + }); +} + +function listCompletions(query = {}) { + return all( + `SELECT c.id, c.completed_at, c.cost, c.notes_json, c.rating_safety, c.rating_physical, c.rating_operating, + CASE WHEN c.signature IS NOT NULL THEN 1 ELSE 0 END AS has_signature, + u.name AS completed_by_name, a.asset_id AS asset_code, a.description AS asset_description, p.name AS plan_name, + (SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photo_count + FROM pm_completions c + JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id + JOIN assets a ON a.id = ap.asset_id + LEFT JOIN pm_plans p ON p.id = ap.plan_id + LEFT JOIN users u ON u.id = c.completed_by + WHERE (? IS NULL OR ap.asset_id = ?) + ORDER BY c.completed_at DESC, c.id DESC + LIMIT 500`, + [query.asset_id || null, query.asset_id || null] + ).map((r) => ({ ...r, has_signature: Boolean(r.has_signature), notes: parseJson(r.notes_json, []) })); +} + +function getCompletion(id) { + const row = one( + `SELECT c.*, u.name AS completed_by_name, a.asset_id AS asset_code, a.description AS asset_description, p.name AS plan_name + FROM pm_completions c + JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id + JOIN assets a ON a.id = ap.asset_id + LEFT JOIN pm_plans p ON p.id = ap.plan_id + LEFT JOIN users u ON u.id = c.completed_by + WHERE c.id = ?`, + [id] + ); + if (!row) return null; + return { + id: row.id, + asset_code: row.asset_code, + asset_description: row.asset_description, + plan_name: row.plan_name, + completed_at: row.completed_at, + completed_by_name: row.completed_by_name, + cost: row.cost, + notes: parseJson(row.notes_json, row.notes ? [row.notes] : []), + steps: parseJson(row.steps_json, []), + components: parseJson(row.components_json, []), + signature: row.signature || null, + ratings: { safety: row.rating_safety, physical: row.rating_physical, operating: row.rating_operating }, + photos: all('SELECT id, name, mime_type, data_base64 FROM pm_completion_photos WHERE completion_id = ? ORDER BY id', [row.id]) + .map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 })) + }; +} + +// ---- Settings & alert candidates ------------------------------------------ + +function getSettings() { + const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'pm_%'"); + const map = Object.fromEntries(rows.map((r) => [r.key, r.value])); + return { + pm_default_frequency_value: Number(map.pm_default_frequency_value || 3), + pm_default_frequency_unit: map.pm_default_frequency_unit || 'months', + pm_lead_days: Number(map.pm_lead_days || 7) + }; +} + +function saveSettings(body, userId) { + const set = (k, v) => run('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [k, String(v), now()]); + if (body.pm_default_frequency_value !== undefined) set('pm_default_frequency_value', Number(body.pm_default_frequency_value) || 3); + if (body.pm_default_frequency_unit !== undefined) set('pm_default_frequency_unit', FREQUENCY_UNITS.includes(body.pm_default_frequency_unit) ? body.pm_default_frequency_unit : 'months'); + if (body.pm_lead_days !== undefined) set('pm_lead_days', Number(body.pm_lead_days) || 7); + audit(userId, 'update', 'pm_settings', null, null, body); + return getSettings(); +} + +// Alert candidates for PM schedules that are due/overdue. +function pmAlertCandidates() { + const lead = getSettings().pm_lead_days; + const today = todayStr(); + const horizon = new Date(Date.now() + lead * 86400000).toISOString().slice(0, 10); + const rows = all( + `SELECT ap.*, p.name AS plan_name, a.asset_id AS asset_code + FROM asset_pm_plans ap + LEFT JOIN pm_plans p ON p.id = ap.plan_id + LEFT JOIN assets a ON a.id = ap.asset_id + WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL` + ); + const list = []; + for (const ap of rows) { + const name = ap.plan_name || 'Preventative maintenance'; + const where = ap.asset_code ? ` on ${ap.asset_code}` : ''; + if (ap.next_due_date < today) { + list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.`, due_date: ap.next_due_date }); + } else if (ap.next_due_date <= horizon) { + list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.`, due_date: ap.next_due_date }); + } + } + return list; +} + +// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year. +function pmBookLedger(year) { + const rows = all( + `SELECT c.cost, c.completed_at, a.asset_id, a.description + FROM pm_completions c + JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id + JOIN assets a ON a.id = ap.asset_id` + ); + const byAsset = {}; + let total = 0; + let count = 0; + for (const row of rows) { + if (yearOf(row.completed_at) !== Number(year)) continue; + const cost = Number(row.cost || 0); + const entry = byAsset[row.asset_id] || (byAsset[row.asset_id] = { asset_id: row.asset_id, description: row.description, completions: 0, cost: 0, last_service: null }); + entry.completions += 1; + entry.cost += cost; + const day = String(row.completed_at).slice(0, 10); + if (!entry.last_service || day > entry.last_service) entry.last_service = day; + total += cost; + count += 1; + } + const assets = Object.values(byAsset).map((a) => ({ ...a, cost: round2(a.cost) })).sort((a, b) => b.cost - a.cost); + const accounts = [ + { account: '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 }, + { account: '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) } + ]; + return { + year: Number(year), + kind: 'maintenance', + rule_set: {}, + summary: { assets: assets.length, completions: count, cost: round2(total) }, + accounts, + accountTotals: { debit: round2(total), credit: round2(total) }, + assets + }; +} + +module.exports = { + FREQUENCY_UNITS, + addInterval, + assetPmRows, + pmBookLedger, + attachPlan, + completePm, + createPlan, + deletePlan, + detachAssetPm, + getCompletion, + getPlan, + listCompletions, + getSettings, + listPlans, + pmAlertCandidates, + saveSettings, + updateAssetPm, + updatePlan +}; diff --git a/server/services/profile.js b/server/services/profile.js index 4877a82..50d805b 100644 --- a/server/services/profile.js +++ b/server/services/profile.js @@ -1,5 +1,6 @@ const { json, now, one, parseJson, run } = require('../db'); const { publicUser } = require('../middleware/auth'); +const { capabilitiesForRole } = require('./roles'); const defaultPreferences = { theme: 'mixedAssetsDark' @@ -16,7 +17,8 @@ function getPreferences(userId) { function getProfile(user) { return { user: publicUser(user), - preferences: getPreferences(user.id) + preferences: getPreferences(user.id), + capabilities: capabilitiesForRole(user.role) }; } diff --git a/server/services/reportEngine.js b/server/services/reportEngine.js new file mode 100644 index 0000000..d1208d1 --- /dev/null +++ b/server/services/reportEngine.js @@ -0,0 +1,807 @@ +const { all, now, one, parseJson } = require('../db'); +const { annualSchedule, money } = require('../depreciation'); +const { ruleSetForBook } = require('./ruleSets'); +const { assetFromRow } = require('./assets'); + +const BOOKS = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER']; + +function currentYear() { + return new Date().getFullYear(); +} + +function yearOf(value) { + if (!value) return null; + return new Date(`${value}T00:00:00`).getFullYear(); +} + +function sumBy(rows, keys) { + const totals = {}; + for (const key of keys) totals[key] = money(rows.reduce((sum, row) => sum + Number(row[key] || 0), 0)); + return totals; +} + +// Columns helper: { key, label, format } where format drives client + export rendering. +function col(key, label, format = 'text') { + return { key, label, format }; +} + +// ---- Asset/book loading ---------------------------------------------------- + +function assetBookRows(bookType, options = {}) { + return all( + `SELECT a.*, b.book_type, b.cost AS book_cost, b.depreciation_method, b.convention, + b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent, + b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, + b.manual_depreciation, e.name AS entity_name + FROM assets a + JOIN asset_books b ON b.asset_id = a.id + LEFT JOIN entities e ON e.id = a.entity_id + WHERE b.book_type = ? AND b.active = 1 + AND (? IS NULL OR a.entity_id = ?) + AND (? IS NULL OR a.category = ?) + AND (? IS NULL OR a.status = ?) + ORDER BY a.asset_id`, + [ + bookType, + options.entity_id || null, options.entity_id || null, + options.category || null, options.category || null, + options.status || null, options.status || null + ] + ); +} + +function toAssetBook(row) { + return { + asset: assetFromRow(row), + entity_name: row.entity_name, + book: { + book_type: row.book_type, + cost: row.book_cost, + depreciation_method: row.depreciation_method, + convention: row.convention, + useful_life_months: row.book_life, + section_179_amount: row.section_179_amount, + bonus_percent: row.bonus_percent, + business_use_percent: row.book_bup, + investment_use_percent: row.book_ivp, + manual_depreciation: row.manual_depreciation + } + }; +} + +function computeYear(asset, book, rules, year) { + const rows = annualSchedule(asset, book, rules, { startYear: year - 1, endYear: year }); + const end = rows.find((row) => row.year === year); + const cost = Number(book.cost ?? asset.acquisition_cost ?? 0); + const depreciation = end ? Number(end.depreciation) : 0; + const accumulatedEnd = end ? Number(end.accumulated) : Number(asset.prior_depreciation || 0); + const accumulatedBegin = money(accumulatedEnd - depreciation); + return { + cost, + depreciation: money(depreciation), + accumulated_begin: accumulatedBegin, + accumulated_end: money(accumulatedEnd), + nbv_begin: money(cost - accumulatedBegin), + nbv_end: money(cost - accumulatedEnd), + method: end ? end.method : book.depreciation_method, + methodLabel: end ? end.methodLabel : book.depreciation_method + }; +} + +function lifetimeAccumulated(asset, book, rules) { + const rows = annualSchedule(asset, book, rules, { startYear: currentYear(), endYear: currentYear() }); + return rows.length ? Number(rows[rows.length - 1].accumulated) : Number(asset.prior_depreciation || 0); +} + +// ---- Report builders ------------------------------------------------------- + +function depreciationExpense(options, titleBook) { + const book = titleBook || options.book || 'GAAP'; + const year = Number(options.year) || currentYear(); + const rules = ruleSetForBook(book); + const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => { + const c = computeYear(asset, bk, rules, year); + return { + asset_id: asset.asset_id, + description: asset.description, + entity: entity_name, + category: asset.category, + method: c.methodLabel, + cost: c.cost, + depreciation: c.depreciation, + accumulated: c.accumulated_end, + nbv: c.nbv_end + }; + }); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'), + col('method', 'Method'), col('cost', 'Cost', 'currency'), col('depreciation', 'Depreciation', 'currency'), + col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency') + ], + rows, + totals: sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv']) + }; +} + +function monthlyDepreciation(options) { + const book = options.book || 'GAAP'; + const year = Number(options.year) || currentYear(); + const annual = depreciationExpense({ ...options, book }).totals.depreciation; + const monthly = money(annual / 12); + const rows = Array.from({ length: 12 }, (_, index) => ({ period: `M${index + 1}`, depreciation: monthly })); + return { + columns: [col('period', 'Month'), col('depreciation', 'Depreciation', 'currency')], + rows, + totals: { depreciation: money(monthly * 12) }, + chart: { type: 'bar', labels: rows.map((r) => r.period), values: rows.map((r) => r.depreciation) } + }; +} + +function quarterlyDepreciation(options) { + const annual = depreciationExpense(options).totals.depreciation; + const quarter = money(annual / 4); + const rows = ['Q1', 'Q2', 'Q3', 'Q4'].map((period) => ({ period, depreciation: quarter })); + return { + columns: [col('period', 'Quarter'), col('depreciation', 'Depreciation', 'currency')], + rows, + totals: { depreciation: money(quarter * 4) }, + chart: { type: 'bar', labels: rows.map((r) => r.period), values: rows.map((r) => r.depreciation) } + }; +} + +function ytdDepreciation(options) { + const month = Math.min(12, Math.max(1, Number(options.month) || 12)); + const base = depreciationExpense(options); + const rows = base.rows.map((row) => ({ ...row, ytd: money((row.depreciation * month) / 12) })); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Method'), + col('depreciation', 'Annual', 'currency'), col('ytd', `YTD through M${month}`, 'currency'), + col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency') + ], + rows, + totals: sumBy(rows, ['depreciation', 'ytd', 'accumulated', 'nbv']) + }; +} + +function netBookValue(options) { + const book = options.book || 'GAAP'; + const year = Number(options.year) || currentYear(); + const rules = ruleSetForBook(book); + const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => { + const c = computeYear(asset, bk, rules, year); + return { + asset_id: asset.asset_id, description: asset.description, entity: entity_name, + cost: c.cost, accumulated: c.accumulated_end, nbv: c.nbv_end + }; + }); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('entity', 'Entity'), + col('cost', 'Cost', 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency') + ], + rows, + totals: sumBy(rows, ['cost', 'accumulated', 'nbv']) + }; +} + +function lifetimeDepreciation(options) { + const book = options.book || 'GAAP'; + const rules = ruleSetForBook(book); + const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => { + const cost = Number(bk.cost ?? asset.acquisition_cost ?? 0); + const accumulated = lifetimeAccumulated(asset, bk, rules); + return { + asset_id: asset.asset_id, description: asset.description, entity: entity_name, + in_service_date: asset.in_service_date, cost, + lifetime_depreciation: money(accumulated), nbv: money(cost - accumulated) + }; + }); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('in_service_date', 'In service', 'date'), + col('cost', 'Cost', 'currency'), col('lifetime_depreciation', 'Lifetime depr.', 'currency'), col('nbv', 'Net book value', 'currency') + ], + rows, + totals: sumBy(rows, ['cost', 'lifetime_depreciation', 'nbv']) + }; +} + +function acquisitions(options) { + const year = Number(options.year) || currentYear(); + const rows = all( + `SELECT a.asset_id, a.description, a.category, a.acquired_date, a.vendor, a.invoice_number, a.acquisition_cost + FROM assets a WHERE a.acquired_date IS NOT NULL + AND (? IS NULL OR a.entity_id = ?) + ORDER BY a.acquired_date`, + [options.entity_id || null, options.entity_id || null] + ).filter((row) => yearOf(row.acquired_date) === year) + .map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) })); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'), + col('acquired_date', 'Acquired', 'date'), col('vendor', 'Vendor'), col('invoice_number', 'Invoice'), + col('acquisition_cost', 'Cost', 'currency') + ], + rows, + totals: sumBy(rows, ['acquisition_cost']) + }; +} + +function disposalRows(year, entityId) { + return all( + `SELECT d.*, a.asset_id, a.description, a.category, a.acquired_date + FROM disposals d JOIN assets a ON a.id = d.asset_id + WHERE (? IS NULL OR a.entity_id = ?) + ORDER BY d.disposal_date`, + [entityId || null, entityId || null] + ).filter((row) => yearOf(row.disposal_date) === year); +} + +function disposals(options) { + const year = Number(options.year) || currentYear(); + const rows = disposalRows(year, options.entity_id).map((row) => ({ + asset_id: row.asset_id, + description: row.description, + method: row.method, + disposal_date: row.disposal_date, + proceeds: money(row.disposal_price - row.disposal_expense), + net_book_value: money(row.net_book_value), + gain_loss: money(row.gain_loss) + })); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Type'), + col('disposal_date', 'Date', 'date'), col('proceeds', 'Net proceeds', 'currency'), + col('net_book_value', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency') + ], + rows, + totals: sumBy(rows, ['proceeds', 'net_book_value', 'gain_loss']) + }; +} + +function adjustments(options) { + const year = Number(options.year) || currentYear(); + const rows = all( + `SELECT adj.*, a.asset_id, a.description FROM asset_adjustments adj + JOIN assets a ON a.id = adj.asset_id + WHERE (? IS NULL OR a.entity_id = ?) + ORDER BY adj.adjustment_date`, + [options.entity_id || null, options.entity_id || null] + ).filter((row) => yearOf(row.adjustment_date) === year) + .map((row) => ({ + asset_id: row.asset_id, description: row.description, type: row.type, + book_type: row.book_type, adjustment_date: row.adjustment_date, + amount: money(row.amount), reason: row.reason + })); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('type', 'Type'), + col('book_type', 'Book'), col('adjustment_date', 'Date', 'date'), col('amount', 'Amount', 'currency'), col('reason', 'Reason') + ], + rows, + totals: sumBy(rows, ['amount']) + }; +} + +function rollforward(options) { + const book = options.book || 'GAAP'; + const year = Number(options.year) || currentYear(); + const rules = ruleSetForBook(book); + const groups = {}; + for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) { + const c = computeYear(asset, bk, rules, year); + const key = asset.category || 'Uncategorized'; + groups[key] = groups[key] || { category: key, beginning_nbv: 0, additions: 0, depreciation: 0, disposals: 0, ending_nbv: 0 }; + groups[key].beginning_nbv += c.nbv_begin; + groups[key].depreciation += c.depreciation; + if (yearOf(asset.acquired_date) === year) groups[key].additions += c.cost; + groups[key].ending_nbv += c.nbv_end; + } + for (const row of disposalRows(year, options.entity_id)) { + const key = row.category || 'Uncategorized'; + groups[key] = groups[key] || { category: key, beginning_nbv: 0, additions: 0, depreciation: 0, disposals: 0, ending_nbv: 0 }; + groups[key].disposals += Number(row.net_book_value || 0); + } + const rows = Object.values(groups).map((row) => ({ + category: row.category, + beginning_nbv: money(row.beginning_nbv), + additions: money(row.additions), + depreciation: money(row.depreciation), + disposals: money(row.disposals), + ending_nbv: money(row.ending_nbv) + })); + return { + columns: [ + col('category', 'Category'), col('beginning_nbv', 'Beginning NBV', 'currency'), + col('additions', 'Additions', 'currency'), col('depreciation', 'Depreciation', 'currency'), + col('disposals', 'Disposals (NBV)', 'currency'), col('ending_nbv', 'Ending NBV', 'currency') + ], + rows, + totals: sumBy(rows, ['beginning_nbv', 'additions', 'depreciation', 'disposals', 'ending_nbv']) + }; +} + +function projection(options) { + const book = options.book || 'GAAP'; + const startYear = Number(options.startYear) || currentYear(); + const endYear = Number(options.endYear) || startYear + 5; + const rules = ruleSetForBook(book); + const items = assetBookRows(book, options).map(toAssetBook); + const rows = []; + for (let year = startYear; year <= endYear; year += 1) { + let depreciation = 0; + let nbv = 0; + for (const { asset, book: bk } of items) { + const c = computeYear(asset, bk, rules, year); + depreciation += c.depreciation; + nbv += c.nbv_end; + } + rows.push({ year, depreciation: money(depreciation), ending_nbv: money(nbv) }); + } + return { + columns: [col('year', 'Year', 'number'), col('depreciation', 'Projected depreciation', 'currency'), col('ending_nbv', 'Ending NBV', 'currency')], + rows, + totals: { depreciation: money(rows.reduce((sum, row) => sum + row.depreciation, 0)) }, + chart: { type: 'bar', labels: rows.map((r) => String(r.year)), values: rows.map((r) => r.depreciation) } + }; +} + +function journalDepreciation(options) { + const book = options.book || 'GAAP'; + const year = Number(options.year) || currentYear(); + const rules = ruleSetForBook(book); + const accounts = {}; + for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) { + const c = computeYear(asset, bk, rules, year); + if (!c.depreciation) continue; + const expense = asset.gl_expense_account || '6400'; + const accum = asset.gl_accumulated_account || '1590'; + accounts[expense] = (accounts[expense] || 0) + c.depreciation; + accounts[`__accum__${accum}`] = (accounts[`__accum__${accum}`] || 0) + c.depreciation; + } + const rows = []; + for (const [key, amount] of Object.entries(accounts)) { + if (key.startsWith('__accum__')) { + rows.push({ account: key.replace('__accum__', ''), memo: 'Accumulated depreciation', debit: 0, credit: money(amount) }); + } else { + rows.push({ account: key, memo: 'Depreciation expense', debit: money(amount), credit: 0 }); + } + } + rows.sort((a, b) => b.debit - a.debit); + return { + columns: [col('account', 'GL account'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')], + rows, + totals: sumBy(rows, ['debit', 'credit']) + }; +} + +function journalDisposals(options) { + const year = Number(options.year) || currentYear(); + const rows = []; + for (const d of disposalRows(year, options.entity_id)) { + const proceeds = money(d.disposal_price - d.disposal_expense); + rows.push({ asset_id: d.asset_id, memo: 'Cash / proceeds', debit: proceeds, credit: 0 }); + rows.push({ asset_id: d.asset_id, memo: 'Accumulated depreciation', debit: money(d.accumulated_depreciation), credit: 0 }); + rows.push({ asset_id: d.asset_id, memo: 'Asset cost', debit: 0, credit: money(d.disposed_cost) }); + if (d.gain_loss >= 0) rows.push({ asset_id: d.asset_id, memo: 'Gain on disposal', debit: 0, credit: money(d.gain_loss) }); + else rows.push({ asset_id: d.asset_id, memo: 'Loss on disposal', debit: money(Math.abs(d.gain_loss)), credit: 0 }); + } + return { + columns: [col('asset_id', 'Asset ID'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')], + rows, + totals: sumBy(rows, ['debit', 'credit']) + }; +} + +function form4562(options) { + const year = Number(options.year) || currentYear(); + const rules = ruleSetForBook('FEDERAL'); + const items = assetBookRows('FEDERAL', options).map(toAssetBook) + .filter(({ asset }) => yearOf(asset.in_service_date || asset.acquired_date) === year); + let section179 = 0; + let bonus = 0; + let macrs = 0; + const rows = items.map(({ asset, book: bk }) => { + const c = computeYear(asset, bk, rules, year); + const s179 = Math.min(Number(bk.cost ?? asset.acquisition_cost ?? 0), Number(bk.section_179_amount || 0)); + section179 += s179; + macrs += c.depreciation; + return { + asset_id: asset.asset_id, description: asset.description, method: c.methodLabel, + cost: c.cost, section_179: money(s179), depreciation: c.depreciation + }; + }); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Method'), + col('cost', 'Cost', 'currency'), col('section_179', '§179', 'currency'), col('depreciation', 'Depreciation', 'currency') + ], + rows, + totals: { section_179: money(section179), bonus: money(bonus), depreciation: money(macrs), cost: money(rows.reduce((s, r) => s + r.cost, 0)) }, + meta: { note: 'Summary for IRS Form 4562 (Federal book, current-year placements). Validate against current tax law before filing.' } + }; +} + +function form4797(options) { + const year = Number(options.year) || currentYear(); + const rows = disposalRows(year, options.entity_id).map((d) => ({ + asset_id: d.asset_id, + description: d.description, + property_type: d.property_type || 'other', + acquired_date: d.acquired_date, + disposal_date: d.disposal_date, + proceeds: money(d.disposal_price - d.disposal_expense), + cost: money(d.disposed_cost), + accumulated: money(d.accumulated_depreciation), + gain_loss: money(d.gain_loss) + })); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('property_type', 'Property type'), + col('acquired_date', 'Acquired', 'date'), col('disposal_date', 'Sold', 'date'), + col('proceeds', 'Proceeds', 'currency'), col('cost', 'Cost basis', 'currency'), + col('accumulated', 'Depr. allowed', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency') + ], + rows, + totals: sumBy(rows, ['proceeds', 'cost', 'accumulated', 'gain_loss']), + meta: { note: 'Summary for IRS Form 4797 (Sales of Business Property). §1245/§1250 recapture analysis should be reviewed before filing.' } + }; +} + +function personalPropertyTax(options) { + const book = options.book || 'GAAP'; + const year = Number(options.year) || currentYear(); + const rules = ruleSetForBook(book); + const groups = {}; + for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) { + const c = computeYear(asset, bk, rules, year); + const key = asset.location || 'Unassigned'; + groups[key] = groups[key] || { location: key, count: 0, cost: 0, nbv: 0 }; + groups[key].count += 1; + groups[key].cost += c.cost; + groups[key].nbv += c.nbv_end; + } + const rows = Object.values(groups).map((row) => ({ ...row, cost: money(row.cost), nbv: money(row.nbv) })); + return { + columns: [col('location', 'Location'), col('count', 'Assets', 'number'), col('cost', 'Cost', 'currency'), col('nbv', 'Taxable NBV', 'currency')], + rows, + totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost', 'nbv']) } + }; +} + +function constructionInProgress(options) { + const rows = all( + `SELECT asset_id, description, category, acquired_date, acquisition_cost, vendor + FROM assets WHERE status = 'cip' AND (? IS NULL OR entity_id = ?) ORDER BY acquired_date`, + [options.entity_id || null, options.entity_id || null] + ).map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) })); + return { + columns: [ + col('asset_id', 'Project ID'), col('description', 'Description'), col('category', 'Category'), + col('acquired_date', 'Started', 'date'), col('vendor', 'Vendor'), col('acquisition_cost', 'Cost to date', 'currency') + ], + rows, + totals: sumBy(rows, ['acquisition_cost']) + }; +} + +function ubia(options) { + const rows = all( + `SELECT asset_id, description, in_service_date, acquisition_cost, land_value + FROM assets WHERE status != 'disposed' AND (? IS NULL OR entity_id = ?) ORDER BY asset_id`, + [options.entity_id || null, options.entity_id || null] + ).map((row) => ({ + asset_id: row.asset_id, description: row.description, in_service_date: row.in_service_date, + ubia: money(Number(row.acquisition_cost || 0) - Number(row.land_value || 0)) + })); + return { + columns: [ + col('asset_id', 'Asset ID'), col('description', 'Description'), + col('in_service_date', 'In service', 'date'), col('ubia', 'UBIA', 'currency') + ], + rows, + totals: sumBy(rows, ['ubia']), + meta: { note: 'Unadjusted Basis Immediately After Acquisition for the §199A qualified business income limitation.' } + }; +} + +function fixedAssetCard(options) { + const asset = options.asset_id + ? assetFromRow(one('SELECT * FROM assets WHERE id = ? OR asset_id = ? LIMIT 1', [options.asset_id, options.asset_id])) + : null; + if (!asset) return { columns: [], rows: [], totals: {}, meta: { note: 'Select an asset to view its card.' } }; + const year = Number(options.year) || currentYear(); + const books = all('SELECT * FROM asset_books WHERE asset_id = ? AND active = 1 ORDER BY book_type', [asset.id]); + const rows = books.map((bookRow) => { + const book = { ...bookRow, useful_life_months: bookRow.useful_life_months, manual_depreciation: bookRow.manual_depreciation }; + const rules = ruleSetForBook(bookRow.book_type); + const c = computeYear(asset, book, rules, year); + return { + book: bookRow.book_type, method: c.methodLabel, cost: c.cost, + depreciation: c.depreciation, accumulated: c.accumulated_end, nbv: c.nbv_end + }; + }); + return { + columns: [ + col('book', 'Book'), col('method', 'Method'), col('cost', 'Cost', 'currency'), + col('depreciation', `${year} depr.`, 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency') + ], + rows, + totals: sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv']), + meta: { + asset: { + asset_id: asset.asset_id, description: asset.description, category: asset.category, status: asset.status, + vendor: asset.vendor, serial_number: asset.serial_number, location: asset.location, + department: asset.department, custodian: asset.custodian, acquired_date: asset.acquired_date, + in_service_date: asset.in_service_date, barcode_value: asset.barcode_value + } + } + }; +} + +// ---- Report Builder (custom) ---------------------------------------------- + +const BUILDER_FIELDS = [ + col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'), + col('status', 'Status'), col('entity_name', 'Entity'), col('location', 'Location'), + col('department', 'Department'), col('group_name', 'Group'), col('custodian', 'Custodian'), + col('vendor', 'Vendor'), col('serial_number', 'Serial'), col('invoice_number', 'Invoice'), + col('barcode_value', 'Barcode'), col('condition', 'Condition'), col('new_or_used', 'New/Used'), + col('acquisition_cost', 'Cost', 'currency'), col('salvage_value', 'Salvage', 'currency'), + col('land_value', 'Land value', 'currency'), col('useful_life_months', 'Life (months)', 'number'), + col('acquired_date', 'Acquired', 'date'), col('in_service_date', 'In service', 'date'), + col('gl_asset_account', 'Asset GL'), col('gl_expense_account', 'Expense GL'), col('gl_accumulated_account', 'Accum. GL') +]; + +function customReport(options) { + const requested = Array.isArray(options.columns) && options.columns.length + ? options.columns + : ['asset_id', 'description', 'category', 'status', 'acquisition_cost']; + const columns = BUILDER_FIELDS.filter((field) => requested.includes(field.key)); + const rows = all( + `SELECT a.*, e.name AS entity_name FROM assets a LEFT JOIN entities e ON e.id = a.entity_id + WHERE (? IS NULL OR a.entity_id = ?) + AND (? IS NULL OR a.category = ?) + AND (? IS NULL OR a.status = ?) + ORDER BY a.asset_id`, + [ + options.entity_id || null, options.entity_id || null, + options.category || null, options.category || null, + options.status || null, options.status || null + ] + ).map((row) => { + const projected = {}; + for (const column of columns) projected[column.key] = row[column.key]; + return projected; + }); + const currencyKeys = columns.filter((c) => c.format === 'currency').map((c) => c.key); + return { columns, rows, totals: sumBy(rows, currencyKeys) }; +} + +// ---- Maintenance & alerts -------------------------------------------------- + +function dateStr(date) { + return date.toISOString().slice(0, 10); +} + +function freqLabel(value, unit) { + return `every ${value} ${unit}`; +} + +function pmDue(options) { + const before = options.before_date || dateStr(new Date(Date.now() + 30 * 86400000)); + const rows = all( + `SELECT ap.next_due_date, ap.last_completed_date, ap.frequency_value, ap.frequency_unit, + p.name AS plan_name, a.asset_id AS asset_code, a.description AS asset_description, u.name AS assigned_to_name + FROM asset_pm_plans ap + JOIN assets a ON a.id = ap.asset_id + LEFT JOIN pm_plans p ON p.id = ap.plan_id + LEFT JOIN users u ON u.id = ap.assigned_to + WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL AND ap.next_due_date <= ? + AND (? IS NULL OR a.entity_id = ?) + ORDER BY ap.next_due_date`, + [before, options.entity_id || null, options.entity_id || null] + ).map((row) => ({ ...row, plan_name: row.plan_name || 'Maintenance', frequency: freqLabel(row.frequency_value, row.frequency_unit) })); + return { + columns: [ + col('asset_code', 'Asset'), col('asset_description', 'Description'), col('plan_name', 'Plan'), + col('frequency', 'Frequency'), col('next_due_date', 'Next due', 'date'), col('last_completed_date', 'Last done', 'date'), + col('assigned_to_name', 'Assigned to') + ], + rows, + totals: {}, + meta: { note: `${rows.length} PM schedule(s) due on or before ${before}.` } + }; +} + +function pmUncompleted(options) { + const today = dateStr(new Date()); + const rows = all( + `SELECT ap.next_due_date, ap.last_completed_date, ap.frequency_value, ap.frequency_unit, + p.name AS plan_name, a.asset_id AS asset_code, a.description AS asset_description, u.name AS assigned_to_name + FROM asset_pm_plans ap + JOIN assets a ON a.id = ap.asset_id + LEFT JOIN pm_plans p ON p.id = ap.plan_id + LEFT JOIN users u ON u.id = ap.assigned_to + WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL AND ap.next_due_date < ? + AND (? IS NULL OR a.entity_id = ?) + ORDER BY ap.next_due_date`, + [today, options.entity_id || null, options.entity_id || null] + ).map((row) => ({ + ...row, + plan_name: row.plan_name || 'Maintenance', + frequency: freqLabel(row.frequency_value, row.frequency_unit), + days_overdue: Math.max(0, Math.round((new Date(`${today}T00:00:00`) - new Date(`${row.next_due_date}T00:00:00`)) / 86400000)) + })); + return { + columns: [ + col('asset_code', 'Asset'), col('asset_description', 'Description'), col('plan_name', 'Plan'), + col('next_due_date', 'Was due', 'date'), col('days_overdue', 'Days overdue', 'number'), + col('last_completed_date', 'Last done', 'date'), col('assigned_to_name', 'Assigned to') + ], + rows, + totals: {}, + meta: { note: `${rows.length} asset PM schedule(s) overdue / uncompleted as of ${today}.` } + }; +} + +function pmCompleted(options) { + const since = options.since_date || dateStr(new Date(Date.now() - 30 * 86400000)); + const rows = all( + `SELECT c.completed_at, c.cost, c.rating_safety, c.rating_physical, c.rating_operating, + CASE WHEN c.signature IS NOT NULL THEN 'yes' ELSE 'no' END AS signed, + (SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photos, + u.name AS completed_by_name, a.asset_id AS asset_code, p.name AS plan_name + FROM pm_completions c + JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id + JOIN assets a ON a.id = ap.asset_id + LEFT JOIN pm_plans p ON p.id = ap.plan_id + LEFT JOIN users u ON u.id = c.completed_by + WHERE c.completed_at >= ? + AND (? IS NULL OR a.entity_id = ?) + ORDER BY c.completed_at DESC`, + [since, options.entity_id || null, options.entity_id || null] + ).map((row) => ({ ...row, plan_name: row.plan_name || 'Maintenance', completed_at: String(row.completed_at).slice(0, 10), cost: money(row.cost) })); + return { + columns: [ + col('asset_code', 'Asset'), col('plan_name', 'Plan'), col('completed_at', 'Completed', 'date'), + col('completed_by_name', 'By'), col('rating_safety', 'Safety', 'number'), col('rating_operating', 'Operating', 'number'), + col('photos', 'Photos', 'number'), col('signed', 'Signed'), col('cost', 'Cost', 'currency') + ], + rows, + totals: sumBy(rows, ['cost']), + meta: { note: `${rows.length} PM service(s) completed since ${since}.` } + }; +} + +function pmPlanPrint(options) { + const plan = options.pm_plan_id ? one('SELECT * FROM pm_plans WHERE id = ?', [options.pm_plan_id]) : null; + if (!plan) return { columns: [], rows: [], totals: {}, meta: { note: 'Select a PM plan to print.' } }; + const steps = all('SELECT step_order, title, details, est_minutes FROM pm_plan_steps WHERE plan_id = ? ORDER BY step_order, id', [plan.id]); + const components = all('SELECT part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [plan.id]); + const rows = steps.map((step, index) => ({ step: index + 1, title: step.title, details: step.details, est_minutes: step.est_minutes })); + const estTotal = steps.reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0); + const partsTotal = components.reduce((sum, comp) => sum + (Number(comp.cost) || 0), 0); + const partsLines = components.map((comp) => `${comp.part_number ? comp.part_number + ' ' : ''}${comp.description || ''}${comp.supplier ? ` (${comp.supplier})` : ''} — ${money(comp.cost)}`).join('; '); + const noteParts = [ + `Frequency: ${freqLabel(plan.frequency_value, plan.frequency_unit)}`, + `Estimated time: ${estTotal} min` + ]; + if (plan.description) noteParts.push(`Description: ${plan.description}`); + if (plan.instructions) noteParts.push(`Instructions: ${plan.instructions}`); + if (components.length) noteParts.push(`Parts (${money(partsTotal)}): ${partsLines}`); + return { + title: `PM plan — ${plan.name}`, + columns: [col('step', '#', 'number'), col('title', 'Step'), col('details', 'Details'), col('est_minutes', 'Est. min', 'number')], + rows, + totals: { est_minutes: estTotal }, + meta: { note: noteParts.join(' · ') } + }; +} + +function alertsSince(options) { + const since = options.since_date || dateStr(new Date(Date.now() - 30 * 86400000)); + const rows = all( + `SELECT al.severity, al.type, al.title, al.due_date, al.status, al.created_at, a.asset_id AS asset_code + FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id + WHERE al.created_at >= ? + ORDER BY al.created_at DESC`, + [since] + ).map((row) => ({ ...row, type: String(row.type).replace(/_/g, ' '), created_at: String(row.created_at).slice(0, 10) })); + return { + columns: [ + col('severity', 'Severity'), col('type', 'Type'), col('title', 'Alert'), col('asset_code', 'Asset'), + col('due_date', 'Due', 'date'), col('status', 'Status'), col('created_at', 'Raised', 'date') + ], + rows, + totals: {}, + meta: { note: `${rows.length} alert(s) raised since ${since}.` } + }; +} + +// ---- Registry -------------------------------------------------------------- + +const REPORTS = { + depreciation_expense: { title: 'Annual depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => depreciationExpense(o) }, + monthly_depreciation: { title: '12-month depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => monthlyDepreciation(o) }, + quarterly_depreciation: { title: 'Quarterly depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => quarterlyDepreciation(o) }, + ytd_depreciation: { title: 'Annual & YTD depreciation', group: 'Depreciation', params: ['book', 'year', 'month', 'entity_id'], build: (o) => ytdDepreciation(o) }, + net_book_value: { title: 'Net book value', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => netBookValue(o) }, + lifetime_depreciation: { title: 'Lifetime depreciation', group: 'Depreciation', params: ['book', 'entity_id'], build: (o) => lifetimeDepreciation(o) }, + projection: { title: 'Projection', group: 'Depreciation', params: ['book', 'startYear', 'endYear', 'entity_id'], build: (o) => projection(o) }, + rollforward: { title: 'Annual rollforward', group: 'Activity', params: ['book', 'year', 'entity_id'], build: (o) => rollforward(o) }, + acquisitions: { title: 'Annual acquisitions', group: 'Activity', params: ['year', 'entity_id'], build: (o) => acquisitions(o) }, + disposals: { title: 'Annual disposals & gain/loss', group: 'Activity', params: ['year', 'entity_id'], build: (o) => disposals(o) }, + adjustments: { title: 'Annual adjustments', group: 'Activity', params: ['year', 'entity_id'], build: (o) => adjustments(o) }, + cip: { title: 'Construction-in-progress', group: 'Activity', params: ['entity_id'], build: (o) => constructionInProgress(o) }, + journal_depreciation: { title: 'Journal entry: depreciation', group: 'Journal entries', params: ['book', 'year', 'entity_id'], build: (o) => journalDepreciation(o) }, + journal_disposals: { title: 'Journal entry: disposals', group: 'Journal entries', params: ['year', 'entity_id'], build: (o) => journalDisposals(o) }, + form_4562: { title: 'Federal Form 4562 (depreciation)', group: 'Tax', params: ['year', 'entity_id'], build: (o) => form4562(o) }, + form_4797: { title: 'Federal Form 4797 (sales)', group: 'Tax', params: ['year', 'entity_id'], build: (o) => form4797(o) }, + amt: { title: 'AMT depreciation', group: 'Tax', params: ['year', 'entity_id'], build: (o) => depreciationExpense(o, 'AMT') }, + ace: { title: 'ACE depreciation', group: 'Tax', params: ['year', 'entity_id'], build: (o) => depreciationExpense(o, 'ACE') }, + personal_property_tax: { title: 'Personal property tax', group: 'Tax', params: ['book', 'year', 'entity_id'], build: (o) => personalPropertyTax(o) }, + ubia: { title: 'UBIA (§199A)', group: 'Tax', params: ['entity_id'], build: (o) => ubia(o) }, + pm_due: { title: 'PM due before date', group: 'Maintenance', params: ['before_date', 'entity_id'], build: (o) => pmDue(o) }, + pm_uncompleted: { title: 'Assets with uncompleted PM', group: 'Maintenance', params: ['entity_id'], build: (o) => pmUncompleted(o) }, + pm_completed: { title: 'PM completed since date', group: 'Maintenance', params: ['since_date', 'entity_id'], build: (o) => pmCompleted(o) }, + pm_plan: { title: 'PM plan (printable)', group: 'Maintenance', params: ['pm_plan_id'], build: (o) => pmPlanPrint(o) }, + alerts_since: { title: 'Alerts since date', group: 'Maintenance', params: ['since_date'], build: (o) => alertsSince(o) }, + fixed_asset_card: { title: 'Fixed asset card', group: 'Detail', params: ['asset_id', 'year'], build: (o) => fixedAssetCard(o) }, + custom: { title: 'Report builder (custom)', group: 'Detail', params: ['columns', 'entity_id', 'category', 'status'], build: (o) => customReport(o) } +}; + +function paramSpecs() { + const entities = all('SELECT id, name FROM entities ORDER BY name').map((row) => ({ title: row.name, value: row.id })); + const assets = all('SELECT id, asset_id, description FROM assets ORDER BY asset_id LIMIT 1000') + .map((row) => ({ title: `${row.asset_id} · ${row.description}`, value: row.id })); + const categories = all('SELECT DISTINCT category FROM assets ORDER BY category').map((row) => row.category).filter(Boolean); + const statuses = all('SELECT DISTINCT status FROM assets ORDER BY status').map((row) => row.status).filter(Boolean); + const year = currentYear(); + const pmPlanOptions = all('SELECT id, name FROM pm_plans ORDER BY name').map((row) => ({ title: row.name, value: row.id })); + const today = new Date(); + const bookCodes = (() => { + try { + const rows = all("SELECT code FROM books WHERE book_type != 'maintenance' ORDER BY sort_order, id"); + if (rows.length) return rows.map((row) => row.code); + } catch { + // books table may be unavailable + } + return BOOKS; + })(); + return { + book: { label: 'Book', type: 'select', options: bookCodes, default: bookCodes[0] || 'GAAP' }, + year: { label: 'Year', type: 'number', default: year }, + month: { label: 'Through month', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), default: 12 }, + startYear: { label: 'Start year', type: 'number', default: year }, + endYear: { label: 'End year', type: 'number', default: year + 5 }, + entity_id: { label: 'Entity', type: 'select', options: entities, clearable: true, default: null }, + category: { label: 'Category', type: 'select', options: categories, clearable: true, default: null }, + status: { label: 'Status', type: 'select', options: statuses, clearable: true, default: null }, + asset_id: { label: 'Asset', type: 'select', options: assets, default: null }, + columns: { label: 'Columns', type: 'multiselect', options: BUILDER_FIELDS.map((f) => ({ title: f.label, value: f.key })), default: ['asset_id', 'description', 'category', 'status', 'acquisition_cost'] }, + before_date: { label: 'Due before', type: 'date', default: dateStr(new Date(today.getTime() + 30 * 86400000)) }, + since_date: { label: 'Since', type: 'date', default: dateStr(new Date(today.getTime() - 30 * 86400000)) }, + pm_plan_id: { label: 'PM plan', type: 'select', options: pmPlanOptions, default: pmPlanOptions[0]?.value || null } + }; +} + +function catalog() { + const reports = Object.entries(REPORTS).map(([key, def]) => ({ key, title: def.title, group: def.group, params: def.params })); + return { reports, params: paramSpecs() }; +} + +function runReport(type, options = {}) { + const def = REPORTS[type]; + if (!def) { + const error = new Error(`Unknown report type: ${type}`); + error.status = 400; + throw error; + } + const result = def.build(options); + return { type, title: def.title, group: def.group, generatedAt: now(), options, ...result }; +} + +module.exports = { BOOKS, catalog, computeYear, runReport }; diff --git a/server/services/reportExport.js b/server/services/reportExport.js new file mode 100644 index 0000000..f467d58 --- /dev/null +++ b/server/services/reportExport.js @@ -0,0 +1,123 @@ +const PDFDocument = require('pdfkit'); +const Papa = require('papaparse'); +const writeXlsxFile = require('write-excel-file/node'); + +function formatValue(value, format) { + if (value === null || value === undefined || value === '') return ''; + if (format === 'currency') return Number(value).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + if (format === 'number') return Number(value).toLocaleString('en-US'); + return String(value); +} + +function tableMatrix(report) { + const header = report.columns.map((column) => column.label); + const body = report.rows.map((row) => report.columns.map((column) => { + const value = row[column.key]; + return column.format === 'currency' || column.format === 'number' ? Number(value || 0) : (value ?? ''); + })); + return { header, body }; +} + +function totalsRow(report) { + if (!report.totals || !Object.keys(report.totals).length) return null; + return report.columns.map((column, index) => { + if (index === 0) return 'Totals'; + if (Object.prototype.hasOwnProperty.call(report.totals, column.key)) return report.totals[column.key]; + return ''; + }); +} + +function exportCsv(report) { + const { header, body } = tableMatrix(report); + const totals = totalsRow(report); + const rows = totals ? [...body, totals] : body; + return { + contentType: 'text/csv', + body: Papa.unparse([header, ...rows]) + }; +} + +async function exportXlsx(report) { + const { header } = tableMatrix(report); + const totals = totalsRow(report); + const data = [ + header.map((label) => ({ value: label, fontWeight: 'bold' })), + ...report.rows.map((row) => report.columns.map((column) => { + const value = row[column.key]; + if (column.format === 'currency' || column.format === 'number') { + return { type: Number, value: Number(value || 0), format: column.format === 'currency' ? '#,##0.00' : '#,##0' }; + } + return { value: value === null || value === undefined ? '' : String(value) }; + })) + ]; + if (totals) { + data.push(totals.map((value, index) => { + if (index === 0) return { value: 'Totals', fontWeight: 'bold' }; + return typeof value === 'number' + ? { type: Number, value, format: '#,##0.00', fontWeight: 'bold' } + : { value: String(value || ''), fontWeight: 'bold' }; + })); + } + return { + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + body: await writeXlsxFile(data).toBuffer() + }; +} + +function exportPdf(report) { + const doc = new PDFDocument({ margin: 36, size: 'LETTER', layout: 'landscape' }); + const chunks = []; + doc.on('data', (chunk) => chunks.push(chunk)); + + doc.fontSize(16).text(report.title || 'MixedAssets report'); + doc.fontSize(9).fillColor('#555').text( + `Generated ${new Date(report.generatedAt || Date.now()).toLocaleString()}` + + (report.options ? ` · ${Object.entries(report.options).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}: ${v}`).join(' · ')}` : '') + ); + if (report.meta && report.meta.note) doc.moveDown(0.3).fontSize(8).fillColor('#777').text(report.meta.note); + doc.fillColor('#000').moveDown(0.6); + + const pageWidth = doc.page.width - 72; + const columns = report.columns; + const colWidth = pageWidth / columns.length; + const drawRow = (cells, options = {}) => { + const y = doc.y; + doc.fontSize(options.bold ? 8.5 : 8); + cells.forEach((cell, index) => { + const column = columns[index]; + const align = column.format === 'currency' || column.format === 'number' ? 'right' : 'left'; + doc.text(String(cell ?? ''), 36 + index * colWidth, y, { width: colWidth - 6, align, ellipsis: true }); + }); + doc.moveDown(0.2); + if (doc.y > doc.page.height - 48) doc.addPage(); + }; + + drawRow(columns.map((column) => column.label), { bold: true }); + doc.moveTo(36, doc.y).lineTo(36 + pageWidth, doc.y).strokeColor('#ccc').stroke(); + doc.moveDown(0.2); + + for (const row of report.rows) { + drawRow(columns.map((column) => formatValue(row[column.key], column.format))); + } + + const totals = totalsRow(report); + if (totals) { + doc.moveTo(36, doc.y).lineTo(36 + pageWidth, doc.y).strokeColor('#ccc').stroke(); + doc.moveDown(0.2); + drawRow(totals.map((value, index) => (typeof value === 'number' ? formatValue(value, columns[index].format || 'currency') : value)), { bold: true }); + } + + doc.end(); + return new Promise((resolve) => { + doc.on('end', () => resolve({ contentType: 'application/pdf', body: Buffer.concat(chunks) })); + }); +} + +async function exportReport(report, format) { + if (format === 'csv') return exportCsv(report); + if (format === 'xlsx') return exportXlsx(report); + if (format === 'pdf') return exportPdf(report); + return { contentType: 'application/json', body: JSON.stringify(report, null, 2) }; +} + +module.exports = { exportReport }; diff --git a/server/services/reports.js b/server/services/reports.js index 593b96e..c0e8dab 100644 --- a/server/services/reports.js +++ b/server/services/reports.js @@ -1,15 +1,11 @@ const PDFDocument = require('pdfkit'); -const { all, one, parseJson } = require('../db'); +const { all, one } = require('../db'); const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation'); const { assetExportRows, assetFromRow } = require('./assets'); - -function activeRuleSet() { - const row = one('SELECT * FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1'); - return row ? parseJson(row.rules_json, {}) : {}; -} +const { ruleSetForBook } = require('./ruleSets'); function depreciationReport(year, bookType) { - const rules = activeRuleSet(); + const rules = ruleSetForBook(bookType); const rows = all(` SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent, b.business_use_percent AS book_business_use_percent, @@ -59,7 +55,7 @@ function depreciationReport(year, bookType) { } function monthlyDepreciationReport(year, bookType) { - const rules = activeRuleSet(); + const rules = ruleSetForBook(bookType); const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow); const rows = assets.flatMap((asset) => { const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]); @@ -100,7 +96,6 @@ function writeDepreciationPdf(res, year, book) { } module.exports = { - activeRuleSet, depreciationReport, monthlyDepreciationReport, writeDepreciationPdf diff --git a/server/services/roles.js b/server/services/roles.js new file mode 100644 index 0000000..40378f1 --- /dev/null +++ b/server/services/roles.js @@ -0,0 +1,102 @@ +const { all, audit, json, now, one, parseJson, run } = require('../db'); +const { CAPABILITY_KEYS, WILDCARD, capabilitySetIncludes, sanitizeCapabilities } = require('./accessControl'); + +function slugify(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 40); +} + +function fromRow(row) { + if (!row) return null; + return { + key: row.key, + name: row.name, + description: row.description || '', + capabilities: parseJson(row.capabilities_json, []), + is_system: Boolean(row.is_system), + locked: Boolean(row.locked), + user_count: one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [row.key]).c + }; +} + +function listRoles() { + return all('SELECT * FROM roles ORDER BY sort_order, name').map(fromRow); +} + +function getRole(key) { + return fromRow(one('SELECT * FROM roles WHERE key = ?', [key])); +} + +function roleExists(key) { + return Boolean(one('SELECT key FROM roles WHERE key = ?', [key])); +} + +// Capabilities granted to a role key (empty when the role is unknown). +function capabilitiesForRole(key) { + const row = one('SELECT capabilities_json FROM roles WHERE key = ?', [key]); + return row ? parseJson(row.capabilities_json, []) : []; +} + +function roleHasCapability(key, capability) { + return capabilitySetIncludes(capabilitiesForRole(key), capability); +} + +function createRole(body, userId) { + const name = String(body.name || '').trim(); + if (!name) throw Object.assign(new Error('A role name is required'), { status: 400 }); + const key = slugify(body.key || name); + if (!key) throw Object.assign(new Error('A valid role key is required'), { status: 400 }); + if (roleExists(key)) throw Object.assign(new Error('A role with that key already exists'), { status: 400 }); + const capabilities = sanitizeCapabilities(body.capabilities); + const ts = now(); + const sort = (one('SELECT MAX(sort_order) AS m FROM roles').m || 0) + 1; + run( + 'INSERT INTO roles (key, name, description, capabilities_json, is_system, locked, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?)', + [key, name, body.description || null, json(capabilities), sort, ts, ts] + ); + audit(userId, 'create', 'role', key, null, { name, capabilities }); + return getRole(key); +} + +function updateRole(key, body, userId) { + const before = one('SELECT * FROM roles WHERE key = ?', [key]); + if (!before) return null; + const name = body.name !== undefined ? String(body.name).trim() || before.name : before.name; + // Locked roles (admin) always keep the wildcard capability set. + const capabilities = before.locked + ? parseJson(before.capabilities_json, [WILDCARD]) + : (body.capabilities !== undefined ? sanitizeCapabilities(body.capabilities) : parseJson(before.capabilities_json, [])); + run( + 'UPDATE roles SET name = ?, description = ?, capabilities_json = ?, updated_at = ? WHERE key = ?', + [name, body.description ?? before.description, json(capabilities), now(), key] + ); + audit(userId, 'update', 'role', key, fromRow(before), { name, capabilities }); + return getRole(key); +} + +function deleteRole(key, userId) { + const before = one('SELECT * FROM roles WHERE key = ?', [key]); + if (!before) return { ok: false, status: 404, error: 'Role not found' }; + if (before.is_system) return { ok: false, status: 400, error: 'Built-in roles cannot be deleted' }; + const inUse = one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [key]).c; + if (inUse) return { ok: false, status: 400, error: `Reassign the ${inUse} user(s) with this role before deleting it` }; + run('DELETE FROM roles WHERE key = ?', [key]); + audit(userId, 'delete', 'role', key, fromRow(before), null); + return { ok: true }; +} + +module.exports = { + CAPABILITY_KEYS, + capabilitiesForRole, + createRole, + deleteRole, + getRole, + listRoles, + roleExists, + roleHasCapability, + updateRole +}; diff --git a/server/services/ruleSets.js b/server/services/ruleSets.js new file mode 100644 index 0000000..5658b3f --- /dev/null +++ b/server/services/ruleSets.js @@ -0,0 +1,25 @@ +const { one, parseJson } = require('../db'); + +function activeRuleSet() { + const row = one('SELECT rules_json FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1'); + return row ? parseJson(row.rules_json, {}) : {}; +} + +// Resolve the depreciation rule set a book should use: its explicitly assigned +// rule set when present, otherwise the globally active set. +function ruleSetForBook(bookCode) { + if (bookCode) { + try { + const book = one('SELECT tax_rule_set_id FROM books WHERE code = ?', [bookCode]); + if (book && book.tax_rule_set_id) { + const row = one('SELECT rules_json FROM tax_rule_sets WHERE id = ?', [book.tax_rule_set_id]); + if (row) return parseJson(row.rules_json, {}); + } + } catch { + // books table may not exist on a very old database; fall through to active set + } + } + return activeRuleSet(); +} + +module.exports = { activeRuleSet, ruleSetForBook }; diff --git a/server/services/servicenow.js b/server/services/servicenow.js new file mode 100644 index 0000000..c5a777e --- /dev/null +++ b/server/services/servicenow.js @@ -0,0 +1,337 @@ +const { all, audit, now, one, run } = require('../db'); +const { createAsset, updateAsset } = require('./assets'); + +const TIMEOUT_MS = 20000; + +// Default CMDB CI field -> MixedAssets asset field mapping. Values are ServiceNow +// column names on the configured CI class; requested with display values so that +// reference fields (manufacturer, location, model) arrive as readable strings. +const DEFAULT_CMDB_MAP = { + asset_id: 'asset_tag', + description: 'name', + serial_number: 'serial_number', + category: 'sys_class_name', + vendor: 'manufacturer', + location: 'location', + department: 'department', + custodian: 'assigned_to', + acquisition_cost: 'cost', + acquired_date: 'purchase_date', + notes: 'short_description' +}; + +function readSettings() { + const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'servicenow_%'"); + return Object.fromEntries(rows.map((row) => [row.key, row.value])); +} + +function parseMap(value) { + if (!value) return { ...DEFAULT_CMDB_MAP }; + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + return parsed && typeof parsed === 'object' && Object.keys(parsed).length ? parsed : { ...DEFAULT_CMDB_MAP }; + } catch { + return { ...DEFAULT_CMDB_MAP }; + } +} + +// Raw config (includes password) — for making API calls only. +function getConfig() { + const s = readSettings(); + return { + instanceUrl: (s.servicenow_instance_url || '').replace(/\/+$/, ''), + username: s.servicenow_username || '', + password: s.servicenow_password || '', + incidentTable: s.servicenow_incident_table || 'incident', + assignmentGroup: s.servicenow_assignment_group || '', + callerId: s.servicenow_caller_id || '', + ticketEnabled: s.servicenow_ticket_enabled === 'true', + autoTicket: s.servicenow_auto_ticket === 'true', + cmdbTable: s.servicenow_cmdb_table || 'cmdb_ci_hardware', + cmdbQuery: s.servicenow_cmdb_query || '', + cmdbLimit: Number(s.servicenow_cmdb_limit || 200), + cmdbMap: parseMap(s.servicenow_cmdb_map), + lastSyncAt: s.servicenow_last_sync_at || '', + lastSyncStatus: s.servicenow_last_sync_status || '' + }; +} + +// Safe config for the API/UI — password is never returned, only whether it is set. +function publicConfig() { + const c = getConfig(); + return { + servicenow_instance_url: c.instanceUrl, + servicenow_username: c.username, + servicenow_password_set: Boolean(c.password), + servicenow_incident_table: c.incidentTable, + servicenow_assignment_group: c.assignmentGroup, + servicenow_caller_id: c.callerId, + servicenow_ticket_enabled: c.ticketEnabled, + servicenow_auto_ticket: c.autoTicket, + servicenow_cmdb_table: c.cmdbTable, + servicenow_cmdb_query: c.cmdbQuery, + servicenow_cmdb_limit: c.cmdbLimit, + servicenow_cmdb_map: c.cmdbMap, + servicenow_last_sync_at: c.lastSyncAt, + servicenow_last_sync_status: c.lastSyncStatus, + configured: Boolean(c.instanceUrl && c.username) + }; +} + +function setValue(key, value) { + run( + 'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', + [key, String(value == null ? '' : value), now()] + ); +} + +function saveConfig(body, userId) { + if (body.servicenow_instance_url !== undefined) setValue('servicenow_instance_url', (body.servicenow_instance_url || '').replace(/\/+$/, '')); + if (body.servicenow_username !== undefined) setValue('servicenow_username', body.servicenow_username || ''); + if (body.servicenow_password_clear) setValue('servicenow_password', ''); + else if (body.servicenow_password) setValue('servicenow_password', body.servicenow_password); + if (body.servicenow_incident_table !== undefined) setValue('servicenow_incident_table', body.servicenow_incident_table || 'incident'); + if (body.servicenow_assignment_group !== undefined) setValue('servicenow_assignment_group', body.servicenow_assignment_group || ''); + if (body.servicenow_caller_id !== undefined) setValue('servicenow_caller_id', body.servicenow_caller_id || ''); + if (body.servicenow_ticket_enabled !== undefined) setValue('servicenow_ticket_enabled', body.servicenow_ticket_enabled ? 'true' : 'false'); + if (body.servicenow_auto_ticket !== undefined) setValue('servicenow_auto_ticket', body.servicenow_auto_ticket ? 'true' : 'false'); + if (body.servicenow_cmdb_table !== undefined) setValue('servicenow_cmdb_table', body.servicenow_cmdb_table || 'cmdb_ci_hardware'); + if (body.servicenow_cmdb_query !== undefined) setValue('servicenow_cmdb_query', body.servicenow_cmdb_query || ''); + if (body.servicenow_cmdb_limit !== undefined) setValue('servicenow_cmdb_limit', Number(body.servicenow_cmdb_limit) || 200); + if (body.servicenow_cmdb_map !== undefined) { + const value = typeof body.servicenow_cmdb_map === 'string' ? body.servicenow_cmdb_map : JSON.stringify(body.servicenow_cmdb_map || {}); + setValue('servicenow_cmdb_map', value.trim() ? value : ''); + } + audit(userId, 'update', 'servicenow_settings', null, null, { ...body, servicenow_password: body.servicenow_password ? '[set]' : undefined }); + return publicConfig(); +} + +function ensureConfigured(c) { + if (!c.instanceUrl || !c.username) { + throw Object.assign(new Error('ServiceNow instance URL and username are required'), { status: 400 }); + } +} + +function authHeader(c) { + return `Basic ${Buffer.from(`${c.username}:${c.password}`).toString('base64')}`; +} + +async function snFetch(c, pathAndQuery, options = {}) { + const url = `${c.instanceUrl}${pathAndQuery}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + let response; + try { + response = await fetch(url, { + ...options, + headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authHeader(c), ...(options.headers || {}) }, + signal: controller.signal + }); + } catch (error) { + throw Object.assign(new Error(`ServiceNow request failed: ${error.message}`), { status: 502 }); + } finally { + clearTimeout(timer); + } + const text = await response.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { body = null; } + if (!response.ok) { + const detail = body?.error?.message || body?.error?.detail || `HTTP ${response.status}`; + throw Object.assign(new Error(`ServiceNow: ${detail}`), { status: response.status === 401 ? 400 : 502 }); + } + return body; +} + +async function testConnection(userId) { + const c = getConfig(); + ensureConfigured(c); + await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}?sysparm_limit=1&sysparm_fields=sys_id`, { method: 'GET' }); + audit(userId, 'test', 'servicenow', null, null, { instance: c.instanceUrl }); + return { ok: true, instance: c.instanceUrl }; +} + +// MixedAssets severity -> ServiceNow impact/urgency (1 high … 3 low). +function severityToImpactUrgency(severity) { + if (severity === 'critical') return { impact: '1', urgency: '1' }; + if (severity === 'warning') return { impact: '2', urgency: '2' }; + return { impact: '3', urgency: '3' }; +} + +function incidentUrl(c, sysId) { + return `${c.instanceUrl}/nav_to.do?uri=${encodeURIComponent(`/${c.incidentTable}.do?sys_id=${sysId}`)}`; +} + +async function createIncidentForAlert(alert, userId, c = getConfig()) { + ensureConfigured(c); + const { impact, urgency } = severityToImpactUrgency(alert.severity); + const descriptionLines = [ + alert.message || '', + '', + `MixedAssets alert #${alert.id} (${alert.type})`, + alert.asset_code ? `Asset: ${alert.asset_code}` : null, + alert.due_date ? `Due: ${alert.due_date}` : null + ].filter((line) => line !== null); + + const payload = { + short_description: alert.title, + description: descriptionLines.join('\n'), + impact, + urgency + }; + if (c.assignmentGroup) payload.assignment_group = c.assignmentGroup; + if (c.callerId) payload.caller_id = c.callerId; + + const data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}`, { method: 'POST', body: JSON.stringify(payload) }); + const result = data?.result || {}; + const url = result.sys_id ? incidentUrl(c, result.sys_id) : null; + run( + 'UPDATE alerts SET external_ticket_id = ?, external_ticket_number = ?, external_ticket_url = ?, external_ticket_system = ?, updated_at = ? WHERE id = ?', + [result.sys_id || null, result.number || null, url, 'servicenow', now(), alert.id] + ); + audit(userId, 'ticket', 'alert', alert.id, null, { system: 'servicenow', number: result.number }); + return { sys_id: result.sys_id, number: result.number, url }; +} + +function alertWithCode(alertId) { + return one( + `SELECT al.*, a.asset_id AS asset_code FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id WHERE al.id = ?`, + [alertId] + ); +} + +// Manually submit a ServiceNow incident for one alert (returns existing ticket if already raised). +async function submitTicket(alertId, userId) { + const c = getConfig(); + ensureConfigured(c); + const alert = alertWithCode(alertId); + if (!alert) return null; + if (alert.external_ticket_id) { + return { sys_id: alert.external_ticket_id, number: alert.external_ticket_number, url: alert.external_ticket_url, duplicate: true }; + } + return createIncidentForAlert(alert, userId, c); +} + +// Auto-create incidents for new critical alerts during the alert cycle (best-effort). +async function autoTicketAlerts(alerts, userId) { + const c = getConfig(); + if (!c.ticketEnabled || !c.autoTicket || !c.instanceUrl) return { created: 0 }; + let created = 0; + for (const alert of alerts) { + if (alert.severity !== 'critical' || alert.external_ticket_id) continue; + try { + await createIncidentForAlert(alert, userId, c); + created += 1; + } catch { + // best-effort: a failed ticket never breaks the alert cycle + } + } + return { created }; +} + +// ---- CMDB pull ------------------------------------------------------------- + +function parseCost(value) { + if (value == null || value === '') return undefined; + const num = Number(String(value).replace(/[^0-9.\-]/g, '')); + return Number.isFinite(num) ? num : undefined; +} + +function parseDate(value) { + if (!value) return undefined; + const match = String(value).match(/\d{4}-\d{2}-\d{2}/); + return match ? match[0] : undefined; +} + +function mapCi(ci, map) { + const asset = {}; + for (const [assetField, ciField] of Object.entries(map)) { + if (!ciField) continue; + let value = ci[ciField]; + if (value == null || value === '') continue; + value = String(value).trim(); + if (!value) continue; + if (assetField === 'acquisition_cost') { + const cost = parseCost(value); + if (cost !== undefined) asset.acquisition_cost = cost; + } else if (assetField === 'acquired_date' || assetField === 'in_service_date' || assetField === 'disposal_date') { + const date = parseDate(value); + if (date) asset[assetField] = date; + } else { + asset[assetField] = value; + } + } + return asset; +} + +function findExistingAsset(sysId, mapped) { + if (sysId) { + const bySysId = one('SELECT id FROM assets WHERE servicenow_sys_id = ?', [sysId]); + if (bySysId) return bySysId.id; + } + if (mapped.serial_number) { + const bySerial = one('SELECT id FROM assets WHERE serial_number = ? COLLATE NOCASE', [mapped.serial_number]); + if (bySerial) return bySerial.id; + } + if (mapped.asset_id) { + const byCode = one('SELECT id FROM assets WHERE asset_id = ? COLLATE NOCASE', [mapped.asset_id]); + if (byCode) return byCode.id; + } + return null; +} + +async function syncCmdb(userId) { + const c = getConfig(); + ensureConfigured(c); + const fields = [...new Set(['sys_id', ...Object.values(c.cmdbMap).filter(Boolean)])]; + const params = new URLSearchParams({ + sysparm_limit: String(c.cmdbLimit || 200), + sysparm_display_value: 'true', + sysparm_exclude_reference_link: 'true', + sysparm_fields: fields.join(',') + }); + if (c.cmdbQuery) params.set('sysparm_query', c.cmdbQuery); + + let data; + try { + data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.cmdbTable)}?${params.toString()}`, { method: 'GET' }); + } catch (error) { + setValue('servicenow_last_sync_at', now()); + setValue('servicenow_last_sync_status', `Failed: ${error.message}`); + throw error; + } + const cis = data?.result || []; + let created = 0; + let updated = 0; + let skipped = 0; + for (const ci of cis) { + const sysId = ci.sys_id || null; + const mapped = mapCi(ci, c.cmdbMap); + if (!mapped.description && !mapped.asset_id && !mapped.serial_number) { skipped += 1; continue; } + const existingId = findExistingAsset(sysId, mapped); + if (existingId) { + updateAsset(existingId, mapped, userId); + if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, existingId]); + updated += 1; + } else { + const asset = createAsset({ ...mapped, source: 'servicenow' }, userId); + if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, asset.id]); + created += 1; + } + } + const status = `Synced ${cis.length} CI(s): ${created} created, ${updated} updated${skipped ? `, ${skipped} skipped` : ''}.`; + setValue('servicenow_last_sync_at', now()); + setValue('servicenow_last_sync_status', status); + audit(userId, 'cmdb_sync', 'servicenow', null, null, { table: c.cmdbTable, created, updated, skipped }); + return { created, updated, skipped, total: cis.length, status }; +} + +module.exports = { + DEFAULT_CMDB_MAP, + autoTicketAlerts, + getConfig, + publicConfig, + saveConfig, + submitTicket, + syncCmdb, + testConnection +}; diff --git a/server/services/webhooks.js b/server/services/webhooks.js new file mode 100644 index 0000000..84f1a86 --- /dev/null +++ b/server/services/webhooks.js @@ -0,0 +1,88 @@ +const crypto = require('crypto'); +const { audit } = require('../db'); +const { getConfig } = require('./notifications'); + +const TIMEOUT_MS = 10000; + +// The JSON object delivered for each alert. Stable, flat shape for easy consumption. +function buildPayload(alert) { + return { + event: 'alert', + id: alert.id, + type: alert.type, + severity: alert.severity, + title: alert.title, + message: alert.message || null, + due_date: alert.due_date || null, + status: alert.status, + reference_type: alert.reference_type, + reference_id: alert.reference_id, + asset_id: alert.asset_id || null, + asset_code: alert.asset_code || null, + created_at: alert.created_at, + sent_at: new Date().toISOString() + }; +} + +// POST a single JSON object. When a secret is set, sign the raw body with HMAC-SHA256 +// so the receiver can verify authenticity via the X-MixedAssets-Signature header. +async function postJson(url, secret, payload) { + const body = JSON.stringify(payload); + const headers = { 'Content-Type': 'application/json', 'User-Agent': 'MixedAssets-Webhook/1' }; + if (secret) { + const signature = crypto.createHmac('sha256', secret).update(body).digest('hex'); + headers['X-MixedAssets-Signature'] = `sha256=${signature}`; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal }); + return { ok: response.ok, status: response.status }; + } finally { + clearTimeout(timer); + } +} + +// Deliver each alert as its own JSON POST. Best-effort: a failed delivery is counted +// but never throws, so one bad endpoint can't break the alert cycle. +async function deliverAlerts(alerts) { + const c = getConfig(); + if (!c.webhookEnabled || !c.webhookUrl || !alerts.length) { + return { attempted: false, delivered: 0, failed: 0 }; + } + let delivered = 0; + let failed = 0; + for (const alert of alerts) { + try { + const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert)); + if (result.ok) delivered += 1; + else failed += 1; + } catch { + failed += 1; + } + } + return { attempted: true, delivered, failed }; +} + +async function sendTestWebhook(userId) { + const c = getConfig(); + if (!c.webhookUrl) throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 }); + const payload = { + event: 'test', + severity: 'info', + title: 'MixedAssets webhook test', + message: 'If you received this, your webhook destination is configured correctly.', + sent_at: new Date().toISOString() + }; + let result; + try { + result = await postJson(c.webhookUrl, c.webhookSecret, payload); + } catch (error) { + throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 }); + } + audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status }); + if (!result.ok) throw Object.assign(new Error(`Webhook endpoint returned HTTP ${result.status}`), { status: 400 }); + return { ok: true, status: result.status }; +} + +module.exports = { buildPayload, deliverAlerts, sendTestWebhook }; diff --git a/server/services/workday.js b/server/services/workday.js index adc4f57..5d3cc09 100644 --- a/server/services/workday.js +++ b/server/services/workday.js @@ -1,5 +1,6 @@ const { audit, json, now, one, run } = require('../db'); const { upsertEmployee } = require('./assignments'); +const { upsertWorkdayContact } = require('./contacts'); function publicConnection(row) { if (!row) return null; @@ -12,12 +13,21 @@ function publicConnection(row) { client_id: row.client_id, workers_path: row.workers_path, enabled: Boolean(row.enabled), + sync_enabled: Boolean(row.sync_enabled), + sync_interval_hours: row.sync_interval_hours || 24, last_sync_at: row.last_sync_at, last_sync_status: row.last_sync_status, configured: Boolean(row.base_url && row.token_url && row.client_id && row.client_secret) }; } +function splitName(full) { + const parts = String(full || '').trim().split(/\s+/).filter(Boolean); + if (!parts.length) return { first: null, last: null }; + if (parts.length === 1) return { first: parts[0], last: null }; + return { first: parts[0], last: parts.slice(1).join(' ') }; +} + function getConnection() { return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1')); } @@ -37,23 +47,25 @@ function saveConnection(body, userId) { client_id: body.client_id ?? existing?.client_id ?? '', client_secret: body.client_secret ? body.client_secret : existing?.client_secret ?? '', workers_path: body.workers_path ?? existing?.workers_path ?? '/workers', - enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0) + enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0), + sync_enabled: body.sync_enabled === undefined ? Number(existing?.sync_enabled || 0) : (body.sync_enabled ? 1 : 0), + sync_interval_hours: body.sync_interval_hours === undefined ? (existing?.sync_interval_hours || 24) : Math.max(1, Number(body.sync_interval_hours) || 24) }; if (existing) { run( `UPDATE workday_connections SET name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?, - workers_path = ?, enabled = ?, updated_at = ? + workers_path = ?, enabled = ?, sync_enabled = ?, sync_interval_hours = ?, updated_at = ? WHERE id = ?`, - [payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, timestamp, existing.id] + [payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, existing.id] ); } else { run( `INSERT INTO workday_connections ( - name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, timestamp, timestamp] + name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, sync_enabled, sync_interval_hours, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, timestamp] ); } @@ -85,19 +97,31 @@ function normalizeWorkers(payload) { ? payload : payload.workers || payload.Workers || payload.data || payload.value || []; - return rows.map((worker) => ({ - workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id, - employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber, - name: worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' '), - email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email, - department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor, - location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor, - manager_name: worker.managerName || worker.manager?.descriptor, - status: worker.status || (worker.active === false ? 'inactive' : 'active'), - source: 'workday', - source_payload: worker, - last_synced_at: now() - })); + return rows.map((worker) => { + const name = worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' '); + const fallbackName = splitName(name); + const managerName = worker.managerName || worker.manager?.descriptor; + const managerSplit = splitName(managerName); + return { + workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id, + employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber, + name, + first_name: worker.firstName || worker.legalFirstName || worker.First_Name || fallbackName.first, + last_name: worker.lastName || worker.legalLastName || worker.Last_Name || fallbackName.last, + email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email, + department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor, + location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor, + manager_name: managerName, + manager_first_name: worker.manager?.firstName || managerSplit.first, + manager_last_name: worker.manager?.lastName || managerSplit.last, + manager_email: worker.managerEmail || worker.manager?.email, + start_date: worker.hireDate || worker.startDate || worker.Hire_Date || worker.continuousServiceDate, + status: worker.status || (worker.active === false ? 'inactive' : 'active'), + source: 'workday', + source_payload: worker, + last_synced_at: now() + }; + }); } async function syncWorkers(userId) { @@ -120,7 +144,10 @@ async function syncWorkers(userId) { } const workers = normalizeWorkers(await response.json()); - for (const worker of workers) upsertEmployee(worker); + for (const worker of workers) { + upsertEmployee(worker); + upsertWorkdayContact(worker); + } const timestamp = now(); run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [ @@ -135,14 +162,30 @@ async function syncWorkers(userId) { function importWorkersFromPayload(workers, userId) { const normalized = normalizeWorkers(workers); - for (const worker of normalized) upsertEmployee(worker); + for (const worker of normalized) { + upsertEmployee(worker); + upsertWorkdayContact(worker); + } audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' }); return { imported: normalized.length }; } +// Run an automatic sync if it's enabled, configured, and the interval has elapsed. +async function runScheduledSync() { + const connection = getPrivateConnection(); + if (!connection || !connection.sync_enabled || !connection.enabled) return { skipped: true }; + if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) return { skipped: true }; + const intervalMs = (Number(connection.sync_interval_hours) || 24) * 3600000; + if (connection.last_sync_at && Date.now() - new Date(connection.last_sync_at).getTime() < intervalMs) { + return { skipped: true }; + } + return syncWorkers(null); +} + module.exports = { getConnection, importWorkersFromPayload, + runScheduledSync, saveConnection, syncWorkers }; diff --git a/server/services/zones.js b/server/services/zones.js new file mode 100644 index 0000000..ada1d9a --- /dev/null +++ b/server/services/zones.js @@ -0,0 +1,114 @@ +const { all, audit, now, one, run } = require('../db'); + +let cache = null; + +function clearCache() { + cache = null; +} + +function lookup() { + if (!cache) { + cache = {}; + for (const row of all('SELECT * FROM depreciation_zones')) cache[row.code] = row; + } + return cache; +} + +// Raw row (incl. dates) for the engine; null when unknown or disabled. +function getZone(code) { + if (!code) return null; + const zone = lookup()[code]; + return zone && zone.enabled ? zone : null; +} + +function slugify(value) { + return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40); +} + +function fromRow(row) { + if (!row) return null; + return { + id: row.id, + code: row.code, + name: row.name, + allowance_percent: row.allowance_percent, + pis_start: row.pis_start, + pis_end: row.pis_end, + real_property_end: row.real_property_end, + max_recovery_years: row.max_recovery_years, + leasehold_life_years: row.leasehold_life_years, + notes: row.notes, + enabled: Boolean(row.enabled), + asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c + }; +} + +function list() { + return all('SELECT * FROM depreciation_zones ORDER BY name').map(fromRow); +} + +function normalize(body) { + const num = (v) => (v === '' || v === null || v === undefined ? null : Number(v)); + return { + name: String(body.name || '').trim(), + allowance_percent: Number(body.allowance_percent) || 0, + pis_start: body.pis_start || null, + pis_end: body.pis_end || null, + real_property_end: body.real_property_end || null, + max_recovery_years: num(body.max_recovery_years), + leasehold_life_years: num(body.leasehold_life_years), + notes: body.notes || null, + enabled: body.enabled === false ? 0 : 1 + }; +} + +function createZone(body, userId) { + const input = normalize(body); + if (!input.name) throw Object.assign(new Error('A zone name is required'), { status: 400 }); + const code = slugify(body.code || input.name); + if (!code) throw Object.assign(new Error('A valid zone code is required'), { status: 400 }); + if (one('SELECT id FROM depreciation_zones WHERE code = ?', [code])) { + throw Object.assign(new Error('A zone with that code already exists'), { status: 400 }); + } + const ts = now(); + run( + `INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, notes, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.notes, input.enabled, ts, ts] + ); + clearCache(); + audit(userId, 'create', 'depreciation_zone', code, null, input); + return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code])); +} + +function updateZone(code, body, userId) { + const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]); + if (!before) return null; + const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled }); + run( + `UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?, + max_recovery_years = ?, leasehold_life_years = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`, + [input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.notes, input.enabled, now(), code] + ); + clearCache(); + audit(userId, 'update', 'depreciation_zone', code, before, input); + return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code])); +} + +function deleteZone(code, userId) { + const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]); + if (!before) return false; + run('DELETE FROM depreciation_zones WHERE code = ?', [code]); + clearCache(); + audit(userId, 'delete', 'depreciation_zone', code, before, null); + return true; +} + +module.exports = { + clearCache, + createZone, + deleteZone, + getZone, + list, + updateZone +}; diff --git a/server/smoke-test.js b/server/smoke-test.js index 916e969..9210e7c 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -1,10 +1,18 @@ const { spawn } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); const port = 3999; const baseUrl = `http://127.0.0.1:${port}`; +// Run against an isolated, freshly-seeded database so the test is deterministic +// and never reads or mutates the developer's working data. +const dbPath = path.join(os.tmpdir(), `mixedassets-smoke-${Date.now()}.sqlite`); const child = spawn(process.execPath, ['server/index.js'], { cwd: process.cwd(), - env: { ...process.env, PORT: String(port) }, + env: { ...process.env, PORT: String(port), MIXEDASSETS_DB_PATH: dbPath }, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -39,6 +47,8 @@ async function waitForServer() { throw new Error('Server did not start'); } +const pngDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + async function main() { await waitForServer(); const login = await request('/api/auth/login', { @@ -50,10 +60,16 @@ async function main() { }); const auth = { Authorization: `Bearer ${login.token}` }; + if (!Array.isArray(login.capabilities) || !login.capabilities.includes('*')) { + throw new Error('Login did not return admin capabilities'); + } const profile = await request('/api/profile', { headers: auth }); if (profile.preferences.theme !== 'mixedAssetsDark') { throw new Error(`Expected default dark theme, found ${profile.preferences.theme}`); } + if (!Array.isArray(profile.capabilities) || !profile.capabilities.includes('*')) { + throw new Error('Profile did not return capabilities'); + } const updatedProfile = await request('/api/profile', { method: 'PUT', headers: auth, @@ -66,15 +82,64 @@ async function main() { 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}`); + if (methodCount !== 74) { + throw new Error(`Expected 74 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'); + if (!accessControl.roles.some((r) => r.key === 'admin') || !accessControl.capabilities.some((c) => c.key === 'admin.users')) { + throw new Error('Access control did not return roles + capability catalog'); } + // Built-in PM roles exist with the expected split. + const pmAdmin = accessControl.roles.find((r) => r.key === 'pm_admin'); + const pmUser = accessControl.roles.find((r) => r.key === 'pm_user'); + if (!pmAdmin || !pmAdmin.capabilities.includes('pm.manage')) throw new Error('PM Admin role missing or lacks pm.manage'); + if (!pmUser || pmUser.capabilities.includes('pm.manage') || !pmUser.capabilities.includes('pm.complete')) { + throw new Error('PM User role should complete PM but not manage plans'); + } + + // Custom role: create, assign to a user, enforce its capabilities, then guard deletion. + const customRole = await request('/api/roles', { + method: 'POST', headers: auth, + body: JSON.stringify({ name: `Inspector ${suffix}`, description: 'Read + PM completion', capabilities: ['assets.view', 'pm.view', 'pm.complete'] }) + }); + const customRoleKey = customRole.role.key; + if (!customRoleKey || customRole.role.capabilities.includes('assets.delete')) throw new Error('Custom role was not created with sanitized capabilities'); + const roleUser = await request('/api/users', { + method: 'POST', headers: auth, + body: JSON.stringify({ name: `Inspector User ${suffix}`, email: `inspector-${suffix}@example.com`, password: 'ChangeMe123!', role: customRoleKey, status: 'active' }) + }); + if (roleUser.user.role !== customRoleKey) throw new Error('User was not assigned the custom role'); + // Sign in as the custom-role user and verify enforcement. + const roleLogin = await request('/api/auth/login', { + method: 'POST', body: JSON.stringify({ email: `inspector-${suffix}@example.com`, password: 'ChangeMe123!' }) + }); + if (roleLogin.capabilities.includes('*') || !roleLogin.capabilities.includes('pm.complete')) throw new Error('Custom-role login capabilities incorrect'); + const roleAuth = { Authorization: `Bearer ${roleLogin.token}` }; + // Allowed: viewing assets. Denied: creating an asset (needs assets.edit) and managing settings. + await request('/api/assets', { headers: roleAuth }); + const deniedCreate = await fetch(`${baseUrl}/api/assets`, { + method: 'POST', headers: { ...roleAuth, 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'nope', category: 'Computer Equipment' }) + }); + if (deniedCreate.status !== 403) throw new Error('Custom role without assets.edit should be denied asset creation'); + const deniedSettings = await fetch(`${baseUrl}/api/settings`, { headers: roleAuth }); + if (deniedSettings.status !== 403) throw new Error('Custom role without admin.settings should be denied settings'); + // Editing the role grants a new capability that immediately takes effect on next login. + await request(`/api/roles/${customRoleKey}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ capabilities: ['assets.view', 'assets.edit', 'pm.view', 'pm.complete'] }) + }); + const reLogin = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: `inspector-${suffix}@example.com`, password: 'ChangeMe123!' }) }); + const reAuth = { Authorization: `Bearer ${reLogin.token}` }; + const nowAllowed = await fetch(`${baseUrl}/api/assets`, { + method: 'POST', headers: { ...reAuth, 'Content-Type': 'application/json' }, body: JSON.stringify({ description: `role asset ${suffix}`, category: 'Computer Equipment' }) + }); + if (nowAllowed.status !== 201) throw new Error('Role capability change did not take effect'); + // Deleting a role in use is blocked; built-in roles cannot be deleted. + const blockedRoleDelete = await fetch(`${baseUrl}/api/roles/${customRoleKey}`, { method: 'DELETE', headers: auth }); + if (blockedRoleDelete.status !== 400) throw new Error('Deleting a role in use should be blocked'); + const blockedSystemDelete = await fetch(`${baseUrl}/api/roles/admin`, { method: 'DELETE', headers: auth }); + if (blockedSystemDelete.status !== 400) throw new Error('Built-in roles must not be deletable'); const teamResult = await request('/api/teams', { method: 'POST', @@ -84,6 +149,17 @@ async function main() { description: 'Smoke test RBAC team' }) }); + const renamedTeam = await request(`/api/teams/${teamResult.team.id}`, { + method: 'PUT', + headers: auth, + body: JSON.stringify({ name: `Smoke Team ${suffix} (edited)`, description: 'edited' }) + }); + if (renamedTeam.team.name !== `Smoke Team ${suffix} (edited)`) throw new Error('Team update did not persist'); + + const tmpTeam = await request('/api/teams', { method: 'POST', headers: auth, body: JSON.stringify({ name: `Tmp Team ${suffix}` }) }); + const okDelete = await fetch(`${baseUrl}/api/teams/${tmpTeam.team.id}`, { method: 'DELETE', headers: auth }); + if (okDelete.status !== 204) throw new Error('Deleting an empty team failed'); + const userResult = await request('/api/users', { method: 'POST', headers: auth, @@ -111,6 +187,9 @@ async function main() { throw new Error('User role or team update did not persist'); } + const blockedDelete = await fetch(`${baseUrl}/api/teams/${teamResult.team.id}`, { method: 'DELETE', headers: auth }); + if (blockedDelete.status !== 400) throw new Error('Deleting a team with members should be blocked'); + await request('/api/templates', { method: 'POST', headers: auth, @@ -129,6 +208,147 @@ async function main() { if (!smokeTemplate || smokeTemplate.custom_fields.length !== 2) { throw new Error('Template custom fields were not saved or returned correctly'); } + if (smokeTemplate.asset_count !== 0) throw new Error('New template should report zero assigned assets'); + + // Update the template. + const updatedTemplate = await request(`/api/templates/${smokeTemplate.id}`, { + method: 'PUT', + headers: auth, + body: JSON.stringify({ + name: `Smoke template ${suffix} (edited)`, + description: 'edited', + defaults: smokeTemplate.defaults, + custom_fields: [{ key: 'employee_id', label: 'Employee ID', type: 'text', required: true }] + }) + }); + if (updatedTemplate.template.name !== `Smoke template ${suffix} (edited)`) throw new Error('Template update did not persist'); + + // Delete a disposable template and confirm it reports unlinked-asset impact. + const disposable = await request('/api/templates', { + method: 'POST', headers: auth, + body: JSON.stringify({ name: `Disposable template ${suffix}`, defaults: {}, custom_fields: [] }) + }); + const tmplDelete = await request(`/api/templates/${disposable.template.id}`, { method: 'DELETE', headers: auth }); + if (!tmplDelete.deleted || typeof tmplDelete.unlinked_assets !== 'number') throw new Error('Template delete did not report unlink impact'); + const afterDelete = await request('/api/templates', { headers: auth }); + if (afterDelete.templates.some((t) => t.id === disposable.template.id)) throw new Error('Deleted template still present'); + + // Asset categories: create, list, rename (cascades to assets), delete. + const newCategory = await request('/api/asset-categories', { + method: 'POST', headers: auth, body: JSON.stringify({ name: `Smoke Category ${suffix}`, code: 'SMK' }) + }); + if (!newCategory.category?.id || newCategory.category.asset_count !== 0) throw new Error('Asset category was not created'); + const dupCategory = await fetch(`${baseUrl}/api/asset-categories`, { + method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `Smoke Category ${suffix}` }) + }); + if (dupCategory.status !== 400) throw new Error('Duplicate category should be rejected'); + // An asset using the category, to prove rename cascades. + const catAsset = await request('/api/assets', { + method: 'POST', headers: auth, + body: JSON.stringify({ asset_id: `CAT-${suffix}`, description: 'Category test', category: `Smoke Category ${suffix}`, acquisition_cost: 100 }) + }); + const renamedCategory = await request(`/api/asset-categories/${newCategory.category.id}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ name: `Smoke Category ${suffix} (renamed)` }) + }); + if (renamedCategory.renamed_assets < 1) throw new Error('Category rename did not cascade to assets'); + const renamedAsset = await request(`/api/assets/${catAsset.asset.id}`, { headers: auth }); + if (renamedAsset.asset.category !== `Smoke Category ${suffix} (renamed)`) throw new Error('Asset category was not updated by rename'); + const categoryList = await request('/api/asset-categories', { headers: auth }); + const listedCategory = categoryList.categories.find((c) => c.id === newCategory.category.id); + if (!listedCategory || listedCategory.asset_count !== 1) throw new Error('Category usage count was not reported'); + const catDelete = await request(`/api/asset-categories/${newCategory.category.id}`, { method: 'DELETE', headers: auth }); + if (!catDelete.deleted || catDelete.asset_count !== 1) throw new Error('Category delete did not report usage impact'); + + // IRS asset-class reference catalog is available for pre-populating class numbers. + const irs = await request('/api/asset-classes', { headers: auth }); + if (!Array.isArray(irs.assetClasses) || irs.assetClasses.length < 280) throw new Error('IRS asset-class catalog was not returned'); + const computers = irs.assetClasses.find((c) => c.description === 'Computers' && c.table === 'common'); + if (!computers || computers.class_number !== '00.12' || computers.gds !== 5) throw new Error('IRS common catalog entry (Computers) was incorrect'); + const semiconductor = irs.assetClasses.find((c) => c.description === 'Semiconductor Manufacturing Equipment' && c.table === 'industry'); + if (!semiconductor || semiconductor.class_number !== '36.1') throw new Error('IRS manufacturing-industry catalog entry was missing'); + const construction = irs.assetClasses.find((c) => c.description === 'Construction' && c.table === 'business'); + if (!construction || construction.class_number !== '15.0') throw new Error('IRS business-activity catalog entry was missing'); + if (!irs.assetClasses[0].id) throw new Error('Asset classes should be managed records with ids'); + + // Asset classes are now managed: create, edit, delete. + const newClass = await request('/api/asset-classes', { + method: 'POST', headers: auth, + body: JSON.stringify({ description: `Smoke Class ${suffix}`, class_number: `99.${suffix.slice(-2)}`, gds: 7, ads: 12, source: 'custom' }) + }); + if (!newClass.assetClass?.id || newClass.assetClass.source !== 'custom') throw new Error('Asset class was not created'); + const editedClass = await request(`/api/asset-classes/${newClass.assetClass.id}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ gds: 5 }) + }); + if (editedClass.assetClass.gds !== 5) throw new Error('Asset class update did not persist'); + const classDelete = await fetch(`${baseUrl}/api/asset-classes/${newClass.assetClass.id}`, { method: 'DELETE', headers: auth }); + if (classDelete.status !== 204) throw new Error('Asset class delete failed'); + + // Asset class number: shared by every asset in the category, and cascades on change. + const classCat = await request('/api/asset-categories', { + method: 'POST', headers: auth, body: JSON.stringify({ name: `Class Cat ${suffix}`, asset_class_number: 'AC-100' }) + }); + if (classCat.category.asset_class_number !== 'AC-100') throw new Error('Category asset class number was not stored'); + const classAsset = await request('/api/assets', { + method: 'POST', headers: auth, body: JSON.stringify({ asset_id: `CLS-${suffix}`, description: 'Class test', category: `Class Cat ${suffix}`, acquisition_cost: 50 }) + }); + if (classAsset.asset.asset_class_number !== 'AC-100') throw new Error('Asset did not inherit the category class number'); + // Changing the category's class number cascades to its assets. + await request(`/api/asset-categories/${classCat.category.id}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ asset_class_number: 'AC-200' }) + }); + const reclassed = await request(`/api/assets/${classAsset.asset.id}`, { headers: auth }); + if (reclassed.asset.asset_class_number !== 'AC-200') throw new Error('Class number change did not cascade to assets'); + + // Asset ID templates: pattern, preview, assign to a category, auto-increment on asset creation. + const idTemplate = await request('/api/asset-id-templates', { + method: 'POST', headers: auth, body: JSON.stringify({ name: `Tag ${suffix}`, pattern: 'T-A#####', next_number: 5 }) + }); + if (idTemplate.template.preview !== 'T-A00005') throw new Error('ID template preview was not formatted correctly'); + const badPattern = await fetch(`${baseUrl}/api/asset-id-templates`, { + method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'bad', pattern: 'NOHASH' }) + }); + if (badPattern.status !== 400) throw new Error('Pattern without # should be rejected'); + // A category that uses the template. + const tagCategory = await request('/api/asset-categories', { + method: 'POST', headers: auth, body: JSON.stringify({ name: `Tagged Cat ${suffix}`, id_template_id: idTemplate.template.id }) + }); + if (tagCategory.category.id_template_id !== idTemplate.template.id) throw new Error('Category did not store its ID template assignment'); + // New asset with no explicit asset_id should get the templated tag and advance the counter. + const auto1 = await request('/api/assets', { + method: 'POST', headers: auth, body: JSON.stringify({ description: 'Auto tag 1', category: `Tagged Cat ${suffix}`, acquisition_cost: 10 }) + }); + if (auto1.asset.asset_id !== 'T-A00005') throw new Error(`Expected auto tag T-A00005, got ${auto1.asset.asset_id}`); + const auto2 = await request('/api/assets', { + method: 'POST', headers: auth, body: JSON.stringify({ description: 'Auto tag 2', category: `Tagged Cat ${suffix}`, acquisition_cost: 10 }) + }); + if (auto2.asset.asset_id !== 'T-A00006') throw new Error(`Expected auto tag T-A00006, got ${auto2.asset.asset_id}`); + // An explicit asset_id still wins over the template. + const explicit = await request('/api/assets', { + method: 'POST', headers: auth, body: JSON.stringify({ asset_id: `EXP-${suffix}`, description: 'Explicit', category: `Tagged Cat ${suffix}`, acquisition_cost: 10 }) + }); + if (explicit.asset.asset_id !== `EXP-${suffix}`) throw new Error('Explicit asset_id should override the template'); + // Deleting the template unassigns it from the category. + const idTplDelete = await request(`/api/asset-id-templates/${idTemplate.template.id}`, { method: 'DELETE', headers: auth }); + if (!idTplDelete.deleted || idTplDelete.category_count !== 1) throw new Error('ID template delete did not unassign its category'); + + // Asset departments: create, rename (cascades to assets), usage count, delete. + const newDept = await request('/api/asset-departments', { + method: 'POST', headers: auth, body: JSON.stringify({ name: `Smoke Dept ${suffix}`, code: 'SMKD' }) + }); + if (!newDept.department?.id) throw new Error('Asset department was not created'); + await request(`/api/assets/${catAsset.asset.id}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ department: `Smoke Dept ${suffix}` }) + }); + const renamedDept = await request(`/api/asset-departments/${newDept.department.id}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ name: `Smoke Dept ${suffix} (renamed)` }) + }); + if (renamedDept.renamed_assets < 1) throw new Error('Department rename did not cascade to assets'); + const deptAsset = await request(`/api/assets/${catAsset.asset.id}`, { headers: auth }); + if (deptAsset.asset.department !== `Smoke Dept ${suffix} (renamed)`) throw new Error('Asset department was not updated by rename'); + const deptList = await request('/api/asset-departments', { headers: auth }); + if (!deptList.departments.some((d) => d.id === newDept.department.id && d.asset_count === 1)) throw new Error('Department usage count was not reported'); + const deptDelete = await request(`/api/asset-departments/${newDept.department.id}`, { method: 'DELETE', headers: auth }); + if (!deptDelete.deleted || deptDelete.asset_count !== 1) throw new Error('Department delete did not report usage impact'); const assetResult = await request('/api/assets', { method: 'POST', @@ -201,10 +421,365 @@ async function main() { body: JSON.stringify({ body: 'Received and tagged in receiving dock.' }) }); + // Identification photos (base64 + caption) + const photoResult = await request(`/api/assets/${assetId}/photos`, { + method: 'POST', headers: auth, + body: JSON.stringify({ name: 'front.png', mime: 'image/png', caption: 'Front nameplate', data: pngDataUrl }) + }); + if (!photoResult.photo?.id) throw new Error('Asset photo was not created'); + const recaptioned = await request(`/api/assets/${assetId}/photos/${photoResult.photo.id}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ caption: 'Serial label, left side' }) + }); + if (recaptioned.photo.caption !== 'Serial label, left side') throw new Error('Asset photo caption did not update'); + const detailed = await request(`/api/assets/${assetId}`, { headers: auth }); if (!detailed.asset.files.length || !detailed.asset.warranties.length || !detailed.asset.notes_log.length) { throw new Error('Asset detail collections (files/warranties/notes) were not returned'); } + if (!detailed.asset.photos.length || !detailed.asset.photos[0].data || detailed.asset.photos[0].caption !== 'Serial label, left side') { + throw new Error('Asset photos (with caption + data) were not returned'); + } + + // Barcode lookup: set a barcode value, then resolve it via the scan endpoint. + await request(`/api/assets/${assetId}`, { + method: 'PUT', + headers: auth, + body: JSON.stringify({ barcode_value: `BC-${suffix}` }) + }); + const scanned = await request(`/api/assets/lookup?code=BC-${suffix}`, { headers: auth }); + if (scanned.asset.id !== assetId) throw new Error('Barcode lookup did not resolve the scanned asset'); + const byAssetId = await request(`/api/assets/lookup?code=${encodeURIComponent(assetResult.asset.asset_id)}`, { headers: auth }); + if (byAssetId.asset.id !== assetId) throw new Error('Asset ID lookup fallback failed'); + + // Reporting suite: catalog, several report types, export, and saved reports. + const reportCatalog = await request('/api/reports/catalog', { headers: auth }); + if (!reportCatalog.reports.length || !reportCatalog.params.book) { + throw new Error('Report catalog did not return reports and param specs'); + } + const runReportApi = (type, options) => request('/api/reports/run', { + method: 'POST', + headers: auth, + body: JSON.stringify({ type, options }) + }); + const acq = await runReportApi('acquisitions', { year: 2026 }); + if (!Array.isArray(acq.report.rows)) throw new Error('Acquisitions report did not return rows'); + const disp = await runReportApi('disposals', { year: 2026 }); + if (!Array.isArray(disp.report.rows)) throw new Error('Disposals report did not return rows'); + const roll = await runReportApi('rollforward', { book: 'GAAP', year: 2026 }); + if (!roll.report.columns.length) throw new Error('Rollforward report did not return columns'); + const f4797 = await runReportApi('form_4797', { year: 2026 }); + if (!Array.isArray(f4797.report.rows)) throw new Error('Form 4797 report did not return rows'); + const custom = await runReportApi('custom', { columns: ['asset_id', 'acquisition_cost'] }); + if (custom.report.columns.length !== 2) throw new Error('Report builder column projection failed'); + for (const pmReport of ['pm_due', 'pm_uncompleted', 'pm_completed', 'alerts_since']) { + const r = await runReportApi(pmReport, { before_date: '2099-01-01', since_date: '2000-01-01' }); + if (!Array.isArray(r.report.rows)) throw new Error(`${pmReport} report did not return rows`); + } + + const exportResponse = await fetch(`${baseUrl}/api/reports/export`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'GAAP', year: 2026 }, format: 'csv' }) + }); + if (!exportResponse.ok || !(await exportResponse.text()).includes('Asset ID')) { + throw new Error('Report CSV export failed'); + } + + const savedReport = await request('/api/saved-reports', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: `Saved ${suffix}`, report_type: 'net_book_value', options: { book: 'GAAP', year: 2026 } }) + }); + const savedList = await request('/api/saved-reports', { headers: auth }); + if (!savedList.reports.some((report) => report.id === savedReport.report.id)) { + throw new Error('Saved report was not persisted/listed'); + } + + // Tax rule sets: expand (method catalog), create, edit, activate, delete. + const expanded = await request('/api/tax-rules/expand', { + method: 'POST', + headers: auth, + body: JSON.stringify({ rules_json: { jurisdiction: 'US-SMOKE', version: '1.0' } }) + }); + if ((expanded.rules_json.methods || []).length !== 74) { + throw new Error('Rule set expansion did not generate the 74-method catalog'); + } + const createdRule = await request('/api/tax-rules', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + jurisdiction: `US-SMOKE-${suffix}`, + name: 'Smoke rule set', + version: '1.0', + effective_date: '2026-01-01', + active: false, + rules_json: { jurisdiction: `US-SMOKE-${suffix}`, version: '1.0', limits: { section179: { deductionLimit: 1000000 } } } + }) + }); + const editedRule = await request(`/api/tax-rules/${createdRule.ruleSet.id}`, { + method: 'PUT', + headers: auth, + body: JSON.stringify({ name: 'Smoke rule set (edited)', rules_json: { jurisdiction: `US-SMOKE-${suffix}`, version: '1.0', limits: { section179: { deductionLimit: 2000000 } } } }) + }); + if (editedRule.ruleSet.name !== 'Smoke rule set (edited)' || editedRule.ruleSet.rules_json.limits.section179.deductionLimit !== 2000000) { + throw new Error('Tax rule set edit did not persist'); + } + const activated = await request(`/api/tax-rules/${createdRule.ruleSet.id}/activate`, { method: 'POST', headers: auth }); + if (!activated.ruleSets.find((set) => set.id === createdRule.ruleSet.id)?.active) { + throw new Error('Tax rule set activation did not persist'); + } + const deleteRule = await fetch(`${baseUrl}/api/tax-rules/${createdRule.ruleSet.id}`, { method: 'DELETE', headers: auth }); + if (deleteRule.status !== 204) throw new Error('Tax rule set delete failed'); + + // Books registry: list, edit (assign rule set + default method), and GL ledger. + const booksList = await request('/api/books', { headers: auth }); + if (booksList.books.length < 6 || !Array.isArray(booksList.ruleSets)) { + throw new Error('Books registry did not return the standard books and rule sets'); + } + const gaapBookDef = booksList.books.find((book) => book.code === 'GAAP'); + const federalRuleSet = booksList.ruleSets[0]; + const editedBook = await request(`/api/books/${gaapBookDef.id}`, { + method: 'PUT', + headers: auth, + body: JSON.stringify({ default_method: 'straight_line', tax_rule_set_id: federalRuleSet.id, enabled: true }) + }); + if (editedBook.book.tax_rule_set_id !== federalRuleSet.id) { + throw new Error('Book tax-rule assignment did not persist'); + } + // User-defined book: create, ensure it applies to a new asset, then guard + delete. + const newBook = await request('/api/books', { + method: 'POST', + headers: auth, + body: JSON.stringify({ code: `IFRS${suffix}`.slice(0, 12), name: 'IFRS book', book_type: 'financial', enabled: true }) + }); + const newBookCode = newBook.book.code; + const assetWithUserBook = await request('/api/assets', { + method: 'POST', + headers: auth, + body: JSON.stringify({ asset_id: `UB-${suffix}`, description: 'User-book asset', category: 'Computer Equipment', acquisition_cost: 1200 }) + }); + const hydratedUserBook = await request(`/api/assets/${assetWithUserBook.asset.id}`, { headers: auth }); + if (!hydratedUserBook.asset.books.some((b) => b.book_type === newBookCode)) { + throw new Error('Newly created enabled book did not apply to a new asset'); + } + const blockedBookDelete = await fetch(`${baseUrl}/api/books/${newBook.book.id}`, { method: 'DELETE', headers: auth }); + if (blockedBookDelete.status !== 400) throw new Error('Deleting a book in use should be blocked'); + const spareBook = await request('/api/books', { + method: 'POST', headers: auth, body: JSON.stringify({ code: `TMP${suffix}`.slice(0, 12), name: 'Temp book' }) + }); + const okBookDelete = await fetch(`${baseUrl}/api/books/${spareBook.book.id}`, { method: 'DELETE', headers: auth }); + if (okBookDelete.status !== 204) throw new Error('Deleting an unused book failed'); + + const ledger = await request('/api/books/GAAP/ledger?year=2026', { headers: auth }); + if (!ledger.ledger.accounts.length || typeof ledger.ledger.summary.nbv !== 'number') { + throw new Error('Book GL ledger did not return accounts and summary'); + } + const ledgerExport = await fetch(`${baseUrl}/api/books/GAAP/ledger/export`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ year: 2026, format: 'csv' }) + }); + if (!ledgerExport.ok || !(await ledgerExport.text()).includes('GL account')) { + throw new Error('Book GL ledger export failed'); + } + + // Alerts: a clearly-overdue task should raise a critical alert on scan. + await request(`/api/assets/${assetId}/tasks`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ title: 'Annual inspection', type: 'inspection', due_date: '2020-01-01' }) + }); + const scan = await request('/api/alerts/scan', { method: 'POST', headers: auth }); + const overdue = scan.alerts.find((a) => a.type === 'maintenance_overdue' && a.due_date === '2020-01-01'); + if (!overdue || overdue.severity !== 'critical') throw new Error('Overdue maintenance alert was not raised'); + const acked = await request(`/api/alerts/${overdue.id}/acknowledge`, { method: 'PUT', headers: auth }); + if (acked.alert.status !== 'acknowledged') throw new Error('Alert acknowledge did not persist'); + const dismissed = await request(`/api/alerts/${overdue.id}/dismiss`, { method: 'PUT', headers: auth }); + if (dismissed.alert.status !== 'dismissed') throw new Error('Alert dismiss did not persist'); + + // Notification (SMTP) settings: password is write-only and never returned. + await request('/api/notifications/settings', { + method: 'PUT', + headers: auth, + body: JSON.stringify({ + smtp_host: 'smtp.example.com', smtp_port: 587, smtp_secure: false, + smtp_user: 'mailer@example.com', smtp_password: 'secret', smtp_from: 'MixedAssets ', + alerts_enabled: false, alerts_lead_days: 45, alerts_recipients: 'ops@example.com, finance@example.com' + }) + }); + const notif = await request('/api/notifications/settings', { headers: auth }); + if (notif.settings.smtp_host !== 'smtp.example.com' || notif.settings.smtp_password_set !== true || notif.settings.smtp_password !== undefined) { + throw new Error('Notification settings did not persist or leaked the password'); + } + if (notif.settings.alerts_lead_days !== 45) throw new Error('Alert lead days did not persist'); + const adminSettings = await request('/api/settings', { headers: auth }); + if ('smtp_password' in adminSettings.settings) throw new Error('Admin settings endpoint leaked smtp_password'); + + // Preventative maintenance: plan with steps, attach to asset, alert, complete, settings. + const pmPlan = await request('/api/pm-plans', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + name: `Lift PM ${suffix}`, + frequency_value: 3, + frequency_unit: 'months', + steps: [{ title: 'Tighten lug bolt A3', est_minutes: 15 }, { title: 'Inspect hydraulics', est_minutes: 30 }], + components: [ + { part_number: 'OIL-10', description: 'Hydraulic oil', supplier: 'Acme', cost: 40 }, + { part_number: 'FLT-2', description: 'Filter', supplier: 'Acme', cost: 12 } + ], + photos: [{ name: 'diagram.png', mime: 'image/png', caption: 'Lug bolt locations', data: pngDataUrl }] + }) + }); + if (pmPlan.plan.steps.length !== 2) throw new Error('PM plan steps were not saved'); + if (pmPlan.plan.estimated_minutes !== 45) throw new Error('Estimated minutes were not totaled from step times'); + if (pmPlan.plan.components.length !== 2 || pmPlan.plan.components_total !== 52) throw new Error('PM components were not saved/totaled'); + if (pmPlan.plan.photos.length !== 1 || !pmPlan.plan.photos[0].data) throw new Error('PM plan guidance photo was not saved'); + + // PM categories: create, rename (cascades to plans), usage count, delete — separate from asset categories. + const pmCat = await request('/api/pm-categories', { + method: 'POST', headers: auth, body: JSON.stringify({ name: `PM Cat ${suffix}`, code: 'PMC' }) + }); + if (!pmCat.category?.id) throw new Error('PM category was not created'); + await request(`/api/pm-plans/${pmPlan.plan.id}`, { method: 'PUT', headers: auth, body: JSON.stringify({ category: `PM Cat ${suffix}` }) }); + const renamedPmCat = await request(`/api/pm-categories/${pmCat.category.id}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ name: `PM Cat ${suffix} (renamed)` }) + }); + if (renamedPmCat.renamed_plans < 1) throw new Error('PM category rename did not cascade to plans'); + const renamedPlan = await request(`/api/pm-plans/${pmPlan.plan.id}`, { headers: auth }); + if (renamedPlan.plan.category !== `PM Cat ${suffix} (renamed)`) throw new Error('PM plan category was not updated by rename'); + const pmCatList = await request('/api/pm-categories', { headers: auth }); + if (!pmCatList.categories.some((c) => c.id === pmCat.category.id && c.plan_count === 1)) throw new Error('PM category usage count was not reported'); + // PM categories are distinct from asset categories. + const assetCats = await request('/api/asset-categories', { headers: auth }); + if (assetCats.categories.some((c) => c.name === `PM Cat ${suffix} (renamed)`)) throw new Error('PM category leaked into asset categories'); + const pmCatDelete = await request(`/api/pm-categories/${pmCat.category.id}`, { method: 'DELETE', headers: auth }); + if (!pmCatDelete.deleted || pmCatDelete.plan_count !== 1) throw new Error('PM category delete did not report usage impact'); + // The list view omits photo blobs; the single-plan fetch includes them. + const planList = await request('/api/pm-plans', { headers: auth }); + const listed = planList.plans.find((p) => p.id === pmPlan.plan.id); + if (listed.photo_count !== 1 || (listed.photos[0] && listed.photos[0].data)) throw new Error('PM plan list should report photo_count without blobs'); + const singlePlan = await request(`/api/pm-plans/${pmPlan.plan.id}`, { headers: auth }); + if (!singlePlan.plan.photos[0].data) throw new Error('Single PM plan fetch should include photo data'); + // Editing the plan keeps the existing photo (resent by id) and updates its caption. + const editedPlan = await request(`/api/pm-plans/${pmPlan.plan.id}`, { + method: 'PUT', headers: auth, + body: JSON.stringify({ photos: [{ id: pmPlan.plan.photos[0].id, caption: 'Updated caption' }] }) + }); + if (editedPlan.plan.photos.length !== 1 || editedPlan.plan.photos[0].caption !== 'Updated caption' || !editedPlan.plan.photos[0].data) { + throw new Error('Editing PM plan photo caption (resend by id) did not preserve the blob'); + } + const planPrint = await request('/api/reports/run', { method: 'POST', headers: auth, body: JSON.stringify({ type: 'pm_plan', options: { pm_plan_id: pmPlan.plan.id } }) }); + if (planPrint.report.rows.length !== 2 || !planPrint.report.meta.note.includes('Frequency')) { + throw new Error('Printable PM plan report was incomplete'); + } + const attached = await request(`/api/assets/${assetId}/pm`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ plan_id: pmPlan.plan.id, frequency_value: 1, frequency_unit: 'months', start_date: '2020-01-01', next_due_date: '2020-01-01', end_date: '2035-01-01' }) + }); + const pmScan = await request('/api/alerts/scan', { method: 'POST', headers: auth }); + if (!pmScan.alerts.some((a) => a.type === 'pm_overdue' && a.reference_id === attached.pm.id)) { + throw new Error('Overdue PM alert was not raised'); + } + // Guidance photos surface on the asset's PM schedule for the completion view. + const pmGuidance = await request(`/api/assets/${assetId}/pm`, { headers: auth }); + const pmSchedule = pmGuidance.pm.find((p) => p.id === attached.pm.id); + if (!pmSchedule.plan_photos || !pmSchedule.plan_photos.length || !pmSchedule.plan_photos[0].data) { + throw new Error('PM plan guidance photos were not exposed on the asset schedule'); + } + const completed = await request(`/api/assets/${assetId}/pm/${attached.pm.id}/complete`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ + steps: [{ title: 'Tighten lug bolt A3', done: true }], + notes_list: ['Topped up fluids', 'Replaced worn filter'], + ratings: { safety: 5, physical: 4, operating: 5 }, + signature: pngDataUrl, + photos: [{ name: 'after.png', mime: 'image/png', data: pngDataUrl }] + }) + }); + if (!completed.pm.next_due_date || completed.pm.next_due_date <= '2020-01-01') { + throw new Error('Completing PM did not advance the next due date'); + } + // Signature is required. + const missingSig = await fetch(`${baseUrl}/api/assets/${assetId}/pm/${attached.pm.id}/complete`, { + method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ ratings: { safety: 3 } }) + }); + if (missingSig.status !== 400) throw new Error('PM completion without a signature should be rejected'); + const withPm = await request(`/api/assets/${assetId}`, { headers: auth }); + if (!withPm.asset.pm.length || !withPm.asset.pm[0].completions.length) { + throw new Error('Asset PM schedule/completions were not returned'); + } + if (withPm.asset.pm[0].total_cost !== 52) { + throw new Error('PM completion cost (from components) was not captured'); + } + const firstCompletion = withPm.asset.pm[0].completions[0]; + if (!firstCompletion.has_signature || firstCompletion.photo_count !== 1 || firstCompletion.ratings.safety !== 5) { + throw new Error('PM completion metadata (signature/photos/ratings) was not stored'); + } + const completionList = await request(`/api/pm-completions?asset_id=${assetId}`, { headers: auth }); + if (!completionList.completions.length) throw new Error('PM completions list was empty'); + const completionDetail = await request(`/api/pm-completions/${completionList.completions[0].id}`, { headers: auth }); + if (!completionDetail.completion.signature || !completionDetail.completion.photos.length || completionDetail.completion.notes.length !== 2) { + throw new Error('PM completion form detail was incomplete'); + } + + // PM book: actual maintenance spend aggregated per asset. + const booksWithPm = await request('/api/books', { headers: auth }); + if (!booksWithPm.books.some((b) => b.code === 'PM')) throw new Error('PM book missing from the registry'); + const yearNow = new Date().getFullYear(); + const pmLedger = await request(`/api/books/PM/ledger?year=${yearNow}`, { headers: auth }); + if (pmLedger.ledger.kind !== 'maintenance' || pmLedger.ledger.summary.cost < 52) { + throw new Error('PM book ledger did not aggregate maintenance cost'); + } + await request('/api/pm-settings', { + method: 'PUT', + headers: auth, + body: JSON.stringify({ pm_default_frequency_value: 6, pm_default_frequency_unit: 'months', pm_lead_days: 14 }) + }); + const pmSettings = await request('/api/pm-settings', { headers: auth }); + if (pmSettings.settings.pm_default_frequency_value !== 6 || pmSettings.settings.pm_lead_days !== 14) { + throw new Error('PM settings did not persist'); + } + + // Contacts directory: typed records, type-specific fields, filtering, notes. + const vendorContact = await request('/api/contacts', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + type: 'vendor', organization: `Acme Supply ${suffix}`, email: `sales-${suffix}@acme.test`, + phone: '+1 555-0100', rating: 4, credit_term: 'net_60', country: 'US' + }) + }); + if (vendorContact.contact.type !== 'vendor' || vendorContact.contact.rating !== 4 || vendorContact.contact.credit_term !== 'net_60') { + throw new Error('Vendor contact fields did not persist'); + } + await request('/api/contacts', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + type: 'contractor', first_name: 'Dana', last_name: 'Cruz', email: `dana-${suffix}@contractor.test`, + tax_id: 'BL-99821', department: 'Facilities' + }) + }); + const vendorList = await request('/api/contacts?type=vendor', { headers: auth }); + if (!vendorList.contacts.some((c) => c.id === vendorContact.contact.id) || vendorList.contacts.some((c) => c.type !== 'vendor')) { + throw new Error('Contact type filter failed'); + } + const updatedContact = await request(`/api/contacts/${vendorContact.contact.id}`, { + method: 'PUT', + headers: auth, + body: JSON.stringify({ rating: 5 }) + }); + if (updatedContact.contact.rating !== 5) throw new Error('Contact update did not persist'); + await request(`/api/contacts/${vendorContact.contact.id}/notes`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ body: 'Negotiated faster lead times.' }) + }); + const contactDetail = await request(`/api/contacts/${vendorContact.contact.id}`, { headers: auth }); + if (!contactDetail.contact.contact_notes.length) throw new Error('Contact notes were not returned'); const employeeResult = await request('/api/employees', { method: 'POST', @@ -244,17 +819,375 @@ async function main() { client_id: 'client', client_secret: 'secret', workers_path: '/workers', - enabled: false + enabled: false, + sync_enabled: true, + sync_interval_hours: 12 }) }); - if (!workday.connection.configured) { + if (!workday.connection.configured || workday.connection.sync_enabled !== true || workday.connection.sync_interval_hours !== 12) { throw new Error('Workday connection configuration did not persist'); } + await request('/api/workday/import-workers', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + workers: [{ id: `WD-${suffix}`, firstName: 'Pat', lastName: 'Lee', email: `pat-${suffix}@corp.test`, department: 'IT', managerName: 'Sam Ray', hireDate: '2024-02-01' }] + }) + }); + const importedContacts = await request(`/api/contacts?type=employee&search=pat-${suffix}`, { headers: auth }); + if (!importedContacts.contacts.some((c) => c.workday_worker_id === `WD-${suffix}` && c.start_date === '2024-02-01' && c.manager_last_name === 'Ray')) { + throw new Error('Workday import did not create an enriched employee contact'); + } + 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'); } + + // 200% declining-balance methods. MF200 (MACRS) ignores salvage and reproduces the IRS + // 5-year table; DB200 (standard) honors salvage (recovers only cost − salvage). + const round = (n) => Math.round(Number(n)); + const macrsAsset = await request('/api/assets', { + method: 'POST', headers: auth, + body: JSON.stringify({ + asset_id: `MF-${suffix}`, description: 'MF200 press', category: 'Computer Equipment', + acquisition_cost: 10000, salvage_value: 1000, in_service_date: '2026-01-01', useful_life_months: 60, + books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MF200', convention: 'half_year', useful_life_months: 60, cost: 10000 }] + }) + }); + const mfSched = (await request(`/api/assets/${macrsAsset.asset.id}/depreciation?startYear=2026&endYear=2032`, { headers: auth })) + .schedules.filter((r) => r.book === 'FEDERAL'); + const mfByYear = Object.fromEntries(mfSched.map((r) => [r.year, round(r.depreciation)])); + // IRS 5-year 200%DB half-year: 2000, 3200, 1920, 1152, 1152, 576 — salvage is ignored. + if (mfByYear[2026] !== 2000 || mfByYear[2027] !== 3200 || mfByYear[2028] !== 1920) { + throw new Error(`MF200 schedule incorrect: ${JSON.stringify(mfByYear)}`); + } + const mfTotal = round(mfSched.reduce((s, r) => s + r.depreciation, 0)); + if (mfTotal !== 10000) throw new Error(`MF200 should recover full cost (MACRS ignores salvage), got ${mfTotal}`); + + const dbAsset = await request('/api/assets', { + method: 'POST', headers: auth, + body: JSON.stringify({ + asset_id: `DB-${suffix}`, description: 'DB200 press', category: 'Computer Equipment', + acquisition_cost: 10000, salvage_value: 1000, in_service_date: '2026-01-01', useful_life_months: 60, + books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'DB200', convention: 'half_year', useful_life_months: 60, cost: 10000 }] + }) + }); + const dbSched = (await request(`/api/assets/${dbAsset.asset.id}/depreciation?startYear=2026&endYear=2034`, { headers: auth })) + .schedules.filter((r) => r.book === 'FEDERAL'); + const dbTotal = round(dbSched.reduce((s, r) => s + r.depreciation, 0)); + if (dbTotal !== 9000) throw new Error(`DB200 should recover cost − salvage (9000), got ${dbTotal}`); + if (dbSched.some((r) => r.netBookValue < 999)) throw new Error('DB200 depreciated below salvage value'); + + // New York Liberty Zone: seeded zone applies a 30% §168 allowance to the federal book in year 1. + const zoneList = await request('/api/depreciation-zones', { headers: auth }); + const liberty = zoneList.zones.find((z) => z.code === 'ny_liberty'); + if (!liberty || liberty.allowance_percent !== 30) throw new Error('New York Liberty Zone was not seeded'); + const zoneAsset = await request('/api/assets', { + method: 'POST', headers: auth, + body: JSON.stringify({ + asset_id: `LZ-${suffix}`, description: 'Liberty Zone equipment', category: 'Computer Equipment', + acquisition_cost: 10000, in_service_date: '2005-01-01', useful_life_months: 60, special_zone: 'ny_liberty', + books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MF200', convention: 'half_year', useful_life_months: 60, cost: 10000 }] + }) + }); + const lzSched = (await request(`/api/assets/${zoneAsset.asset.id}/depreciation?startYear=2005&endYear=2011`, { headers: auth })) + .schedules.filter((r) => r.book === 'FEDERAL'); + const lzByYear = Object.fromEntries(lzSched.map((r) => [r.year, round(r.depreciation)])); + // 30% allowance (3000) + first-year MACRS (20% half-year) on the remaining 7000 (1400) = 4400. + if (lzByYear[2005] !== 4400) throw new Error(`Liberty Zone year-1 allowance incorrect: ${JSON.stringify(lzByYear)}`); + + // Outside the placed-in-service window (2008) the allowance must NOT apply. + const outAsset = await request('/api/assets', { + method: 'POST', headers: auth, + body: JSON.stringify({ + asset_id: `LZX-${suffix}`, description: 'Out-of-window', category: 'Computer Equipment', + acquisition_cost: 10000, in_service_date: '2008-01-01', useful_life_months: 60, special_zone: 'ny_liberty', + books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MF200', convention: 'half_year', useful_life_months: 60, cost: 10000 }] + }) + }); + const outYear1 = round((await request(`/api/assets/${outAsset.asset.id}/depreciation?startYear=2008&endYear=2008`, { headers: auth })) + .schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation); + if (outYear1 !== 2000) throw new Error(`Out-of-window asset should get plain MACRS year-1 (2000), got ${outYear1}`); + + // Disposal: preview gain/loss, then record a full disposal. + const disposalAsset = await request('/api/assets', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + asset_id: `D-${suffix}`, + description: 'Disposal test press', + category: 'Computer Equipment', + acquisition_cost: 10000, + acquired_date: '2022-01-05', + in_service_date: '2022-01-05', + useful_life_months: 60, + depreciation_method: 'straight_line' + }) + }); + const preview = await request(`/api/assets/${disposalAsset.asset.id}/disposal/preview`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ book_type: 'GAAP', disposal_date: '2026-06-01', disposal_price: 3000, disposal_expense: 200 }) + }); + if (typeof preview.preview.gain_loss !== 'number' || typeof preview.preview.net_book_value !== 'number') { + throw new Error('Disposal preview did not return gain/loss figures'); + } + const disposed = await request(`/api/assets/${disposalAsset.asset.id}/disposals`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ book_type: 'GAAP', disposal_date: '2026-06-01', disposal_price: 3000, disposal_expense: 200, method: 'sale', property_type: '1245' }) + }); + if (disposed.asset.status !== 'disposed' || !disposed.disposal?.id) { + throw new Error('Full disposal did not mark the asset disposed'); + } + + // Impairment adjustment + const adjustment = await request(`/api/assets/${disposalAsset.asset.id}/adjustments`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ type: 'impairment', amount: 500, book_type: 'GAAP', reason: 'Flood damage' }) + }); + if (!adjustment.adjustment?.id) throw new Error('Adjustment was not recorded'); + + // Lease accounting + amortization schedule + const lease = await request('/api/leases', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + asset_id: assetId, + lessor: 'Equip Leasing Co', + contract_value: 24000, + start_date: '2026-01-01', + end_date: '2028-01-01', + discount_rate: 6, + payment_frequency: 'monthly' + }) + }); + const schedule = await request(`/api/leases/${lease.lease.id}/schedule`, { headers: auth }); + if (!schedule.schedule?.rows?.length || !(schedule.schedule.summary.present_value > 0)) { + throw new Error('Lease amortization schedule was not generated'); + } + if (schedule.schedule.rows.length !== schedule.schedule.summary.periods) { + throw new Error('Lease schedule period count mismatch'); + } + + // Parts inventory: part record, stock locations (aisle/bin), reservations, movements, photos. + const workLocation = await request('/api/contacts', { + method: 'POST', + headers: auth, + body: JSON.stringify({ type: 'work_location', organization: `Main Warehouse ${suffix}` }) + }); + const partResult = await request('/api/parts', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + part_number: `HYD-${suffix}`, name: 'Hydraulic filter', category: 'Filters', + unit_of_measure: 'each', unit_cost: 18.5, reorder_point: 5, reorder_quantity: 20, + measurements: '120mm x 80mm', supplier_contact_id: vendorContact.contact.id + }) + }); + const partId = partResult.part.id; + if (partResult.part.supplier_name === null) throw new Error('Part supplier name was not resolved from contacts'); + + const withStock = await request(`/api/parts/${partId}/stock`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ location_contact_id: workLocation.contact.id, aisle: 'A12', bin: 'B3', quantity: 10, reserved: 2 }) + }); + const stockRow = withStock.part.stock[0]; + if (!stockRow || stockRow.aisle !== 'A12' || stockRow.bin !== 'B3' || stockRow.available !== 8) { + throw new Error('Part stock location (aisle/bin/available) was not stored correctly'); + } + if (withStock.part.on_hand !== 10 || withStock.part.reserved !== 2) { + throw new Error('Part on-hand/reserved aggregation failed'); + } + + // Receive 5 more, then reserve 3, then issue 4. + const received = await request(`/api/parts/${partId}/transactions`, { + method: 'POST', headers: auth, body: JSON.stringify({ stock_id: stockRow.id, type: 'receive', quantity: 5, notes: 'PO-1' }) + }); + if (received.part.on_hand !== 15) throw new Error('Receive movement did not increase on-hand'); + const reserved = await request(`/api/parts/${partId}/transactions`, { + method: 'POST', headers: auth, body: JSON.stringify({ stock_id: stockRow.id, type: 'reserve', quantity: 3, asset_id: assetId }) + }); + if (reserved.part.reserved !== 5) throw new Error('Reserve movement did not increase reserved'); + const issued = await request(`/api/parts/${partId}/transactions`, { + method: 'POST', headers: auth, body: JSON.stringify({ stock_id: stockRow.id, type: 'issue', quantity: 4 }) + }); + if (issued.part.on_hand !== 11 || issued.part.available !== 6) throw new Error('Issue movement did not reduce on-hand/available'); + if (issued.part.transactions.length !== 3) throw new Error('Part transaction history was not recorded'); + + // Photo attach + const photoPart = await request(`/api/parts/${partId}/photos`, { + method: 'POST', headers: auth, body: JSON.stringify({ name: 'filter.png', mime: 'image/png', data: pngDataUrl }) + }); + if (!photoPart.part.photos.length) throw new Error('Part photo was not attached'); + + // Low-stock filter: drop below reorder point and confirm it surfaces. + await request(`/api/parts/${partId}/transactions`, { + method: 'POST', headers: auth, body: JSON.stringify({ stock_id: stockRow.id, type: 'adjust', quantity: 3 }) + }); + const lowStock = await request('/api/parts?low_stock=true', { headers: auth }); + if (!lowStock.parts.some((p) => p.id === partId && p.low_stock)) { + throw new Error('Low-stock filter did not surface the depleted part'); + } + const partList = await request(`/api/parts?search=HYD-${suffix}`, { headers: auth }); + if (!partList.parts.some((p) => p.id === partId)) throw new Error('Part search did not return the created part'); + + // Parts categories: seeded defaults present; create, rename (cascades to parts), usage count, delete. + const seededPartCats = await request('/api/part-categories', { headers: auth }); + for (const expected of ['Electrical', 'Plumbing', 'Fire Safety', 'Other']) { + if (!seededPartCats.categories.some((c) => c.name === expected)) throw new Error(`Seeded part category "${expected}" missing`); + } + const newPartCat = await request('/api/part-categories', { + method: 'POST', headers: auth, body: JSON.stringify({ name: `Part Cat ${suffix}` }) + }); + if (!newPartCat.category?.id) throw new Error('Part category was not created'); + await request(`/api/parts/${partId}`, { method: 'PUT', headers: auth, body: JSON.stringify({ category: `Part Cat ${suffix}` }) }); + const renamedPartCat = await request(`/api/part-categories/${newPartCat.category.id}`, { + method: 'PUT', headers: auth, body: JSON.stringify({ name: `Part Cat ${suffix} (renamed)` }) + }); + if (renamedPartCat.renamed_parts < 1) throw new Error('Part category rename did not cascade to parts'); + const renamedPartDetail = await request(`/api/parts/${partId}`, { headers: auth }); + if (renamedPartDetail.part.category !== `Part Cat ${suffix} (renamed)`) throw new Error('Part category was not updated by rename'); + const partCatList = await request('/api/part-categories', { headers: auth }); + if (!partCatList.categories.some((c) => c.id === newPartCat.category.id && c.part_count === 1)) throw new Error('Part category usage count was not reported'); + const partCatDelete = await request(`/api/part-categories/${newPartCat.category.id}`, { method: 'DELETE', headers: auth }); + if (!partCatDelete.deleted || partCatDelete.part_count !== 1) throw new Error('Part category delete did not report usage impact'); + + // Alert webhook: each new alert is delivered to an external endpoint as a JSON POST. + const webhookHits = []; + const hookSecret = 'whsec_smoke'; + const hookServer = http.createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { + webhookHits.push({ headers: req.headers, raw, body: JSON.parse(raw || '{}') }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); + }); + await new Promise((resolve) => hookServer.listen(4100, '127.0.0.1', resolve)); + try { + const hookUrl = 'http://127.0.0.1:4100/hook'; + const savedHook = await request('/api/notifications/settings', { + method: 'PUT', headers: auth, + body: JSON.stringify({ webhook_enabled: true, webhook_url: hookUrl, webhook_secret: hookSecret }) + }); + if (savedHook.settings.webhook_url !== hookUrl || savedHook.settings.webhook_secret_set !== true || savedHook.settings.webhook_secret !== undefined) { + throw new Error('Webhook settings did not persist or leaked the secret'); + } + + // Test event + HMAC signature verification. + const hookTest = await request('/api/notifications/webhook-test', { method: 'POST', headers: auth, body: JSON.stringify({}) }); + if (!hookTest.ok) throw new Error('Webhook test event was not delivered'); + const testHit = webhookHits.find((h) => h.body.event === 'test'); + if (!testHit) throw new Error('Webhook receiver did not get the test event'); + const expectedSig = `sha256=${crypto.createHmac('sha256', hookSecret).update(testHit.raw).digest('hex')}`; + if (testHit.headers['x-mixedassets-signature'] !== expectedSig) { + throw new Error('Webhook HMAC signature was missing or incorrect'); + } + + // A fresh overdue task guarantees a new open alert; the cycle should POST it. + await request(`/api/assets/${assetId}/tasks`, { + method: 'POST', headers: auth, + body: JSON.stringify({ title: `Webhook overdue ${suffix}`, type: 'inspection', due_date: '2019-01-01' }) + }); + const cycle = await request('/api/alerts/run', { method: 'POST', headers: auth }); + if (!cycle.webhooked || !(cycle.delivered > 0)) throw new Error('Alert cycle did not deliver alerts to the webhook'); + const alertHit = webhookHits.find((h) => h.body.event === 'alert'); + if (!alertHit || !alertHit.body.type || !alertHit.body.severity) throw new Error('Webhook alert payload was incomplete'); + } finally { + await new Promise((resolve) => hookServer.close(resolve)); + } + + // ServiceNow integration: incident push (Table API) + CMDB asset pull, against a mock instance. + const snHits = []; + const snServer = http.createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { + snHits.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', raw }); + res.setHeader('Content-Type', 'application/json'); + if (req.url.startsWith('/api/now/table/incident') && req.method === 'POST') { + res.writeHead(201); + res.end(JSON.stringify({ result: { sys_id: 'sys-inc-123', number: 'INC0099999' } })); + } else if (req.url.includes('/api/now/table/cmdb_ci_hardware')) { + res.writeHead(200); + res.end(JSON.stringify({ + result: [{ + sys_id: 'ci-abc-1', name: 'Mock Server 01', asset_tag: `CMDB-${suffix}`, serial_number: `SN-${suffix}`, + sys_class_name: 'cmdb_ci_server', manufacturer: 'Dell', location: 'Data Center 1', cost: '2500', purchase_date: '2025-01-15' + }] + })); + } else { + res.writeHead(200); + res.end(JSON.stringify({ result: [] })); + } + }); + }); + await new Promise((resolve) => snServer.listen(4200, '127.0.0.1', resolve)); + try { + const snUrl = 'http://127.0.0.1:4200'; + const savedSn = await request('/api/servicenow/settings', { + method: 'PUT', headers: auth, + body: JSON.stringify({ + servicenow_instance_url: snUrl, servicenow_username: 'svc_mixedassets', servicenow_password: 'snpass', + servicenow_ticket_enabled: true, servicenow_cmdb_table: 'cmdb_ci_hardware' + }) + }); + if (savedSn.settings.servicenow_instance_url !== snUrl || savedSn.settings.servicenow_password_set !== true || savedSn.settings.servicenow_password !== undefined) { + throw new Error('ServiceNow settings did not persist or leaked the password'); + } + if (!savedSn.settings.servicenow_cmdb_map || !savedSn.settings.servicenow_cmdb_map.serial_number) { + throw new Error('ServiceNow default CMDB field map was not returned'); + } + + // Connection test (GET incident with a basic-auth header). + const snTest = await request('/api/servicenow/test', { method: 'POST', headers: auth, body: JSON.stringify({}) }); + if (!snTest.ok) throw new Error('ServiceNow connection test failed'); + if (!snHits.some((h) => h.method === 'GET' && h.auth.startsWith('Basic '))) { + throw new Error('ServiceNow request did not include basic auth'); + } + + // Submit an alert as an incident. + await request(`/api/assets/${assetId}/tasks`, { + method: 'POST', headers: auth, + body: JSON.stringify({ title: `ServiceNow overdue ${suffix}`, type: 'inspection', due_date: '2018-01-01' }) + }); + const snScan = await request('/api/alerts/scan', { method: 'POST', headers: auth }); + const targetAlert = snScan.alerts.find((a) => a.status === 'open' && !a.external_ticket_number); + if (!targetAlert) throw new Error('No open alert available to ticket'); + const ticketResult = await request(`/api/alerts/${targetAlert.id}/ticket`, { method: 'POST', headers: auth, body: JSON.stringify({}) }); + if (ticketResult.ticket.number !== 'INC0099999') throw new Error('ServiceNow incident number was not returned'); + if (!snHits.some((h) => h.method === 'POST' && h.url.startsWith('/api/now/table/incident'))) { + throw new Error('ServiceNow incident POST was not sent'); + } + // Re-submitting returns the existing ticket rather than duplicating. + const dupTicket = await request(`/api/alerts/${targetAlert.id}/ticket`, { method: 'POST', headers: auth, body: JSON.stringify({}) }); + if (!dupTicket.ticket.duplicate) throw new Error('Re-ticketing should return the existing incident'); + const ticketedList = await request('/api/alerts', { headers: auth }); + if (!ticketedList.alerts.some((a) => a.id === targetAlert.id && a.external_ticket_number === 'INC0099999')) { + throw new Error('Alert was not linked to its ServiceNow incident'); + } + + // CMDB pull: create/update assets from configuration items. + const cmdbSync = await request('/api/servicenow/cmdb-sync', { method: 'POST', headers: auth, body: JSON.stringify({}) }); + if (cmdbSync.total !== 1 || (cmdbSync.created + cmdbSync.updated) < 1) throw new Error('CMDB sync did not import the configuration item'); + const cmdbAssets = await request(`/api/assets?search=CMDB-${suffix}`, { headers: auth }); + const cmdbAsset = cmdbAssets.assets.find((a) => a.asset_id === `CMDB-${suffix}`); + if (!cmdbAsset || cmdbAsset.serial_number !== `SN-${suffix}` || Number(cmdbAsset.acquisition_cost) !== 2500) { + throw new Error('CMDB-sourced asset fields were not populated'); + } + // A second sync should update the same asset, not duplicate it. + const reSync = await request('/api/servicenow/cmdb-sync', { method: 'POST', headers: auth, body: JSON.stringify({}) }); + if (reSync.created !== 0 || reSync.updated !== 1) throw new Error('Re-syncing a CMDB CI should update, not duplicate'); + } finally { + await new Promise((resolve) => snServer.close(resolve)); + } + console.log('Smoke test passed'); } @@ -265,4 +1198,11 @@ main() }) .finally(() => { child.kill('SIGTERM'); + for (const suffix of ['', '-wal', '-shm']) { + try { + fs.rmSync(`${dbPath}${suffix}`, { force: true }); + } catch { + // best-effort cleanup of the temp database + } + } }); diff --git a/src/App.vue b/src/App.vue index 094aba4..522967b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,28 +4,59 @@
- + +
- MA - MixedAssets + +
- - + +
@@ -38,15 +69,16 @@ :title="currentTitle" :user="user" @logout="logout" + @open-help="helpDialog = true" @save-preferences="saveProfilePreferences" + @toggle-nav="toggleNav" > - + + + + + + + + + + + + + +
@@ -170,19 +283,29 @@ diff --git a/src/components/AssetDrawer.vue b/src/components/AssetDrawer.vue index 1615ae5..18511b6 100644 --- a/src/components/AssetDrawer.vue +++ b/src/components/AssetDrawer.vue @@ -10,9 +10,13 @@ Details Books Files + Photos Warranties Tasks Notes + Disposal + Lease + PM
@@ -21,10 +25,22 @@
- + + @@ -39,13 +55,23 @@ - - + + {{ zoneGuidance }} + + + - + @@ -54,6 +80,20 @@
+ +
+
+

Barcode label

+
Uses the barcode value, or the Asset ID when blank.
+
+ + Use Asset ID + Print +
+
+ +
+ + + diff --git a/src/components/BarcodeLabel.vue b/src/components/BarcodeLabel.vue new file mode 100644 index 0000000..fcf0ae6 --- /dev/null +++ b/src/components/BarcodeLabel.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/CategorySettings.vue b/src/components/CategorySettings.vue new file mode 100644 index 0000000..da175fa --- /dev/null +++ b/src/components/CategorySettings.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/src/components/DataTable.vue b/src/components/DataTable.vue new file mode 100644 index 0000000..6049471 --- /dev/null +++ b/src/components/DataTable.vue @@ -0,0 +1,205 @@ + + + diff --git a/src/components/DepartmentSettings.vue b/src/components/DepartmentSettings.vue new file mode 100644 index 0000000..18349e4 --- /dev/null +++ b/src/components/DepartmentSettings.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/src/components/DepreciationZoneSettings.vue b/src/components/DepreciationZoneSettings.vue new file mode 100644 index 0000000..779a132 --- /dev/null +++ b/src/components/DepreciationZoneSettings.vue @@ -0,0 +1,184 @@ + + + diff --git a/src/components/LabelSheet.vue b/src/components/LabelSheet.vue new file mode 100644 index 0000000..f0d20d4 --- /dev/null +++ b/src/components/LabelSheet.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/components/MonacoJsonEditor.vue b/src/components/MonacoJsonEditor.vue new file mode 100644 index 0000000..ccca1e2 --- /dev/null +++ b/src/components/MonacoJsonEditor.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/components/NotificationSettings.vue b/src/components/NotificationSettings.vue new file mode 100644 index 0000000..9b4e4e1 --- /dev/null +++ b/src/components/NotificationSettings.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/components/PartCategorySettings.vue b/src/components/PartCategorySettings.vue new file mode 100644 index 0000000..a9184b4 --- /dev/null +++ b/src/components/PartCategorySettings.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/components/PmCategorySettings.vue b/src/components/PmCategorySettings.vue new file mode 100644 index 0000000..623f879 --- /dev/null +++ b/src/components/PmCategorySettings.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/components/PmCompletionDialog.vue b/src/components/PmCompletionDialog.vue new file mode 100644 index 0000000..d2ebd35 --- /dev/null +++ b/src/components/PmCompletionDialog.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/src/components/PmSettings.vue b/src/components/PmSettings.vue new file mode 100644 index 0000000..fb09c89 --- /dev/null +++ b/src/components/PmSettings.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/components/ServiceNowSettings.vue b/src/components/ServiceNowSettings.vue new file mode 100644 index 0000000..eaefe24 --- /dev/null +++ b/src/components/ServiceNowSettings.vue @@ -0,0 +1,213 @@ + + + diff --git a/src/components/SignaturePad.vue b/src/components/SignaturePad.vue new file mode 100644 index 0000000..56cc591 --- /dev/null +++ b/src/components/SignaturePad.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/components/TopNav.vue b/src/components/TopNav.vue index 38ca438..f6f2aba 100644 --- a/src/components/TopNav.vue +++ b/src/components/TopNav.vue @@ -1,14 +1,16 @@ + + diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue new file mode 100644 index 0000000..bad0c52 --- /dev/null +++ b/src/views/ContactsView.vue @@ -0,0 +1,253 @@ + + + diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index b7150ad..a9c4ffc 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -28,6 +28,34 @@
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryAssetsValue
{{ row.category }}{{ row.count }}{{ currency(row.value) }}
No assets yet.
Total{{ totalAssets }}{{ currency(totalValue) }}
+
@@ -56,6 +84,14 @@ export default { currency: { type: Function, required: true }, dashboard: { type: Object, required: true }, shortDate: { type: Function, required: true } + }, + computed: { + totalAssets() { + return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.count || 0), 0); + }, + totalValue() { + return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.value || 0), 0); + } } }; diff --git a/src/views/PartsView.vue b/src/views/PartsView.vue new file mode 100644 index 0000000..efe42d4 --- /dev/null +++ b/src/views/PartsView.vue @@ -0,0 +1,509 @@ + + + + + diff --git a/src/views/PmView.vue b/src/views/PmView.vue new file mode 100644 index 0000000..75f9b93 --- /dev/null +++ b/src/views/PmView.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/src/views/ReportsView.vue b/src/views/ReportsView.vue index 2a6ae0a..3cf44d4 100644 --- a/src/views/ReportsView.vue +++ b/src/views/ReportsView.vue @@ -1,76 +1,287 @@ diff --git a/src/views/TaxRulesView.vue b/src/views/TaxRulesView.vue new file mode 100644 index 0000000..5d54d60 --- /dev/null +++ b/src/views/TaxRulesView.vue @@ -0,0 +1,254 @@ + + + diff --git a/src/views/TemplatesView.vue b/src/views/TemplatesView.vue index f8ea8cb..21eb8e9 100644 --- a/src/views/TemplatesView.vue +++ b/src/views/TemplatesView.vue @@ -1,7 +1,11 @@ + +