Compare commits

..

53 Commits

Author SHA1 Message Date
grabowski b4338c6814 Link source expense/invoice from account transaction rows
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 35s
Each transaction that was posted from an expense or invoice now shows
an 'Open expense →' or 'Open invoice →' link to the source record's
detail page. The sourceExpenseId/sourceInvoiceId fields were already
loaded — just needed the UI affordance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:10:07 +07:00
grabowski 06ae314b3c Convert project list and detail spent to base currency via FX rate
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 38s
Both the projects list and the project detail stats (totalApproved,
totalPending) now left-join companyAccounts and multiply expense
amounts by fxRateToBase before summing, matching the overview and
budget page fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:03:08 +07:00
grabowski c570019fd8 Convert report amounts to base currency; add expense void action
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 34s
Reports: all three aggregations (byCategory, byProject, byMonth)
left-join companyAccounts and multiply expense amounts by
fxRateToBase before summing, so USD expenses show correctly.

Expenses: new 'voided' status on expenseStatusEnum with voidedAt +
voidReason columns. Void button on the detail page (admin/manager/
accountant) requires a reason, reverses the ledger entry, and writes
an 'expense_voided' audit log entry. Status badge shows
strikethrough red.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:56:03 +07:00
grabowski 8ef2ef7465 Convert per-project spent to base currency via account FX rate
Deploy to LXC / deploy (push) Successful in 2m2s
Validate / validate (push) Successful in 36s
Both the overview and budget page queries now multiply each approved
expense amount by its account's fxRateToBase before summing. A -$434
USD expense on a USD account (rate 34.5) now contributes -14,973 THB
to the total, not -434. Expenses with no account fall back to rate 1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:33:51 +07:00
grabowski ef6ba485d3 Auto-detect expense currency from the selected account
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 34s
On create: if accountId is set, use that account's currency; else fall
back to the company's base currency (not hardcoded THB).
On update: if account changes, also update the expense currency to
match, so the ledger entry posts in the right currency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:24:04 +07:00
grabowski e216a393e4 Use MutationObserver to constrain dynamically-rendered date inputs
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 29s
Previous afterNavigate hook missed inputs inside conditional blocks
that appear after user interaction (e.g. the "+ New Expense" form).
Replaced with a MutationObserver on document.body that catches every
<input type="date"> as it's added to the DOM and sets min/max.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:20:50 +07:00
grabowski 6d0fb30545 Constrain date inputs to 4-digit years (fixes yyyyyy-mm-dd display)
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s
Chrome/Blink renders <input type="date"> with a 6-digit year field
unless min/max attributes restrict the range. Added a root-layout
hook that auto-sets min=1900-01-01, max=2100-12-31 on every date
input on mount and after navigation — no need to edit 19 form files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:16:18 +07:00
grabowski 8376116765 Auto-resolve FX rate on account creation (base=1, else fetch from API)
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 37s
Account creation no longer requires the user to enter an FX rate.
On create:
  - If account currency == company base → fxRateToBase = 1
  - Otherwise → fetchRate(accountCurrency, baseCurrency) from the
    fawazahmed0 FX API (same helper the daily scheduler uses)
  - Fallback to 1 if API call fails
The manual override field is still shown on the edit form for admin
corrections, and the daily scheduler keeps it fresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:57:08 +07:00
grabowski 7367aa9572 Add '+ New Package' button to expense detail packages section
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 35s
Visible whenever the user can manage. Always routes to the package
creation page. The link-existing dropdown still shows when other
packages exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:21:57 +07:00
grabowski 7465b498e0 Move invoice upload + package linking to the expense detail page
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 34s
List page expenses now show a "View details →" link that routes to
the detail page. The detail page gains:
  - Invoice file upload (with Paperless push if configured)
  - Paperless URL link field
  - Link / unlink packages to the expense (many-to-many)

Same actions exist on both pages for convenience, but the detail page
is the primary workspace for managing an expense.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:31:47 +07:00
grabowski 7fba11941f Fix double-counting: available = total (expenses already debit accounts)
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 35s
The previous Remaining Budget card subtracted approved expenses from
the account balance sum — but postExpenseTransaction already posts
negative-amount rows to the ledger, so the balance sum already reflects
them. Replaced with:
  - Available Cash (= sum of account balances)
  - Allocated (with % progress bar)
  - Unallocated (cash not assigned to any project)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:22:16 +07:00
grabowski 94e38aca9c Redesign overview: income vs expenses split with net-position card
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 36s
Hero row is now a two-column green/red split showing Income and
Expenses side-by-side, with a full-width Net Position card below that
colours green or red based on the sign. Budget KPIs (Remaining,
Total, Allocated) moved to a secondary row underneath.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:11:13 +07:00
grabowski 00b8b239e0 Add expense detail page with edit (audit-logged) and clickable rows on projects
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s
New expense detail at /companies/[id]/expenses/[expenseId] with full
info, edit form for admin/manager/accountant, and audit log entry on
every edit (`expense_updated`). Project view expense rows are now
clickable and navigate to the detail page. Ledger re-posts
automatically if an approved expense's amount or account changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:05:17 +07:00
grabowski 26945285eb Show base-currency equivalent next to foreign-currency account balances
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 34s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:55:37 +07:00
grabowski 2540a7603e Add Sales tab to primary company nav (admin/manager/accountant)
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:49:20 +07:00
grabowski 0795d78bdf Add confirmed-sales income to budget and overview
Budget page load now computes per-project income (net of withholding)
from confirmed sales. Overview has a full-width Income KPI showing
total confirmed net revenue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:48:06 +07:00
grabowski 1c15cbc36e Add sales CRUD with line items, taxes, withholding, and package linking
New routes: /companies/[id]/sales (list + create) and [saleId] (detail).
Per-line tax rate, single withholding % on sale. Computed totals:
subtotal, tax, gross, withholding, net receivable. Status flow:
draft → confirmed → voided. Packages linked via sale_packages junction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:46:05 +07:00
grabowski f56d4caec8 Link multiple packages to expenses via junction table
Added linkPackage/unlinkPackage actions and a collapsible package
checklist per expense. Linked packages display as clickable cyan chips
on the expense row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:56:39 +07:00
grabowski 34aab722b4 Add expense invoice upload with Paperless push + paperless URL link
Expenses now show Pending Invoice badge when no file/link attached.
Upload action saves file via existing uploads helper, optionally
pushes to Paperless-ngx if PAPERLESS_URL + PAPERLESS_TOKEN env set.
Download endpoint serves attached invoice with attachment disposition.
Paperless URL link provides a zero-integration alternative.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:54:13 +07:00
grabowski bbfab9faaa Add expense invoice fields, sales tables, and Paperless env vars
Expenses now have invoiceFileUrl, invoiceFileName, paperlessUrl,
paperlessDocumentId for supplier invoice attachment.

New expense_packages junction links expenses to multiple packages.

New sales + sale_line_items + sale_packages tables for income tracking
with per-line tax rate and per-sale withholding rate.

Added saleStatusEnum and 4 audit events: expense_invoice_uploaded,
sale_created, sale_confirmed, sale_voided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:51:18 +07:00
grabowski 84c8beca15 Auto-refresh FX rates daily from fawazahmed0/exchange-api
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s
Scheduler checks every 15min; if 24h since last FX refresh, fetches
rates for all foreign-currency accounts and updates fxRateToBase.
Uses CDN primary + Cloudflare fallback with 10s timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:36:17 +07:00
grabowski 34b1524d3a Add FX rate per account, convert foreign balances to base currency in budget
Deploy to LXC / deploy (push) Successful in 1m55s
Validate / validate (push) Successful in 34s
Accounts now have fxRateToBase (default 1.0). The budget total query
multiplies each transaction by the account's rate, so a USD account
with rate 34.5 contributes correctly to the THB-denominated budget.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:24:00 +07:00
grabowski bc0699a992 Derive total budget from account balances instead of manual field
Deploy to LXC / deploy (push) Successful in 1m55s
Validate / validate (push) Successful in 38s
Total budget is now sum(account transaction amounts) across all
non-deleted accounts. Removed the manual 'Add Budget' action and form.
Budget page is now read-only for the total; allocations still work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:16:05 +07:00
grabowski 283f0d4dd1 Add invoice linking on expenses: optional FK, dropdown on add form, clickable chip
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:46:20 +07:00
grabowski 0710d63cc1 Add inline expense form on expenses tab with company-wide (General) option
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:26:18 +07:00
grabowski 5ff4f07ff4 Add invoice void with ledger reversal, required reason, and voided badge
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:18:35 +07:00
grabowski 0906a448b3 Add procedure instance detail with step completion and auto-complete
Deploy to LXC / deploy (push) Successful in 1m55s
Validate / validate (push) Successful in 33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:58:10 +07:00
grabowski 65cee9855c Add procedures templates, step management, and nav tab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:51:29 +07:00
grabowski f1dd6877f6 Add procedures schema: templates, steps, instances, 7 audit events
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:47:36 +07:00
grabowski 8a23a849da Fix CSP: allow unsafe-inline scripts for SvelteKit hydration
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:32:12 +07:00
grabowski b4eda2d553 Fix security audit findings: auth scoping, OIDC hardening, CSP, file download
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s
C3: Budget allocation now verifies project belongs to company
M4: Expense approve/reject scoped by company via project join
H2: OIDC cookies get secure flag on HTTPS
H3: OIDC auto-link only when email_verified by provider
H4: Content-Security-Policy + X-Content-Type-Options in hooks
M7: SSRF favicon redirect depth capped at 3
M2: File downloads use attachment disposition (not inline)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:18:28 +07:00
grabowski dbfd229ba8 Link service accounts to recurring bills with dropdown and display chip
Deploy to LXC / deploy (push) Successful in 2m1s
Validate / validate (push) Successful in 35s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:58:43 +07:00
grabowski 1ce614186d Add service accounts page with CRUD, filter pills, and nav tab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:52:33 +07:00
grabowski 493ffa4097 Add service accounts schema, enum, audit events, recurringBills FK
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:49:16 +07:00
grabowski a1fffebbf6 Add CI/CD deploy setup doc
Validate / validate (push) Successful in 31s
Deploy to LXC / deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:52:46 +07:00
grabowski 1fed8ee920 Add Gitea Actions deploy workflow
Mirrors the buildfor_life_repair workflow: SSH into LXC, reset working
tree, pull, npm ci, build, db:push, restart the buildfor-life-budget
systemd service, health-check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:49:33 +07:00
grabowski 639c261995 Regenerate deployment architecture diagram via beautiful-mermaid
Validate / validate (push) Successful in 30s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:42:08 +07:00
grabowski 5451a591ad Add deployment doc: Caddy + Tor + Yggdrasil + NetBird + external TLS proxy
Validate / validate (push) Successful in 32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:36:49 +07:00
grabowski fef69b653c Add inline rename/edit on project detail page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:09:52 +07:00
grabowski 57f3d42133 Redesign company overview: 4 compact KPIs, side-by-side projects + recent expenses
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:06:19 +07:00
grabowski f51e156539 Restructure company nav: 8 primary tabs + HR/Ops/Admin dropdowns with active highlight
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:59:25 +07:00
grabowski 03526ff3b9 Restore pointer cursor on buttons (Tailwind v4 Preflight reset)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:35:46 +07:00
grabowski b43924f527 Add recurring bills UI with full CRUD, filters, overdue highlight, amount override
Validate / validate (push) Successful in 33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:23:53 +07:00
grabowski b611207d25 Add recurring bills poster, scheduler boot, and manual run stub
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:17:38 +07:00
grabowski bd87cd09f5 Add recurring bills schema and cycle math helper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:13:38 +07:00
grabowski 70bb5954a0 Make entire account card clickable to open detail
Stretched-link pattern: absolute-positioned overlay anchor covers the
card; action controls (edit/archive/delete + inline forms) get
`relative z-10` so they float above and stay clickable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:25:03 +07:00
grabowski c1a575241f Fix zero balance on accounts list page
The correlated subquery in the SELECT was returning 0 for every row.
Replaced with a separate grouped-sum query joined in JS — same data, more
reliable SQL generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:17:33 +07:00
grabowski 77c5d72e43 Reconciliation link, account CSVs in export, drop legacy bank/card tables
Validate / validate (push) Successful in 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:06:53 +07:00
grabowski 0d4fdb6fd7 Add account detail page with transaction history, filters, and CSV export
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:58:44 +07:00
grabowski 3a095851e9 Auto-post expenses and invoice payments to accounts ledger
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:04:15 +07:00
grabowski d75fe6ed95 Add opening balance, manual transactions, and cross-currency transfers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:54:10 +07:00
grabowski aea6dbc06e Add accounts list page with CRUD, Accounts nav tab, profile deprecation banner
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:50:40 +07:00
grabowski 57e72e5b6c Add companyAccounts schema, ledger helper, legacy migration script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:34:57 +07:00
64 changed files with 11030 additions and 954 deletions
+4
View File
@@ -21,3 +21,7 @@ BODY_SIZE_LIMIT=26214400
# Company Links favicon fetching (set false to disable outbound fetches in offline dev) # Company Links favicon fetching (set false to disable outbound fetches in offline dev)
FAVICON_FETCH_ENABLED=true FAVICON_FETCH_ENABLED=true
# Paperless-ngx integration (optional — leave blank to disable)
PAPERLESS_URL=
PAPERLESS_TOKEN=
+66
View File
@@ -0,0 +1,66 @@
name: Deploy to LXC
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
script: |
set -e
APP_DIR="${{ secrets.DEPLOY_PATH || '/opt/buildfor-life-budget' }}"
# Set up deploy key for private repo access
mkdir -p ~/.ssh
echo "${{ secrets.REPO_DEPLOY_KEY }}" > ~/.ssh/repo_deploy_key
chmod 600 ~/.ssh/repo_deploy_key
# Configure SSH to use deploy key for git.b4l.co.th
if ! grep -q "git.b4l.co.th" ~/.ssh/config 2>/dev/null; then
cat >> ~/.ssh/config <<EOF
Host git.b4l.co.th
HostName git.b4l.co.th
IdentityFile ~/.ssh/repo_deploy_key
StrictHostKeyChecking accept-new
EOF
chmod 600 ~/.ssh/config
fi
# Clone if first deploy, otherwise pull
if [ ! -d "$APP_DIR" ]; then
echo "==> First deploy, cloning..."
git clone git@git.b4l.co.th:B4L/buildfor_life_budget.git "$APP_DIR"
cd "$APP_DIR"
else
cd "$APP_DIR"
echo "==> Resetting local changes..."
git checkout -- .
echo "==> Pulling latest code..."
git pull origin main
fi
echo "==> Installing dependencies..."
npm ci
echo "==> Building..."
npm run build
echo "==> Running migrations..."
npm run db:push
echo "==> Restarting service..."
sudo systemctl restart buildfor-life-budget
echo "==> Waiting for startup..."
sleep 2
systemctl is-active --quiet buildfor-life-budget && echo "Deploy successful!" || (echo "Service failed to start!" && exit 1)
+129
View File
@@ -0,0 +1,129 @@
# CI/CD Deploy Setup
Auto-deploys to your LXC server on every push to `main` via `.gitea/workflows/deploy.yml`.
## 1. Server preparation
On the LXC server, allow the deploy user to restart the service without a password:
```bash
# As root on the LXC
echo "budget ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart buildfor-life-budget, /usr/bin/systemctl status buildfor-life-budget" > /etc/sudoers.d/budget-deploy
chmod 440 /etc/sudoers.d/budget-deploy
```
Make sure the repo is cloned and the app works manually first (see [`docs/deployment.md`](./deployment.md)).
## 2. Generate SSH keys
You need **two** SSH key pairs:
### a) Deploy key (CI runner → LXC server)
This lets the CI runner SSH into your server:
```bash
ssh-keygen -t ed25519 -C "ci-to-server" -f ci_deploy_key -N ""
```
Copy the **public** key to the server:
```bash
ssh-copy-id -i ci_deploy_key.pub budget@your-lxc-ip
```
### b) Repo deploy key (LXC server → private Gitea repo)
This lets the server `git pull` from the private repo:
```bash
ssh-keygen -t ed25519 -C "server-to-repo" -f repo_deploy_key -N ""
```
Add the **public** key in Gitea: repo → **Settings****Deploy Keys****Add Deploy Key**, paste `repo_deploy_key.pub`.
## 3. Add secrets in Gitea
Go to your repo on git.b4l.co.th → **Settings****Actions****Secrets**, and add:
| Secret | Value |
|--------|-------|
| `DEPLOY_HOST` | LXC server IP (e.g. `192.168.10.5`) |
| `DEPLOY_USER` | SSH user (e.g. `budget`) |
| `DEPLOY_KEY` | Contents of `ci_deploy_key` (private key — CI runner → server) |
| `REPO_DEPLOY_KEY` | Contents of `repo_deploy_key` (private key — server → Gitea repo) |
| `DEPLOY_PORT` | SSH port (optional, defaults to 22) |
| `DEPLOY_PATH` | App directory (optional, defaults to `/opt/buildfor-life-budget`) |
### First clone on the server
The workflow will clone the repo automatically on the first run if `DEPLOY_PATH` doesn't exist. If you prefer to clone manually:
```bash
# On the server as the budget user, set up the deploy key first
mkdir -p ~/.ssh
cp repo_deploy_key ~/.ssh/repo_deploy_key
chmod 600 ~/.ssh/repo_deploy_key
cat >> ~/.ssh/config <<EOF
Host git.b4l.co.th
HostName git.b4l.co.th
IdentityFile ~/.ssh/repo_deploy_key
StrictHostKeyChecking accept-new
EOF
sudo mkdir -p /opt/buildfor-life-budget
sudo chown budget:budget /opt/buildfor-life-budget
git clone git@git.b4l.co.th:B4L/buildfor_life_budget.git /opt/buildfor-life-budget
```
Remember to create `/opt/buildfor-life-budget/.env` (see [`docs/deployment.md`](./deployment.md#1-node-app)) before the first deploy — the service won't start without it.
## 4. Enable Actions in Gitea
Make sure Gitea Actions is enabled on your instance:
```ini
# In app.ini (Gitea config)
[actions]
ENABLED = true
```
You also need a runner registered. If you don't have one, install the Gitea runner on the Gitea host or another machine:
```bash
# Download the runner
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64
chmod +x act_runner-linux-amd64
# Register with your Gitea instance
./act_runner-linux-amd64 register --instance https://git.b4l.co.th --token <your-runner-token>
# Start
./act_runner-linux-amd64 daemon
```
## 5. Test
Push any change to `main` and check the Actions tab in Gitea for the deploy log.
## What the workflow does
1. SSHs into the LXC server
2. Installs the repo deploy key for private repo access
3. `git pull` the latest code (or `git clone` on first deploy)
4. `npm ci` to install exact lockfile deps
5. `npm run build` to compile SvelteKit (adapter-node)
6. `npm run db:push` to apply any schema changes to PostgreSQL
7. `sudo systemctl restart buildfor-life-budget` to restart the service
8. Verifies the service started successfully via `systemctl is-active`
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| `Permission denied (publickey)` from CI | `DEPLOY_KEY` mismatches what's in `~/.ssh/authorized_keys` for the deploy user. Re-paste exactly (include `-----BEGIN/END-----` lines). |
| `sudo: a password is required` | Sudoers file not installed or has a typo. Check `/etc/sudoers.d/budget-deploy` with `sudo visudo -cf /etc/sudoers.d/budget-deploy`. |
| `git@git.b4l.co.th: Permission denied` on the LXC | `REPO_DEPLOY_KEY` not registered as a Deploy Key on the repo, or the LXC's `~/.ssh/repo_deploy_key` has wrong permissions (must be `600`). |
| `npm ci` fails with "lock file version mismatch" | Node version on the LXC doesn't match what produced `package-lock.json`. Use the same Node major version as local dev (check `.nvmrc` if present, else `node --version` on both sides). |
| `npm run db:push` hangs on interactive prompt | Destructive schema change (column/table drop). Either revert the change or run it manually with `npx drizzle-kit push --force` after confirming data loss is acceptable. |
| Service restarts but then exits | Missing or invalid `.env` — check `journalctl -u buildfor-life-budget -n 50`. `ORIGIN`, `DATABASE_URL`, and `UPLOADS_DIR` are mandatory. |
+274
View File
@@ -0,0 +1,274 @@
# Deployment
Self-hosted on a single Linux box (Proxmox LXC / VM / bare metal). Four layers, innermost to outermost:
1. **Node app** (`adapter-node`) — listens on `127.0.0.1:3000`
2. **Caddy** (internal) — listens on LAN IP, Yggdrasil IPv6, NetBird IP, and `127.0.0.1` (for Tor). HTTP only — no TLS.
3. **Tor / Yggdrasil / NetBird** — overlay transports that connect peers to Caddy
4. **External reverse proxy** (off-box, e.g. at the network edge) — does TLS termination for the public hostname; forwards to LAN IP:8080
```
┌────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐ ┌────────────────┐
│ │ │ │ │ │ │ │
│ Internet user ├────►│ External TLS proxy ├────►│ Internal Caddy :8080 ├────►│ Node app :3000 │
│ │ │ │ │ │ │ │
└────────────────┘ └─────────────────────┘ └──────────────────────┘ └────────────────┘
┌────────────────┐ ┌─────────────────────┐ │
│ │ │ │ │
│ Tor peer ├────►│ tor daemon (.onion) ├─────────────────┤
│ │ │ │ │
└────────────────┘ └─────────────────────┘ │
┌────────────────┐ ┌─────────────────────┐ │
│ │ │ │ │
│ Yggdrasil peer ├────►│ Yggdrasil IPv6 ├─────────────────┤
│ │ │ │ │
└────────────────┘ └─────────────────────┘ │
┌────────────────┐ ┌─────────────────────┐ │
│ │ │ │ │
│ NetBird peer ├────►│ NetBird IP ├─────────────────┘
│ │ │ │
└────────────────┘ └─────────────────────┘
```
_Diagram rendered from a Mermaid source via [beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid)._
All non-TLS: the external proxy terminates TLS, Tor encrypts its own path, Yggdrasil/NetBird are encrypted overlays. Caddy and the node app speak plain HTTP.
---
## 1. Node app
`systemd` unit at `/etc/systemd/system/buildfor-life-budget.service`:
```ini
[Unit]
Description=B4L Budget (SvelteKit adapter-node)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=budget
WorkingDirectory=/opt/buildfor-life-budget
EnvironmentFile=/opt/buildfor-life-budget/.env
ExecStart=/usr/bin/node build/index.js
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/buildfor-life-budget/uploads
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
`/opt/buildfor-life-budget/.env` must contain:
```
PORT=3000
HOST=127.0.0.1
ORIGIN=https://budget.example.com
PROTOCOL_HEADER=x-forwarded-proto
HOST_HEADER=x-forwarded-host
BODY_SIZE_LIMIT=26214400
DATABASE_URL=postgresql://budget_app:...@127.0.0.1:5432/buildfor_life_budget
UPLOADS_DIR=/var/lib/buildfor-life-budget/uploads
FAVICON_FETCH_ENABLED=true
```
`ORIGIN` stays the public HTTPS URL — SvelteKit uses this for CSRF and cookie checks. If you hit the app via Tor/Yggdrasil/NetBird with a different Host header, either:
- set `csrf.checkOrigin: false` in `svelte.config.js`, or
- access each transport via a Host that matches `ORIGIN` (point DNS / hosts-file entries at the transport IPs).
The second approach is cleaner — all transports see the same `Host: budget.example.com` and SvelteKit doesn't care which IP the connection came from.
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now buildfor-life-budget.service
```
---
## 2. Internal Caddy
Package: `sudo apt install caddy` (or use the official repo for latest).
`/etc/caddy/Caddyfile`:
```caddyfile
{
# Off — TLS is handled by the upstream external proxy.
auto_https off
admin off
}
# Listen on every transport-facing IP. Same backend.
# LAN IP · Yggdrasil IPv6 · NetBird IP · localhost (for Tor)
http://192.168.10.5:8080,
http://[200:abcd:1234:...]:8080,
http://100.64.0.5:8080,
http://127.0.0.1:8080 {
# Forward real client info so SvelteKit's PROTOCOL_HEADER/HOST_HEADER work.
# The external TLS proxy should set X-Forwarded-Proto=https; default to http for
# the overlays (Tor/Ygg/NetBird) which do their own encryption.
@fromTls header X-Forwarded-Proto https
header @fromTls X-Forwarded-Proto https
reverse_proxy 127.0.0.1:3000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
encode zstd gzip
request_body {
max_size 26MB
}
log {
output file /var/log/caddy/budget.log {
roll_size 50mb
roll_keep 10
}
format console
}
}
```
Replace the three IPs with the real values:
- **LAN IP** — `ip -4 -br addr show | grep -v lo` on the host.
- **Yggdrasil IPv6** — `yggdrasilctl getSelf | grep -i address` (starts with `200:` or `201:`).
- **NetBird IP** — `netbird status | grep 'NetBird IP'` (usually `100.64.0.0/10` range).
```bash
sudo systemctl enable --now caddy
sudo systemctl reload caddy # after edits
```
**Firewall** — the box should accept `:8080` inbound only on those four interfaces. With `ufw`:
```bash
sudo ufw allow in on eth0 from 192.168.10.0/24 to any port 8080 proto tcp # LAN
sudo ufw allow in on tun-ygg to any port 8080 proto tcp # Yggdrasil
sudo ufw allow in on wt0 to any port 8080 proto tcp # NetBird (interface name varies)
# Tor connects via 127.0.0.1 — no firewall rule needed
sudo ufw deny 8080/tcp # block the rest
```
---
## 3. Tor onion service
Package: `sudo apt install tor`. Append to `/etc/tor/torrc`:
```
HiddenServiceDir /var/lib/tor/budget/
HiddenServicePort 80 127.0.0.1:8080
HiddenServiceVersion 3
```
```bash
sudo systemctl restart tor
sudo cat /var/lib/tor/budget/hostname
```
The printed `<random>.onion` is the address. Protect the contents of `/var/lib/tor/budget/` — anyone with the private key can impersonate this service. Back them up offline.
---
## 4. Yggdrasil
Package: official `.deb` from [yggdrasil-network.github.io](https://yggdrasil-network.github.io/installation-linux.html).
After install, Yggdrasil picks a stable IPv6 address from the `200::/7` range. To see it:
```bash
sudo yggdrasilctl getSelf
```
No Yggdrasil-side config needed for this app — peers who have your Yggdrasil address can reach `http://[200:...]:8080` once they're on the network.
Add trusted peers in `/etc/yggdrasil.conf`'s `Peers` block, or rely on public peers listed at [publicpeers.neilalexander.dev](https://publicpeers.neilalexander.dev/).
---
## 5. NetBird
Package: official from `pkgs.netbird.io`. Set up:
```bash
sudo netbird up --setup-key <your-netbird-setup-key>
sudo netbird status
```
Note the IP under `NetBird IP` — that's what Caddy binds to. NetBird manages WireGuard tunnels; any peer in the same NetBird network can hit that IP.
Keep the daemon running via the included `netbird.service`.
---
## 6. External reverse proxy (TLS termination)
This sits on your network edge, not on the app box. Example Caddyfile on the edge:
```caddyfile
budget.example.com {
reverse_proxy 192.168.10.5:8080 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host}
header_up Host {host}
}
}
```
Caddy auto-provisions a Let's Encrypt cert. If you use nginx/Traefik instead, forward the same headers so the internal Caddy and the SvelteKit app see the correct `https` protocol and public host.
---
## Update / redeploy
On the app box:
```bash
cd /opt/buildfor-life-budget
sudo -u budget git pull
sudo -u budget npm ci --omit=dev=false
sudo -u budget npm run build
sudo -u budget npm run db:push # only if schema changed
sudo systemctl restart buildfor-life-budget
```
Caddy config does not need reloading unless IPs change.
---
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| External URL 502 from the edge proxy | Internal Caddy not up, or LAN firewall blocking `:8080` from the edge. |
| `.onion` loads but forms return 403 | CSRF origin mismatch — either configure a hosts-file entry for the onion pointing to `budget.example.com`, or disable `csrf.checkOrigin` in `svelte.config.js`. |
| Yggdrasil peer can't reach the address | Both sides must be on Yggdrasil; check `yggdrasilctl getPeers` on both ends. |
| NetBird IP changes after reconnect | Setup key vs reusable key — use a fixed/dedicated peer config so the IP stays stable. |
| SvelteKit sees HTTP for TLS-terminated traffic | External proxy not sending `X-Forwarded-Proto: https` header. |
| Scheduler doesn't tick | Systemd unit restart killed the in-process interval; check logs for `[scheduler] recurring bills started`. |
---
## Security notes
- The internal Caddy MUST NOT be reachable from the internet directly. The four-IP bind list keeps it on private transports only; verify with `ss -tlnp | grep :8080` that no `0.0.0.0:8080` shows up.
- `.onion` key files and NetBird setup keys are secrets — back them up to an offline medium, not to the repo.
- PostgreSQL should bind to `127.0.0.1` only (`listen_addresses = 'localhost'` in `postgresql.conf`).
- File uploads under `UPLOADS_DIR` are served via auth-checked endpoints only — never expose that directory statically.
+21
View File
@@ -1,3 +1,24 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
/* Tailwind v4's Preflight removes cursor:pointer from buttons — restore the expected UX. */
@layer base {
button:not(:disabled),
[role='button']:not(:disabled),
summary,
label[for],
select:not(:disabled),
input[type='submit']:not(:disabled),
input[type='reset']:not(:disabled),
input[type='button']:not(:disabled),
input[type='checkbox']:not(:disabled),
input[type='radio']:not(:disabled) {
cursor: pointer;
}
button:disabled,
[role='button'][aria-disabled='true'] {
cursor: not-allowed;
}
}
+12 -1
View File
@@ -1,5 +1,8 @@
import { redirect, type Handle } from '@sveltejs/kit'; import { redirect, type Handle } from '@sveltejs/kit';
import { validateSession, setSessionCookie } from '$lib/server/auth/index.js'; import { validateSession, setSessionCookie } from '$lib/server/auth/index.js';
import { startScheduler } from '$lib/server/recurring-bills/scheduler.js';
startScheduler();
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
// Redirect implicit /favicon.ico requests to our SVG to avoid 404 noise // Redirect implicit /favicon.ico requests to our SVG to avoid 404 noise
@@ -27,5 +30,13 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.session = null; event.locals.session = null;
} }
return resolve(event); const response = await resolve(event);
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'"
);
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
}; };
+354
View File
@@ -0,0 +1,354 @@
import { db } from '$lib/server/db/index.js';
import {
companyAccounts,
companyAccountTransactions,
expenses,
invoices,
externalTransactions
} from '$lib/server/db/schema.js';
import { and, eq, sql } from 'drizzle-orm';
/**
* Drizzle's tx inside db.transaction() has the same methods as db.
* Use `any` to avoid importing the internal PgTransaction generic type.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Dbx = typeof db | any;
export type CompanyAccountTxnType =
| 'opening_balance'
| 'expense'
| 'invoice_payment'
| 'transfer_in'
| 'transfer_out'
| 'deposit'
| 'adjustment'
| 'reconciliation';
export interface PostTxnInput {
accountId: string;
companyId: string;
type: CompanyAccountTxnType;
amount: string | number; // signed decimal; positive = credit, negative = debit
currency: string;
occurredAt: Date;
description?: string | null;
reference?: string | null;
counterpartyAccountId?: string | null;
sourceExpenseId?: string | null;
sourceInvoiceId?: string | null;
sourceExternalTransactionId?: string | null;
fxRate?: string | number | null;
fxAmount?: string | number | null;
createdBy?: string | null;
}
function toDecimalString(v: string | number | null | undefined): string | null {
if (v === null || v === undefined) return null;
return typeof v === 'number' ? v.toString() : v;
}
export async function postTransaction(dbx: Dbx, input: PostTxnInput): Promise<{ id: string }> {
const amountStr = toDecimalString(input.amount);
if (amountStr === null) throw new Error('postTransaction: amount is required');
const [row] = await dbx
.insert(companyAccountTransactions)
.values({
accountId: input.accountId,
companyId: input.companyId,
type: input.type,
amount: amountStr,
currency: input.currency,
occurredAt: input.occurredAt,
description: input.description ?? null,
reference: input.reference ?? null,
counterpartyAccountId: input.counterpartyAccountId ?? null,
sourceExpenseId: input.sourceExpenseId ?? null,
sourceInvoiceId: input.sourceInvoiceId ?? null,
sourceExternalTransactionId: input.sourceExternalTransactionId ?? null,
fxRate: toDecimalString(input.fxRate),
fxAmount: toDecimalString(input.fxAmount),
createdBy: input.createdBy ?? null
})
.returning({ id: companyAccountTransactions.id });
return row;
}
export interface PostTransferInput {
fromAccountId: string;
toAccountId: string;
companyId: string;
amount: string | number; // always positive — the sending side (debit)
occurredAt: Date;
description?: string | null;
reference?: string | null;
/** Cross-currency: set exactly one of fxRate OR destinationAmount. */
fxRate?: string | number | null;
destinationAmount?: string | number | null;
createdBy?: string | null;
}
export async function postTransfer(
input: PostTransferInput
): Promise<{ fromTxnId: string; toTxnId: string }> {
if (input.fromAccountId === input.toAccountId) {
throw new Error('postTransfer: fromAccountId and toAccountId must differ');
}
const absAmount = Number(input.amount);
if (!Number.isFinite(absAmount) || absAmount <= 0) {
throw new Error('postTransfer: amount must be a positive number');
}
return await db.transaction(async (tx: Dbx) => {
const accts = await tx
.select({
id: companyAccounts.id,
companyId: companyAccounts.companyId,
currency: companyAccounts.currency
})
.from(companyAccounts)
.where(
sql`${companyAccounts.id} IN (${input.fromAccountId}, ${input.toAccountId}) AND ${companyAccounts.companyId} = ${input.companyId} AND ${companyAccounts.deletedAt} IS NULL`
);
const fromAcct = accts.find((a: { id: string }) => a.id === input.fromAccountId);
const toAcct = accts.find((a: { id: string }) => a.id === input.toAccountId);
if (!fromAcct || !toAcct) throw new Error('postTransfer: account not found or mismatched company');
const sameCurrency = fromAcct.currency === toAcct.currency;
let fxRateStr: string | null = null;
let destAmount: number;
if (sameCurrency) {
destAmount = absAmount;
} else if (input.destinationAmount != null && input.destinationAmount !== '') {
destAmount = Number(input.destinationAmount);
if (!Number.isFinite(destAmount) || destAmount <= 0) {
throw new Error('postTransfer: destinationAmount must be positive');
}
fxRateStr = (destAmount / absAmount).toFixed(8);
} else if (input.fxRate != null && input.fxRate !== '') {
const rate = Number(input.fxRate);
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error('postTransfer: fxRate must be positive');
}
fxRateStr = rate.toFixed(8);
destAmount = +(absAmount * rate).toFixed(2);
} else {
throw new Error(
'postTransfer: cross-currency transfer requires fxRate or destinationAmount'
);
}
const fxAmountStr = sameCurrency ? null : destAmount.toFixed(2);
const fromTxn = await postTransaction(tx, {
accountId: input.fromAccountId,
companyId: input.companyId,
type: 'transfer_out',
amount: (-absAmount).toFixed(2),
currency: fromAcct.currency,
occurredAt: input.occurredAt,
description: input.description ?? null,
reference: input.reference ?? null,
counterpartyAccountId: input.toAccountId,
fxRate: fxRateStr,
fxAmount: fxAmountStr,
createdBy: input.createdBy ?? null
});
const toTxn = await postTransaction(tx, {
accountId: input.toAccountId,
companyId: input.companyId,
type: 'transfer_in',
amount: destAmount.toFixed(2),
currency: toAcct.currency,
occurredAt: input.occurredAt,
description: input.description ?? null,
reference: input.reference ?? null,
counterpartyAccountId: input.fromAccountId,
fxRate: fxRateStr,
fxAmount: fxAmountStr,
createdBy: input.createdBy ?? null
});
return { fromTxnId: fromTxn.id, toTxnId: toTxn.id };
});
}
export async function getBalance(accountId: string): Promise<{ balance: string; currency: string }> {
const [acct] = await db
.select({ currency: companyAccounts.currency })
.from(companyAccounts)
.where(eq(companyAccounts.id, accountId))
.limit(1);
if (!acct) throw new Error(`getBalance: account ${accountId} not found`);
const [row] = await db
.select({
total: sql<string>`coalesce(sum(${companyAccountTransactions.amount}), '0')::text`
})
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.accountId, accountId));
return { balance: row?.total ?? '0', currency: acct.currency };
}
export async function postExpenseTransaction(
expenseId: string,
accountId: string,
userId: string,
dbx?: Dbx
): Promise<void> {
const dbOrTx = dbx ?? db;
const [exp] = await dbOrTx
.select({
id: expenses.id,
amount: expenses.amount,
currency: expenses.currency,
expenseDate: expenses.expenseDate,
title: expenses.title
})
.from(expenses)
.where(eq(expenses.id, expenseId))
.limit(1);
if (!exp) throw new Error(`postExpenseTransaction: expense ${expenseId} not found`);
const [acct] = await dbOrTx
.select({ companyId: companyAccounts.companyId, currency: companyAccounts.currency })
.from(companyAccounts)
.where(eq(companyAccounts.id, accountId))
.limit(1);
if (!acct) throw new Error(`postExpenseTransaction: account ${accountId} not found`);
// Idempotent: replace any prior post for this expense.
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExpenseId, expenseId));
await postTransaction(dbOrTx, {
accountId,
companyId: acct.companyId,
type: 'expense',
amount: (-Number(exp.amount)).toFixed(2),
currency: exp.currency,
occurredAt: new Date(exp.expenseDate),
description: exp.title,
sourceExpenseId: expenseId,
createdBy: userId
});
}
export async function removeExpenseTransaction(expenseId: string, dbx?: Dbx): Promise<void> {
const dbOrTx = dbx ?? db;
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExpenseId, expenseId));
}
export async function postInvoicePaymentTransaction(
invoiceId: string,
paymentAccountId: string,
userId: string,
dbx?: Dbx
): Promise<void> {
const dbOrTx = dbx ?? db;
const [inv] = await dbOrTx
.select({
id: invoices.id,
total: invoices.total,
currency: invoices.currency,
issueDate: invoices.issueDate,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction
})
.from(invoices)
.where(eq(invoices.id, invoiceId))
.limit(1);
if (!inv) throw new Error(`postInvoicePaymentTransaction: invoice ${invoiceId} not found`);
const [acct] = await dbOrTx
.select({ companyId: companyAccounts.companyId, currency: companyAccounts.currency })
.from(companyAccounts)
.where(eq(companyAccounts.id, paymentAccountId))
.limit(1);
if (!acct) throw new Error(`postInvoicePaymentTransaction: account ${paymentAccountId} not found`);
// outgoing = we billed a customer → cash in (credit).
// incoming = we owe a supplier → cash out (debit).
const sign = inv.direction === 'outgoing' ? 1 : -1;
const signedAmount = sign * Number(inv.total);
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId));
await postTransaction(dbOrTx, {
accountId: paymentAccountId,
companyId: acct.companyId,
type: 'invoice_payment',
amount: signedAmount.toFixed(2),
currency: inv.currency,
occurredAt: new Date(inv.issueDate),
description: `Invoice ${inv.invoiceNumber}`,
sourceInvoiceId: invoiceId,
createdBy: userId
});
}
export async function removeInvoicePaymentTransaction(
invoiceId: string,
dbx?: Dbx
): Promise<void> {
const dbOrTx = dbx ?? db;
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId));
}
export async function postReconciliationTransaction(
externalTransactionId: string,
accountId: string,
companyId: string,
userId: string,
dbx?: Dbx
): Promise<void> {
const dbOrTx = dbx ?? db;
const [ext] = await dbOrTx
.select({
id: externalTransactions.id,
amount: externalTransactions.amount,
currency: externalTransactions.currency,
direction: externalTransactions.direction,
occurredAt: externalTransactions.occurredAt,
description: externalTransactions.description,
counterparty: externalTransactions.counterparty
})
.from(externalTransactions)
.where(
and(
eq(externalTransactions.id, externalTransactionId),
eq(externalTransactions.companyId, companyId)
)
)
.limit(1);
if (!ext) throw new Error(`postReconciliationTransaction: external txn ${externalTransactionId} not found`);
const signedAmount = ext.direction === 'credit' ? Number(ext.amount) : -Number(ext.amount);
await dbOrTx
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExternalTransactionId, externalTransactionId));
await postTransaction(dbOrTx, {
accountId,
companyId,
type: 'reconciliation',
amount: signedAmount.toFixed(2),
currency: ext.currency,
occurredAt: ext.occurredAt,
description: ext.description ?? ext.counterparty ?? 'Bank reconciliation',
sourceExternalTransactionId: externalTransactionId,
createdBy: userId
});
}
+1 -1
View File
@@ -117,7 +117,7 @@ export async function exchangeCode(
export async function getUserInfo( export async function getUserInfo(
accessToken: string accessToken: string
): Promise<{ sub: string; email: string; name?: string }> { ): Promise<{ sub: string; email: string; name?: string; email_verified?: boolean }> {
const config = await getOIDCConfig(); const config = await getOIDCConfig();
const res = await fetch(config.userinfoEndpoint, { const res = await fetch(config.userinfoEndpoint, {
+419 -26
View File
@@ -18,7 +18,7 @@ import {
// ── Enums ────────────────────────────────────────────── // ── Enums ──────────────────────────────────────────────
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']); export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']);
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']); export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected', 'voided']);
// ── Users ────────────────────────────────────────────── // ── Users ──────────────────────────────────────────────
@@ -127,6 +127,12 @@ export const expenses = pgTable(
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }), categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references((): any => parties.id, { onDelete: 'set null' }), partyId: uuid('party_id').references((): any => parties.id, { onDelete: 'set null' }),
accountId: uuid('account_id').references((): any => companyAccounts.id, {
onDelete: 'set null'
}),
invoiceId: uuid('invoice_id').references((): any => invoices.id, {
onDelete: 'set null'
}),
submittedBy: text('submitted_by') submittedBy: text('submitted_by')
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
@@ -140,6 +146,13 @@ export const expenses = pgTable(
status: expenseStatusEnum('status').notNull().default('pending'), status: expenseStatusEnum('status').notNull().default('pending'),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }), reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'), rejectionReason: text('rejection_reason'),
// Supplier invoice attachment
invoiceFileUrl: text('invoice_file_url'),
invoiceFileName: text('invoice_file_name'),
paperlessUrl: text('paperless_url'),
paperlessDocumentId: integer('paperless_document_id'),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidReason: text('void_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
}, },
@@ -150,6 +163,22 @@ export const expenses = pgTable(
] ]
); );
// ── Expense ↔ Packages junction ────────────────────────
export const expensePackages = pgTable(
'expense_packages',
{
expenseId: uuid('expense_id')
.notNull()
.references(() => expenses.id, { onDelete: 'cascade' }),
packageId: uuid('package_id')
.notNull()
.references((): any => packages.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [primaryKey({ columns: [table.expenseId, table.packageId] })]
);
// ── Tags ─────────────────────────────────────────────── // ── Tags ───────────────────────────────────────────────
export const tags = pgTable( export const tags = pgTable(
@@ -429,7 +458,8 @@ export const invoiceStatusEnum = pgEnum('invoice_status', [
'sent', 'sent',
'paid', 'paid',
'overdue', 'overdue',
'cancelled' 'cancelled',
'voided'
]); ]);
export const invoices = pgTable( export const invoices = pgTable(
@@ -452,8 +482,13 @@ export const invoices = pgTable(
currency: text('currency').notNull().default('THB'), currency: text('currency').notNull().default('THB'),
status: invoiceStatusEnum('status').notNull().default('draft'), status: invoiceStatusEnum('status').notNull().default('draft'),
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }), expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
paymentAccountId: uuid('payment_account_id').references((): any => companyAccounts.id, {
onDelete: 'set null'
}),
notes: text('notes'), notes: text('notes'),
pdfPath: text('pdf_path'), pdfPath: text('pdf_path'),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidReason: text('void_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
}, },
@@ -826,52 +861,379 @@ export const cardBrandEnum = pgEnum('card_brand', [
'other' 'other'
]); ]);
export const companyBankAccounts = pgTable( // ── Company Accounts (unified ledger) ──────────────────
'company_bank_accounts',
export const companyAccountTypeEnum = pgEnum('company_account_type', [
'bank',
'credit_card',
'cash',
'mobile_money',
'petty_cash',
'loan',
'other'
]);
export const companyAccountTxnTypeEnum = pgEnum('company_account_txn_type', [
'opening_balance',
'expense',
'invoice_payment',
'transfer_in',
'transfer_out',
'deposit',
'adjustment',
'reconciliation'
]);
export const companyAccounts = pgTable(
'company_accounts',
{ {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id') companyId: uuid('company_id')
.notNull() .notNull()
.references(() => companies.id, { onDelete: 'cascade' }), .references(() => companies.id, { onDelete: 'cascade' }),
bankName: text('bank_name').notNull(), accountType: companyAccountTypeEnum('account_type').notNull(),
accountName: text('account_name').notNull(), name: text('name').notNull(),
accountNumber: text('account_number').notNull(), currency: text('currency').notNull().default('THB'),
accountType: text('account_type'), isActive: boolean('is_active').notNull().default(true),
isArchived: boolean('is_archived').notNull().default(false),
notes: text('notes'),
sortOrder: integer('sort_order').notNull().default(0),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
// Bank-specific
bankName: text('bank_name'),
accountNumber: text('account_number'),
branch: text('branch'), branch: text('branch'),
swiftBic: text('swift_bic'), swiftBic: text('swift_bic'),
iban: text('iban'), iban: text('iban'),
currency: text('currency').notNull().default('THB'), accountHolderName: text('account_holder_name'),
isPrimary: boolean('is_primary').notNull().default(false), // Card-specific
isActive: boolean('is_active').notNull().default(true), cardBrand: cardBrandEnum('card_brand'),
notes: text('notes'), last4: varchar('last4', { length: 4 }),
cardholderName: text('cardholder_name'),
expiryMonth: integer('expiry_month'),
expiryYear: integer('expiry_year'),
creditLimit: numeric('credit_limit', { precision: 15, scale: 2 }),
statementCloseDay: integer('statement_close_day'),
paymentDueDay: integer('payment_due_day'),
// FX conversion to company base currency (e.g. 34.5 for USD→THB)
fxRateToBase: numeric('fx_rate_to_base', { precision: 18, scale: 8 }).notNull().default('1'),
// Banking integration link
externalAccountId: uuid('external_account_id').references(() => externalAccounts.id, {
onDelete: 'set null'
}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
}, },
(table) => [index('company_bank_accounts_company_idx').on(table.companyId)] (table) => [
index('company_accounts_company_type_idx').on(table.companyId, table.accountType),
index('company_accounts_company_archived_idx').on(table.companyId, table.isArchived)
]
); );
export const companyCards = pgTable( export const companyAccountTransactions = pgTable(
'company_cards', 'company_account_transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.notNull()
.references(() => companyAccounts.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
type: companyAccountTxnTypeEnum('type').notNull(),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
description: text('description'),
reference: text('reference'),
counterpartyAccountId: uuid('counterparty_account_id').references(
(): any => companyAccounts.id,
{ onDelete: 'set null' }
),
sourceExpenseId: uuid('source_expense_id').references(() => expenses.id, {
onDelete: 'set null'
}),
sourceInvoiceId: uuid('source_invoice_id').references(() => invoices.id, {
onDelete: 'set null'
}),
sourceExternalTransactionId: uuid('source_external_transaction_id').references(
() => externalTransactions.id,
{ onDelete: 'set null' }
),
fxRate: numeric('fx_rate', { precision: 18, scale: 8 }),
fxAmount: numeric('fx_amount', { precision: 15, scale: 2 }),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('company_account_txns_account_occurred_idx').on(table.accountId, table.occurredAt),
index('company_account_txns_company_occurred_idx').on(table.companyId, table.occurredAt),
index('company_account_txns_expense_idx').on(table.sourceExpenseId),
index('company_account_txns_invoice_idx').on(table.sourceInvoiceId)
]
);
// ── Recurring Bills ────────────────────────────────────
export const recurringBillCycleEnum = pgEnum('recurring_bill_cycle', [
'weekly',
'monthly',
'quarterly',
'yearly'
]);
export const recurringBillStatusEnum = pgEnum('recurring_bill_status', [
'active',
'paused',
'ended'
]);
export const recurringBills = pgTable(
'recurring_bills',
{ {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id') companyId: uuid('company_id')
.notNull() .notNull()
.references(() => companies.id, { onDelete: 'cascade' }), .references(() => companies.id, { onDelete: 'cascade' }),
brand: cardBrandEnum('brand').notNull(), projectId: uuid('project_id')
last4: varchar('last4', { length: 4 }).notNull(), .notNull()
cardholderName: text('cardholder_name').notNull(), .references(() => projects.id, { onDelete: 'restrict' }),
expiryMonth: integer('expiry_month'), accountId: uuid('account_id')
expiryYear: integer('expiry_year'), .notNull()
nickname: text('nickname'), .references(() => companyAccounts.id, { onDelete: 'restrict' }),
bankAccountId: uuid('bank_account_id').references(() => companyBankAccounts.id, { categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
onDelete: 'set null' partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
}), serviceAccountId: uuid('service_account_id').references(
(): any => companyServiceAccounts.id,
{ onDelete: 'set null' }
),
name: text('name').notNull(),
description: text('description'),
cycle: recurringBillCycleEnum('cycle').notNull(),
defaultAmount: numeric('default_amount', { precision: 15, scale: 2 }).notNull(),
nextCycleAmount: numeric('next_cycle_amount', { precision: 15, scale: 2 }),
currency: text('currency').notNull().default('THB'),
dayOfCycle: integer('day_of_cycle'),
startDate: date('start_date').notNull(),
endDate: date('end_date'),
nextDueDate: date('next_due_date').notNull(),
lastPostedDate: date('last_posted_date'),
status: recurringBillStatusEnum('status').notNull().default('active'),
pausedAt: timestamp('paused_at', { withTimezone: true }),
skipNext: boolean('skip_next').notNull().default(false),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('recurring_bills_company_next_due_idx').on(table.companyId, table.nextDueDate),
index('recurring_bills_company_status_idx').on(table.companyId, table.status)
]
);
// ── Service Accounts ───────────────────────────────────
export const serviceAccountTypeEnum = pgEnum('service_account_type', [
'electricity',
'water',
'gas',
'internet',
'phone',
'shipping',
'insurance',
'tax_registration',
'social_security',
'customs',
'other'
]);
export const companyServiceAccounts = pgTable(
'company_service_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
type: serviceAccountTypeEnum('type').notNull(),
providerName: text('provider_name').notNull(),
accountNumber: text('account_number').notNull(),
customLabel: text('custom_label'),
contactPhone: text('contact_phone'),
websiteUrl: text('website_url'),
notes: text('notes'),
isActive: boolean('is_active').notNull().default(true), isActive: boolean('is_active').notNull().default(true),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('company_service_accounts_company_type_idx').on(table.companyId, table.type)
]
);
// ── Procedures & Checklists ────────────────────────────
export const procedureInstanceStatusEnum = pgEnum('procedure_instance_status', [
'in_progress',
'completed',
'cancelled'
]);
export const procedureTemplates = pgTable(
'procedure_templates',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
category: text('category'),
isPublished: boolean('is_published').notNull().default(false),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('procedure_templates_company_idx').on(table.companyId)]
);
export const procedureSteps = pgTable(
'procedure_steps',
{
id: uuid('id').primaryKey().defaultRandom(),
templateId: uuid('template_id')
.notNull()
.references(() => procedureTemplates.id, { onDelete: 'cascade' }),
stepNumber: integer('step_number').notNull(),
title: text('title').notNull(),
description: text('description'),
assigneeRole: text('assignee_role'),
estimatedMinutes: integer('estimated_minutes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('procedure_steps_template_step_idx').on(table.templateId, table.stepNumber)
]
);
export const procedureInstances = pgTable(
'procedure_instances',
{
id: uuid('id').primaryKey().defaultRandom(),
templateId: uuid('template_id')
.notNull()
.references(() => procedureTemplates.id, { onDelete: 'restrict' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
status: procedureInstanceStatusEnum('status').notNull().default('in_progress'),
startedBy: text('started_by').references(() => users.id, { onDelete: 'set null' }),
completedAt: timestamp('completed_at', { withTimezone: true }),
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
notes: text('notes'), notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
}, },
(table) => [index('company_cards_company_idx').on(table.companyId)] (table) => [
index('procedure_instances_company_status_idx').on(table.companyId, table.status)
]
);
export const procedureInstanceSteps = pgTable(
'procedure_instance_steps',
{
id: uuid('id').primaryKey().defaultRandom(),
instanceId: uuid('instance_id')
.notNull()
.references(() => procedureInstances.id, { onDelete: 'cascade' }),
stepId: uuid('step_id')
.notNull()
.references(() => procedureSteps.id, { onDelete: 'restrict' }),
stepNumber: integer('step_number').notNull(),
title: text('title').notNull(),
description: text('description'),
isCompleted: boolean('is_completed').notNull().default(false),
completedBy: text('completed_by').references(() => users.id, { onDelete: 'set null' }),
completedAt: timestamp('completed_at', { withTimezone: true }),
notes: text('notes')
},
(table) => [
index('procedure_instance_steps_instance_idx').on(table.instanceId, table.stepNumber)
]
);
// ── Sales / Income ─────────────────────────────────────
export const saleStatusEnum = pgEnum('sale_status', ['draft', 'confirmed', 'voided']);
export const sales = pgTable(
'sales',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
invoiceId: uuid('invoice_id').references((): any => invoices.id, { onDelete: 'set null' }),
title: text('title').notNull(),
saleDate: date('sale_date').notNull(),
currency: text('currency').notNull().default('THB'),
withholdingTaxRate: numeric('withholding_tax_rate', { precision: 5, scale: 4 })
.notNull()
.default('0'),
notes: text('notes'),
status: saleStatusEnum('status').notNull().default('draft'),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidReason: text('void_reason'),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('sales_company_status_idx').on(table.companyId, table.status),
index('sales_project_idx').on(table.projectId),
index('sales_date_idx').on(table.saleDate)
]
);
export const saleLineItems = pgTable(
'sale_line_items',
{
id: uuid('id').primaryKey().defaultRandom(),
saleId: uuid('sale_id')
.notNull()
.references(() => sales.id, { onDelete: 'cascade' }),
productName: text('product_name').notNull(),
description: text('description'),
quantity: numeric('quantity', { precision: 15, scale: 4 }).notNull().default('1'),
unitPrice: numeric('unit_price', { precision: 15, scale: 2 }).notNull(),
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }).notNull().default('0'),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('sale_line_items_sale_idx').on(table.saleId, table.sortOrder)]
);
export const salePackages = pgTable(
'sale_packages',
{
saleId: uuid('sale_id')
.notNull()
.references(() => sales.id, { onDelete: 'cascade' }),
packageId: uuid('package_id')
.notNull()
.references(() => packages.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [primaryKey({ columns: [table.saleId, table.packageId] })]
); );
export const companyAddresses = pgTable( export const companyAddresses = pgTable(
@@ -936,6 +1298,13 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'invoice_created', 'invoice_created',
'invoice_sent', 'invoice_sent',
'invoice_paid', 'invoice_paid',
'invoice_voided',
'expense_invoice_uploaded',
'expense_updated',
'expense_voided',
'sale_created',
'sale_confirmed',
'sale_voided',
'integration_connected', 'integration_connected',
'integration_disconnected', 'integration_disconnected',
'transaction_matched', 'transaction_matched',
@@ -960,7 +1329,31 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'document_deleted', 'document_deleted',
'link_added', 'link_added',
'link_updated', 'link_updated',
'link_deleted' 'link_deleted',
'account_created',
'account_updated',
'account_archived',
'account_deleted',
'account_transaction_added',
'account_transfer_posted',
'account_reconciled',
'recurring_bill_created',
'recurring_bill_updated',
'recurring_bill_deleted',
'recurring_bill_paused',
'recurring_bill_resumed',
'recurring_bill_skipped',
'recurring_bill_posted',
'service_account_created',
'service_account_updated',
'service_account_deleted',
'procedure_template_created',
'procedure_template_updated',
'procedure_template_deleted',
'procedure_instance_started',
'procedure_step_completed',
'procedure_instance_completed',
'procedure_instance_cancelled'
]); ]);
export const companyLog = pgTable( export const companyLog = pgTable(
+70 -47
View File
@@ -19,9 +19,9 @@ import {
externalAccounts, externalAccounts,
externalTransactions, externalTransactions,
users, users,
companyBankAccounts,
companyCards,
companyAddresses, companyAddresses,
companyAccounts,
companyAccountTransactions,
companyDocuments, companyDocuments,
companyDocumentVersions companyDocumentVersions
} from '../db/schema.js'; } from '../db/schema.js';
@@ -73,8 +73,8 @@ export async function buildFinancialExport(
``, ``,
`Files:`, `Files:`,
` company.csv — company record`, ` company.csv — company record`,
` company_bank_accounts.csv — company bank accounts`, ` company_accounts.csv — unified ledger accounts (bank, card, cash, etc.)`,
` company_cards.csv — company credit/debit cards (last 4 only)`, ` company_account_transactions.csv — ledger transactions in the selected year`,
` company_addresses.csv — legal/shipping/billing/other addresses`, ` company_addresses.csv — legal/shipping/billing/other addresses`,
` company_documents.csv — uploaded document metadata (files not bundled)`, ` company_documents.csv — uploaded document metadata (files not bundled)`,
` projects.csv — all projects (active + inactive)`, ` projects.csv — all projects (active + inactive)`,
@@ -119,70 +119,93 @@ export async function buildFinancialExport(
) )
); );
// ── company_bank_accounts.csv ────────────────────── // ── company_accounts.csv ───────────────────────────
{ {
const bankRows = await db const acctRows = await db
.select() .select()
.from(companyBankAccounts) .from(companyAccounts)
.where(eq(companyBankAccounts.companyId, companyId)) .where(eq(companyAccounts.companyId, companyId))
.orderBy(asc(companyBankAccounts.bankName)); .orderBy(asc(companyAccounts.accountType), asc(companyAccounts.name));
const rows: unknown[][] = [ const rows: unknown[][] = [
[ [
'id', 'bankName', 'accountName', 'accountNumber', 'accountType', 'branch', 'id', 'accountType', 'name', 'currency', 'isActive', 'isArchived',
'swiftBic', 'iban', 'currency', 'isPrimary', 'isActive', 'notes', 'bankName', 'accountNumber', 'branch', 'swiftBic', 'iban', 'accountHolderName',
'createdAt', 'updatedAt' 'cardBrand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear',
'creditLimit', 'statementCloseDay', 'paymentDueDay',
'externalAccountId', 'notes', 'deletedAt', 'createdAt', 'updatedAt'
] ]
]; ];
for (const b of bankRows) { for (const a of acctRows) {
rows.push([ rows.push([
b.id, b.bankName, b.accountName, b.accountNumber, b.accountType ?? '', a.id, a.accountType, a.name, a.currency, a.isActive, a.isArchived,
b.branch ?? '', b.swiftBic ?? '', b.iban ?? '', b.currency, a.bankName ?? '', a.accountNumber ?? '', a.branch ?? '', a.swiftBic ?? '',
b.isPrimary, b.isActive, b.notes ?? '', a.iban ?? '', a.accountHolderName ?? '',
b.createdAt.toISOString(), b.updatedAt.toISOString() a.cardBrand ?? '', a.last4 ?? '', a.cardholderName ?? '',
a.expiryMonth ?? '', a.expiryYear ?? '',
a.creditLimit ?? '', a.statementCloseDay ?? '', a.paymentDueDay ?? '',
a.externalAccountId ?? '', a.notes ?? '',
a.deletedAt ? a.deletedAt.toISOString() : '',
a.createdAt.toISOString(), a.updatedAt.toISOString()
]); ]);
} }
zip.file('company_bank_accounts.csv', withBom(csvBuild(rows))); zip.file('company_accounts.csv', withBom(csvBuild(rows)));
} }
// ── company_cards.csv ────────────────────────────── // ── company_account_transactions.csv ───────────────
{ {
const cardRows = await db const yearStartDate = new Date(`${year}-01-01T00:00:00Z`);
const yearEndDate = new Date(`${year}-12-31T23:59:59.999Z`);
const txRows = await db
.select({ .select({
id: companyCards.id, id: companyAccountTransactions.id,
brand: companyCards.brand, accountId: companyAccountTransactions.accountId,
last4: companyCards.last4, accountName: companyAccounts.name,
cardholderName: companyCards.cardholderName, type: companyAccountTransactions.type,
expiryMonth: companyCards.expiryMonth, amount: companyAccountTransactions.amount,
expiryYear: companyCards.expiryYear, currency: companyAccountTransactions.currency,
nickname: companyCards.nickname, occurredAt: companyAccountTransactions.occurredAt,
bankAccountId: companyCards.bankAccountId, description: companyAccountTransactions.description,
bankAccountName: companyBankAccounts.bankName, reference: companyAccountTransactions.reference,
isActive: companyCards.isActive, counterpartyAccountId: companyAccountTransactions.counterpartyAccountId,
notes: companyCards.notes, sourceExpenseId: companyAccountTransactions.sourceExpenseId,
createdAt: companyCards.createdAt, sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
updatedAt: companyCards.updatedAt sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
createdAt: companyAccountTransactions.createdAt
}) })
.from(companyCards) .from(companyAccountTransactions)
.leftJoin(companyBankAccounts, eq(companyCards.bankAccountId, companyBankAccounts.id)) .innerJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
.where(eq(companyCards.companyId, companyId)) .where(
.orderBy(asc(companyCards.brand)); and(
eq(companyAccountTransactions.companyId, companyId),
sql`${companyAccountTransactions.occurredAt} >= ${yearStartDate}`,
sql`${companyAccountTransactions.occurredAt} <= ${yearEndDate}`
)
)
.orderBy(
asc(companyAccountTransactions.occurredAt),
asc(companyAccountTransactions.createdAt)
);
const rows: unknown[][] = [ const rows: unknown[][] = [
[ [
'id', 'brand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear', 'id', 'accountId', 'accountName', 'type', 'amount', 'currency',
'nickname', 'bankAccountId', 'bankAccountName', 'isActive', 'notes', 'occurredAt', 'description', 'reference',
'createdAt', 'updatedAt' 'counterpartyAccountId', 'sourceExpenseId', 'sourceInvoiceId',
'sourceExternalTransactionId', 'fxRate', 'fxAmount', 'createdAt'
] ]
]; ];
for (const c of cardRows) { for (const t of txRows) {
rows.push([ rows.push([
c.id, c.brand, c.last4, c.cardholderName, t.id, t.accountId, t.accountName, t.type, t.amount, t.currency,
c.expiryMonth ?? '', c.expiryYear ?? '', t.occurredAt.toISOString(), t.description ?? '', t.reference ?? '',
c.nickname ?? '', c.bankAccountId ?? '', c.bankAccountName ?? '', t.counterpartyAccountId ?? '', t.sourceExpenseId ?? '',
c.isActive, c.notes ?? '', t.sourceInvoiceId ?? '', t.sourceExternalTransactionId ?? '',
c.createdAt.toISOString(), c.updatedAt.toISOString() t.fxRate ?? '', t.fxAmount ?? '',
t.createdAt.toISOString()
]); ]);
} }
zip.file('company_cards.csv', withBom(csvBuild(rows))); zip.file('company_account_transactions.csv', withBom(csvBuild(rows)));
} }
// ── company_addresses.csv ────────────────────────── // ── company_addresses.csv ──────────────────────────
+3 -2
View File
@@ -74,7 +74,8 @@ async function resolvePublicIp(hostname: string): Promise<string> {
return ips[0]; return ips[0];
} }
async function safeFetch(targetUrl: URL): Promise<Response | null> { async function safeFetch(targetUrl: URL, depth = 0): Promise<Response | null> {
if (depth > 3) return null;
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') return null; if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') return null;
try { try {
await resolvePublicIp(targetUrl.hostname); await resolvePublicIp(targetUrl.hostname);
@@ -109,7 +110,7 @@ async function safeFetch(targetUrl: URL): Promise<Response | null> {
} catch { } catch {
return null; return null;
} }
return safeFetch(next); return safeFetch(next, depth + 1);
} }
return res; return res;
} catch { } catch {
+112
View File
@@ -0,0 +1,112 @@
import { db } from '$lib/server/db/index.js';
import { companies, companyAccounts } from '$lib/server/db/schema.js';
import { and, eq, isNull, ne } from 'drizzle-orm';
const CDN_URL = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies';
const FALLBACK_URL = 'https://latest.currency-api.pages.dev/v1/currencies';
async function fetchJson(url: string): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
export async function fetchRate(
fromCurrency: string,
toCurrency: string
): Promise<number | null> {
const from = fromCurrency.toLowerCase();
const to = toCurrency.toLowerCase();
if (from === to) return 1;
for (const base of [CDN_URL, FALLBACK_URL]) {
try {
const data = (await fetchJson(`${base}/${from}.min.json`)) as Record<string, unknown>;
const rates = data[from] as Record<string, number> | undefined;
if (rates && typeof rates[to] === 'number') {
return rates[to];
}
} catch {
continue;
}
}
return null;
}
export interface FxUpdateResult {
updated: number;
errors: string[];
}
export async function updateAllFxRates(): Promise<FxUpdateResult> {
let updated = 0;
const errors: string[] = [];
// Get all companies with their base currency
const companyList = await db
.select({ id: companies.id, currency: companies.currency })
.from(companies)
.where(isNull(companies.deletedAt));
for (const company of companyList) {
const baseCurrency = company.currency;
// Find accounts in foreign currencies
const foreignAccounts = await db
.select({
id: companyAccounts.id,
currency: companyAccounts.currency
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, company.id),
isNull(companyAccounts.deletedAt),
ne(companyAccounts.currency, baseCurrency)
)
);
if (foreignAccounts.length === 0) continue;
// Group by currency to minimize API calls
const byCurrency = new Map<string, string[]>();
for (const acct of foreignAccounts) {
const ids = byCurrency.get(acct.currency) ?? [];
ids.push(acct.id);
byCurrency.set(acct.currency, ids);
}
for (const [foreignCurrency, accountIds] of byCurrency) {
try {
const rate = await fetchRate(foreignCurrency, baseCurrency);
if (rate === null) {
errors.push(`No rate for ${foreignCurrency}${baseCurrency}`);
continue;
}
for (const id of accountIds) {
await db
.update(companyAccounts)
.set({
fxRateToBase: rate.toFixed(8),
updatedAt: new Date()
})
.where(eq(companyAccounts.id, id));
updated++;
}
} catch (err) {
errors.push(
`${foreignCurrency}${baseCurrency}: ${err instanceof Error ? err.message : String(err)}`
);
}
}
}
return { updated, errors };
}
+59
View File
@@ -0,0 +1,59 @@
import { env } from '$env/dynamic/private';
const FETCH_TIMEOUT_MS = 20_000;
export function isPaperlessEnabled(): boolean {
return Boolean(env.PAPERLESS_URL && env.PAPERLESS_TOKEN);
}
function baseUrl(): string {
const raw = (env.PAPERLESS_URL ?? '').trim();
return raw.endsWith('/') ? raw.slice(0, -1) : raw;
}
/**
* Upload a File blob to Paperless-ngx.
* Returns the task ID string if accepted; null on failure or if disabled.
*
* Paperless accepts multipart/form-data at /api/documents/post_document/
* and returns a task UUID (string) — the doc ID itself is assigned asynchronously
* after OCR. Callers can store the task ID as a reference.
*/
export async function uploadToPaperless(
file: File,
title?: string
): Promise<{ taskId: string } | null> {
if (!isPaperlessEnabled()) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const form = new FormData();
form.append('document', file, file.name);
if (title) form.append('title', title);
const res = await fetch(`${baseUrl()}/api/documents/post_document/`, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Token ${env.PAPERLESS_TOKEN}`
},
body: form
});
if (!res.ok) {
console.error('[paperless] upload failed', res.status, await res.text().catch(() => ''));
return null;
}
// Paperless returns a quoted task-id string in the body.
const raw = (await res.text()).trim().replace(/^"|"$/g, '');
return { taskId: raw };
} catch (err) {
console.error('[paperless] upload error', err);
return null;
} finally {
clearTimeout(timer);
}
}
+94
View File
@@ -0,0 +1,94 @@
export type Cycle = 'weekly' | 'monthly' | 'quarterly' | 'yearly';
function daysInMonthUTC(year: number, monthZeroBased: number): number {
return new Date(Date.UTC(year, monthZeroBased + 1, 0)).getUTCDate();
}
function toUtcMidnight(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
function clampDay(year: number, monthZeroBased: number, day: number): number {
const max = daysInMonthUTC(year, monthZeroBased);
return Math.min(Math.max(day, 1), max);
}
// Monthly day=31: Jan 31 → Feb 28 → Mar 31 (we never advance based on the previous result alone)
// Yearly Feb 29 (leap) → Feb 28 next year → Feb 29 in next leap year
// Quarterly day=31 starting Jan 31 → Apr 30, Jul 31, Oct 31
// This clamps to month length without losing the "original" day intent across cycles.
function stepForward(date: Date, cycle: Cycle, targetDay: number | null): Date {
const y = date.getUTCFullYear();
const m = date.getUTCMonth();
const d = date.getUTCDate();
if (cycle === 'weekly') {
return new Date(Date.UTC(y, m, d + 7));
}
const monthDelta = cycle === 'monthly' ? 1 : cycle === 'quarterly' ? 3 : 12;
const nextMonth = m + monthDelta;
const nextYear = y + Math.floor(nextMonth / 12);
const normalisedMonth = ((nextMonth % 12) + 12) % 12;
const intendedDay = targetDay ?? d;
const clampedDay = clampDay(nextYear, normalisedMonth, intendedDay);
return new Date(Date.UTC(nextYear, normalisedMonth, clampedDay));
}
export function addCycle(date: Date, cycle: Cycle, targetDay: number | null = null): Date {
return stepForward(toUtcMidnight(date), cycle, targetDay);
}
function alignWeekly(startDate: Date, dayOfCycle: number | null): Date {
if (dayOfCycle === null) return toUtcMidnight(startDate);
const base = toUtcMidnight(startDate);
const baseDay = base.getUTCDay();
const target = Math.max(0, Math.min(6, dayOfCycle));
const delta = (target - baseDay + 7) % 7;
return new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate() + delta));
}
function alignMonthLike(startDate: Date, dayOfCycle: number | null): Date {
const base = toUtcMidnight(startDate);
if (dayOfCycle === null) return base;
const y = base.getUTCFullYear();
const m = base.getUTCMonth();
const clamped = clampDay(y, m, dayOfCycle);
const aligned = new Date(Date.UTC(y, m, clamped));
if (aligned.getTime() < base.getTime()) {
const nextMonth = m + 1;
const nextYear = y + Math.floor(nextMonth / 12);
const normalisedMonth = ((nextMonth % 12) + 12) % 12;
return new Date(Date.UTC(nextYear, normalisedMonth, clampDay(nextYear, normalisedMonth, dayOfCycle)));
}
return aligned;
}
export function computeNextDueDate(
startDate: string,
cycle: Cycle,
dayOfCycle: number | null,
from?: Date
): Date {
const start = new Date(`${startDate}T00:00:00Z`);
if (Number.isNaN(start.getTime())) {
throw new Error(`Invalid startDate: ${startDate}`);
}
const fromUtc = from ? toUtcMidnight(from) : toUtcMidnight(new Date());
let candidate =
cycle === 'weekly' ? alignWeekly(start, dayOfCycle) : alignMonthLike(start, dayOfCycle);
while (candidate.getTime() < fromUtc.getTime()) {
candidate = stepForward(candidate, cycle, dayOfCycle);
}
return candidate;
}
export function toIsoDate(date: Date): string {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
+161
View File
@@ -0,0 +1,161 @@
import { db } from '$lib/server/db/index.js';
import { expenses, recurringBills } from '$lib/server/db/schema.js';
import { postExpenseTransaction } from '$lib/server/accounts/ledger.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { addCycle, toIsoDate, type Cycle } from './cycle.js';
import { and, eq, isNull, lte } from 'drizzle-orm';
export interface PostResult {
postedCount: number;
skippedCount: number;
errors: Array<{ billId: string; error: string }>;
}
type BillRow = typeof recurringBills.$inferSelect;
function fromIso(iso: string): Date {
return new Date(`${iso}T00:00:00Z`);
}
function advanceDate(iso: string, cycle: Cycle, dayOfCycle: number | null): string {
return toIsoDate(addCycle(fromIso(iso), cycle, dayOfCycle));
}
async function processBill(
bill: BillRow,
nowDate: Date
): Promise<{ posted: number; skipped: number }> {
const nowIso = toIsoDate(nowDate);
let posted = 0;
let skipped = 0;
let nextDueDate = bill.nextDueDate;
let skipNext = bill.skipNext;
let currentOverride: string | null = bill.nextCycleAmount;
while (nextDueDate <= nowIso) {
if (bill.endDate && nextDueDate > bill.endDate) break;
if (skipNext) {
const advancedIso = advanceDate(nextDueDate, bill.cycle, bill.dayOfCycle);
await db
.update(recurringBills)
.set({
skipNext: false,
nextDueDate: advancedIso,
updatedAt: nowDate
})
.where(eq(recurringBills.id, bill.id));
await logCompanyEvent(
bill.companyId,
bill.createdBy,
'recurring_bill_skipped',
`Skipped ${nextDueDate} cycle for "${bill.name}"`,
{ billId: bill.id, skippedDate: nextDueDate }
);
skipNext = false;
nextDueDate = advancedIso;
skipped++;
continue;
}
if (!bill.createdBy) {
throw new Error('Bill has no createdBy (user was deleted); cannot post expense');
}
const createdBy = bill.createdBy;
const amountStr = currentOverride ?? bill.defaultAmount;
const postedDate = nextDueDate;
const advancedIso = advanceDate(postedDate, bill.cycle, bill.dayOfCycle);
const willBeEnded = bill.endDate !== null && advancedIso > bill.endDate;
await db.transaction(async (tx) => {
const [exp] = await tx
.insert(expenses)
.values({
projectId: bill.projectId,
accountId: bill.accountId,
categoryId: bill.categoryId,
partyId: bill.partyId,
submittedBy: createdBy,
approvedBy: createdBy,
title: bill.name,
description: bill.description,
amount: amountStr,
currency: bill.currency,
expenseDate: postedDate,
status: 'approved',
reviewedAt: nowDate
})
.returning({ id: expenses.id });
await postExpenseTransaction(exp.id, bill.accountId, createdBy, tx);
await tx
.update(recurringBills)
.set({
lastPostedDate: postedDate,
nextDueDate: advancedIso,
nextCycleAmount: null,
status: willBeEnded ? 'ended' : 'active',
updatedAt: nowDate
})
.where(eq(recurringBills.id, bill.id));
await logCompanyEvent(
bill.companyId,
createdBy,
'recurring_bill_posted',
`Posted ${amountStr} ${bill.currency} for "${bill.name}" (${postedDate})`,
{ billId: bill.id, expenseId: exp.id, amount: amountStr, postedFor: postedDate }
);
});
currentOverride = null;
nextDueDate = advancedIso;
posted++;
if (willBeEnded) break;
}
return { posted, skipped };
}
export async function postBillsDue(
companyId?: string,
now?: Date
): Promise<PostResult> {
const nowDate = now ?? new Date();
const nowIso = toIsoDate(nowDate);
const scopeFilter = companyId ? eq(recurringBills.companyId, companyId) : undefined;
const dueBills = await db
.select()
.from(recurringBills)
.where(
and(
eq(recurringBills.status, 'active'),
isNull(recurringBills.pausedAt),
isNull(recurringBills.deletedAt),
lte(recurringBills.nextDueDate, nowIso),
...(scopeFilter ? [scopeFilter] : [])
)
);
let postedCount = 0;
let skippedCount = 0;
const errors: Array<{ billId: string; error: string }> = [];
for (const bill of dueBills) {
try {
const r = await processBill(bill, nowDate);
postedCount += r.posted;
skippedCount += r.skipped;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push({ billId: bill.id, error: msg });
console.error(`[recurring-bills] failed to post bill ${bill.id}:`, err);
}
}
return { postedCount, skippedCount, errors };
}
@@ -0,0 +1,43 @@
import { postBillsDue } from './poster.js';
import { updateAllFxRates } from '$lib/server/fx/index.js';
const INTERVAL_MS = 15 * 60 * 1000;
const FX_INTERVAL_MS = 24 * 60 * 60 * 1000;
const GUARD_KEY = '__b4lScheduler';
const FX_LAST_KEY = '__b4lFxLastRefresh';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type GlobalAny = typeof globalThis & Record<string, any>;
export function startScheduler(): void {
const g = globalThis as GlobalAny;
if (g[GUARD_KEY]) return;
g[GUARD_KEY] = setInterval(async () => {
// ── Recurring bills (every 15min) ──
try {
const result = await postBillsDue();
if (result.postedCount > 0 || result.errors.length > 0 || result.skippedCount > 0) {
console.log('[scheduler] recurring bills tick:', result);
}
} catch (err) {
console.error('[scheduler] recurring bills tick error:', err);
}
// ── FX rate refresh (daily) ──
const lastFx: number = g[FX_LAST_KEY] ?? 0;
if (Date.now() - lastFx > FX_INTERVAL_MS) {
try {
const result = await updateAllFxRates();
g[FX_LAST_KEY] = Date.now();
if (result.updated > 0 || result.errors.length > 0) {
console.log('[scheduler] FX rates refreshed:', result);
}
} catch (err) {
console.error('[scheduler] FX refresh error:', err);
}
}
}, INTERVAL_MS);
console.log('[scheduler] started (bills: 15min, FX: daily)');
}
@@ -1,8 +1,8 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { companies } from '$lib/server/db/schema.js'; import { companies, companyAccounts, companyAccountTransactions } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm'; import { eq, and, isNull, sql } from 'drizzle-orm';
import { requireAuth, getCompanyRoles } from '$lib/server/authorization.js'; import { requireAuth, getCompanyRoles } from '$lib/server/authorization.js';
import type { CompanyRole } from '$lib/types/index.js'; import type { CompanyRole } from '$lib/types/index.js';
@@ -27,12 +27,26 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
error(403, 'Not a member of this company'); error(403, 'Not a member of this company');
} }
// Total budget = sum of all non-deleted account balances, converted to base currency
const [balanceRow] = await db
.select({
total: sql<string>`coalesce(sum(${companyAccountTransactions.amount} * ${companyAccounts.fxRateToBase}), '0')::text`
})
.from(companyAccountTransactions)
.innerJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
.where(
and(
eq(companyAccountTransactions.companyId, company.id),
isNull(companyAccounts.deletedAt)
)
);
return { return {
company: { company: {
id: company.id, id: company.id,
name: company.name, name: company.name,
description: company.description, description: company.description,
totalBudget: company.totalBudget, totalBudget: balanceRow?.total ?? '0',
currency: company.currency currency: company.currency
}, },
companyRoles: roles companyRoles: roles
@@ -1,56 +1,98 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { afterNavigate } from '$app/navigation';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
let { data, children } = $props(); let { data, children }: { data: LayoutData; children: Snippet } = $props();
const tabs = $derived([ type MenuKey = 'hr' | 'ops' | 'admin';
{ href: `/companies/${data.company.id}`, label: 'Overview' }, let openMenu = $state<MenuKey | null>(null);
{ href: `/companies/${data.company.id}/links`, label: 'Links' },
{ href: `/companies/${data.company.id}/projects`, label: 'Projects' }, afterNavigate(() => {
{ href: `/companies/${data.company.id}/expenses`, label: 'Expenses' }, openMenu = null;
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' }, });
{ href: `/companies/${data.company.id}/categories`, label: 'Categories' },
{ href: `/companies/${data.company.id}/reports`, label: 'Reports' }, const baseUrl = $derived(`/companies/${data.company.id}`);
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'hr') const currentPath = $derived($page.url.pathname);
function isActive(href: string): boolean {
if (href === baseUrl) return currentPath === baseUrl;
return currentPath === href || currentPath.startsWith(href + '/');
}
function has(roles: string[]): boolean {
return data.companyRoles.some((r) => roles.includes(r));
}
const primaryTabs = $derived(
[
{ href: baseUrl, label: 'Overview', show: true },
{ href: `${baseUrl}/accounts`, label: 'Accounts', show: has(['admin', 'manager', 'accountant']) },
{ href: `${baseUrl}/projects`, label: 'Projects', show: true },
{ href: `${baseUrl}/expenses`, label: 'Expenses', show: true },
{ href: `${baseUrl}/sales`, label: 'Sales', show: has(['admin', 'manager', 'accountant']) },
{ href: `${baseUrl}/bills`, label: 'Bills', show: has(['admin', 'manager', 'accountant']) },
{ href: `${baseUrl}/invoices`, label: 'Invoices', show: has(['admin', 'manager']) },
{ href: `${baseUrl}/budget`, label: 'Budget', show: true },
{ href: `${baseUrl}/reports`, label: 'Reports', show: true }
].filter((t) => t.show)
);
const hrItems = $derived(
has(['admin', 'manager', 'hr'])
? [ ? [
{ href: `/companies/${data.company.id}/employees`, label: 'Employees' }, { href: `${baseUrl}/employees`, label: 'Employees' },
{ href: `/companies/${data.company.id}/hr/leave-requests`, label: 'Leave' }, { href: `${baseUrl}/hr/leave-requests`, label: 'Leave' },
{ href: `/companies/${data.company.id}/hr/payroll`, label: 'Payroll' }, { href: `${baseUrl}/hr/payroll`, label: 'Payroll' },
{ href: `/companies/${data.company.id}/hr/holidays`, label: 'Holidays' } { href: `${baseUrl}/hr/holidays`, label: 'Holidays' }
] ]
: []), : []
...(data.companyRoles.some(r => r === 'admin' || r === 'manager') );
? [
{ href: `/companies/${data.company.id}/parties`, label: 'Contacts' }, const opsItems = $derived(
{ href: `/companies/${data.company.id}/invoices`, label: 'Invoices' } [
] { href: `${baseUrl}/parties`, label: 'Contacts', show: has(['admin', 'manager']) },
: []), { href: `${baseUrl}/categories`, label: 'Categories', show: true },
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr') { href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) },
? [{ href: `/companies/${data.company.id}/packages`, label: 'Packages' }] { href: `${baseUrl}/links`, label: 'Links', show: true },
: []), { href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) },
...(data.companyRoles.includes('admin') { href: `${baseUrl}/service-accounts`, label: 'Service Accounts', show: has(['admin', 'manager', 'accountant']) },
? [ { href: `${baseUrl}/procedures`, label: 'Procedures', show: true }
{ href: `/companies/${data.company.id}/integrations`, label: 'Integrations' } ].filter((t) => t.show)
] );
: []),
...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant') const adminItems = $derived(
? [ [
{ href: `/companies/${data.company.id}/profile`, label: 'Profile' }, { href: `${baseUrl}/integrations`, label: 'Integrations', show: has(['admin']) },
{ href: `/companies/${data.company.id}/documents`, label: 'Documents' } { href: `${baseUrl}/import`, label: 'Import', show: has(['admin', 'manager']) },
] { href: `${baseUrl}/export`, label: 'Export', show: has(['admin', 'accountant']) },
: []), { href: `${baseUrl}/profile`, label: 'Profile', show: has(['admin', 'manager', 'accountant']) },
...(data.companyRoles.includes('admin') || data.companyRoles.includes('accountant') { href: `${baseUrl}/settings`, label: 'Settings', show: has(['admin', 'manager']) }
? [{ href: `/companies/${data.company.id}/export`, label: 'Export' }] ].filter((t) => t.show)
: []), );
...(data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
? [ function menuActive(items: Array<{ href: string }>): boolean {
{ href: `/companies/${data.company.id}/import`, label: 'Import' }, return items.some((i) => isActive(i.href));
{ href: `/companies/${data.company.id}/settings`, label: 'Settings' } }
]
: []) function toggleMenu(menu: MenuKey) {
]); openMenu = openMenu === menu ? null : menu;
}
function handleWindowClick(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-nav-dropdown]')) return;
openMenu = null;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') openMenu = null;
}
</script> </script>
<svelte:window onclick={handleWindowClick} onkeydown={handleKeydown} />
<div> <div>
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.company.name}</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.company.name}</h1>
@@ -59,16 +101,82 @@
{/if} {/if}
</div> </div>
<!-- Tabs --> <nav
<nav class="mb-6 flex gap-1 overflow-x-auto border-b border-gray-200 dark:border-gray-700"> class="mb-6 flex flex-wrap items-center gap-1 border-b border-gray-200 dark:border-gray-700"
{#each tabs as tab} aria-label="Company navigation"
>
{#each primaryTabs as tab (tab.href)}
{@const active = isActive(tab.href)}
<a <a
href={tab.href} href={tab.href}
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300" class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {active
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
aria-current={active ? 'page' : undefined}
> >
{tab.label} {tab.label}
</a> </a>
{/each} {/each}
{#snippet dropdown(key: MenuKey, label: string, items: Array<{ href: string; label: string }>)}
{#if items.length > 0}
{@const active = menuActive(items)}
{@const open = openMenu === key}
<div class="relative" data-nav-dropdown>
<button
type="button"
onclick={() => toggleMenu(key)}
class="flex items-center gap-1 whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {active
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: open
? 'border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
aria-haspopup="menu"
aria-expanded={open}
>
{label}
<svg
class="h-3 w-3 transition-transform {open ? 'rotate-180' : ''}"
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if open}
<div
role="menu"
class="absolute left-0 top-full z-20 mt-1 min-w-[180px] rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
{#each items as item (item.href)}
{@const itemActive = isActive(item.href)}
<a
href={item.href}
role="menuitem"
class="block px-4 py-2 text-sm transition-colors {itemActive
? 'bg-blue-50 font-medium text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700/60'}"
aria-current={itemActive ? 'page' : undefined}
>
{item.label}
</a>
{/each}
</div>
{/if}
</div>
{/if}
{/snippet}
{@render dropdown('hr', 'HR', hrItems)}
{@render dropdown('ops', 'Ops', opsItems)}
{@render dropdown('admin', 'Admin', adminItems)}
</nav> </nav>
{@render children()} {@render children()}
@@ -1,23 +1,30 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { projects, expenses } from '$lib/server/db/schema.js'; import {
projects,
expenses,
sales,
saleLineItems,
companyAccounts
} from '$lib/server/db/schema.js';
import { eq, and, sql } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent }) => { export const load: PageServerLoad = async ({ parent }) => {
const { company } = await parent(); const { company } = await parent();
// Get projects with spent amounts // Get projects with spent amounts (converted to base currency via each expense's account fx rate)
const projectList = await db const projectList = await db
.select({ .select({
id: projects.id, id: projects.id,
name: projects.name, name: projects.name,
allocatedBudget: projects.allocatedBudget, allocatedBudget: projects.allocatedBudget,
isActive: projects.isActive, isActive: projects.isActive,
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`, spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int` pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
}) })
.from(projects) .from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id)) .leftJoin(expenses, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(eq(projects.companyId, company.id)) .where(eq(projects.companyId, company.id))
.groupBy(projects.id) .groupBy(projects.id)
.orderBy(projects.name); .orderBy(projects.name);
@@ -38,5 +45,20 @@ export const load: PageServerLoad = async ({ parent }) => {
.orderBy(sql`${expenses.createdAt} desc`) .orderBy(sql`${expenses.createdAt} desc`)
.limit(10); .limit(10);
return { projects: projectList, recentExpenses }; // Total confirmed sales income (net of withholding)
const [incomeRow] = await db
.select({
total: sql<string>`coalesce(sum(
(select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate})) from sale_line_items where sale_id = ${sales.id})
* (1 - ${sales.withholdingTaxRate})
), '0')::text`
})
.from(sales)
.where(and(eq(sales.companyId, company.id), eq(sales.status, 'confirmed')));
return {
projects: projectList,
recentExpenses,
totalIncome: incomeRow?.total ?? '0'
};
}; };
@@ -2,134 +2,244 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
let { data } = $props(); let { data }: { data: PageData } = $props();
const currency = $derived(data.company.currency); const currency = $derived(data.company.currency);
const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0)); const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0));
const spent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0)); const spent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0));
const total = $derived(parseFloat(data.company.totalBudget)); const total = $derived(parseFloat(data.company.totalBudget));
const remaining = $derived(total - spent); const income = $derived(parseFloat(data.totalIncome ?? '0'));
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0); // Total already reflects approved expenses (they post negative txns to the ledger),
// so available cash IS the total. Spent stays informational.
const available = $derived(total);
const unallocated = $derived(total - allocated);
const allocatedPct = $derived(total > 0 ? (allocated / total) * 100 : 0);
const net = $derived(income - spent);
const netPositive = $derived(net >= 0);
const tone = $derived(available < 0 ? 'red' : available < Math.abs(allocated) * 0.2 ? 'amber' : 'green');
const toneRing: Record<string, string> = {
green: 'border-green-300 dark:border-green-700',
amber: 'border-amber-300 dark:border-amber-700',
red: 'border-red-300 dark:border-red-700'
};
const toneText: Record<string, string> = {
green: 'text-green-700 dark:text-green-400',
amber: 'text-amber-700 dark:text-amber-400',
red: 'text-red-700 dark:text-red-400'
};
const toneBar: Record<string, string> = {
green: 'bg-green-500',
amber: 'bg-amber-500',
red: 'bg-red-500'
};
</script> </script>
<svelte:head> <svelte:head>
<title>{data.company.name} - {data.appName}</title> <title>{data.company.name} - {data.appName}</title>
</svelte:head> </svelte:head>
<div class="grid gap-6 lg:grid-cols-2"> <div class="space-y-6">
<!-- Budget Summary --> <!-- Income vs Expenses (hero split) -->
<div class="rounded-lg border-2 {remaining < 0 ? 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/30' : remainingPct < 20 ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/30' : 'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-900/30'} p-5"> <div class="grid gap-4 md:grid-cols-2">
<h2 class="mb-1 text-sm font-semibold uppercase tracking-wider {remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-400'}">Remaining Budget</h2> <div class="rounded-lg border-2 border-emerald-300 bg-emerald-50 p-5 dark:border-emerald-700 dark:bg-emerald-900/20">
<div class="text-3xl font-bold {remaining < 0 ? 'text-red-700 dark:text-red-400' : remainingPct < 20 ? 'text-amber-700 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}"> <div class="flex items-center justify-between">
{formatCurrency(remaining, currency)} <p class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
Income
</p>
<svg class="h-5 w-5 text-emerald-500 dark:text-emerald-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 3a1 1 0 01.707.293l5 5a1 1 0 01-1.414 1.414L11 6.414V16a1 1 0 11-2 0V6.414L5.707 9.707a1 1 0 01-1.414-1.414l5-5A1 1 0 0110 3z" />
</svg>
</div>
<p class="mt-2 text-3xl font-bold text-emerald-700 dark:text-emerald-400">
{formatCurrency(income, currency)}
</p>
<p class="mt-1 text-xs text-emerald-700/70 dark:text-emerald-400/70">
Net of withholding · from confirmed sales
</p>
</div> </div>
<div class="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-white/60 dark:bg-gray-700/60">
<div
class="h-full rounded-full transition-all {remaining < 0 ? 'bg-red-500' : remainingPct < 20 ? 'bg-amber-500' : 'bg-green-500'}"
style="width: {Math.max(0, Math.min(remainingPct, 100))}%"
></div>
</div>
<p class="mt-2 text-xs {remaining < 0 ? 'text-red-500' : remainingPct < 20 ? 'text-amber-500' : 'text-green-500'}">{remainingPct.toFixed(1)}% remaining</p>
<div class="mt-4 space-y-1.5 text-sm"> <div class="rounded-lg border-2 border-red-300 bg-red-50 p-5 dark:border-red-700 dark:bg-red-900/20">
<div class="flex justify-between"> <div class="flex items-center justify-between">
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Total budget</span> <p class="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400">
<span class="font-medium {remaining < 0 ? 'text-red-600 dark:text-red-400' : remainingPct < 20 ? 'text-amber-600 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}">{formatCurrency(total, currency)}</span> Expenses
</div> </p>
<div class="flex justify-between"> <svg class="h-5 w-5 text-red-500 dark:text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Total spent</span> <path d="M10 17a1 1 0 01-.707-.293l-5-5a1 1 0 011.414-1.414L9 13.586V4a1 1 0 112 0v9.586l3.293-3.293a1 1 0 011.414 1.414l-5 5A1 1 0 0110 17z" />
<span class="font-medium {remaining < 0 ? 'text-red-600 dark:text-red-400' : remainingPct < 20 ? 'text-amber-600 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}">{formatCurrency(spent, currency)}</span> </svg>
</div>
<div class="flex justify-between">
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Allocated</span>
<span class="font-medium {remaining < 0 ? 'text-red-600 dark:text-red-400' : remainingPct < 20 ? 'text-amber-600 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}">{formatCurrency(allocated, currency)}</span>
</div> </div>
<p class="mt-2 text-3xl font-bold text-red-700 dark:text-red-400">
{formatCurrency(spent, currency)}
</p>
<p class="mt-1 text-xs text-red-700/70 dark:text-red-400/70">
Approved · across {data.projects.length} {data.projects.length === 1 ? 'project' : 'projects'}
</p>
</div>
<!-- Net position -->
<div class="rounded-lg border-2 {netPositive ? 'border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-900/20' : 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20'} p-5 md:col-span-2">
<p class="text-xs font-semibold uppercase tracking-wider {netPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}">
Net Position (Income Expenses)
</p>
<p class="mt-2 text-3xl font-bold {netPositive ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-700 dark:text-red-400'}">
{netPositive ? '+' : ''}{formatCurrency(net, currency)}
</p>
</div> </div>
</div> </div>
<!-- Projects --> <!-- Cash KPIs (secondary) -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-3">
<div class="mb-3 flex items-center justify-between"> <div class="rounded-lg border {toneRing[tone]} bg-white p-4 dark:bg-gray-800">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Projects</h2> <p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{#if data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')} Available Cash
<a </p>
href="/companies/{data.company.id}/projects/new" <p class="mt-1 text-2xl font-bold {toneText[tone]}">
class="text-sm font-medium text-blue-600 hover:text-blue-700" {formatCurrency(available, currency)}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Sum of account balances (base currency)
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Allocated
</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{formatCurrency(allocated, currency)}
</p>
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div class="h-full bg-blue-500 transition-all" style="width: {Math.min(allocatedPct, 100)}%"></div>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{allocatedPct.toFixed(1)}% of available
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Unallocated
</p>
<p class="mt-1 text-2xl font-bold {unallocated < 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'}">
{formatCurrency(unallocated, currency)}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Cash not assigned to a project
</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- Projects -->
<div
class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-3 flex items-center justify-between">
<h2
class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
> >
+ New Project Projects
</a> </h2>
{#if data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')}
<a
href="/companies/{data.company.id}/projects/new"
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
+ New Project
</a>
{/if}
</div>
{#if data.projects.length === 0}
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No projects yet.</p>
{:else}
<div class="space-y-3">
{#each data.projects as project (project.id)}
{@const budgetNum = parseFloat(project.allocatedBudget)}
{@const spentNum = parseFloat(project.spent)}
{@const pct = budgetNum > 0 ? Math.min((spentNum / budgetNum) * 100, 100) : 0}
<a href="/companies/{data.company.id}/projects/{project.id}" class="block">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-900 dark:text-white">{project.name}</span>
<span class="text-gray-500 dark:text-gray-400">
{formatCurrency(project.spent, currency)} / {formatCurrency(
project.allocatedBudget,
currency
)}
</span>
</div>
<div
class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700"
>
<div
class="h-full rounded-full {pct > 90
? 'bg-red-500'
: pct > 70
? 'bg-amber-500'
: 'bg-blue-500'}"
style="width: {pct}%"
></div>
</div>
{#if project.pendingCount > 0}
<p class="mt-0.5 text-xs text-amber-600">{project.pendingCount} pending</p>
{/if}
</a>
{/each}
</div>
{/if} {/if}
</div> </div>
{#if data.projects.length === 0} <!-- Recent Expenses -->
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No projects yet.</p> <div
{:else} class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
<div class="space-y-3"> >
{#each data.projects as project} <div class="mb-3 flex items-center justify-between">
{@const budgetNum = parseFloat(project.allocatedBudget)} <h2
{@const spentNum = parseFloat(project.spent)} class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
{@const pct = budgetNum > 0 ? Math.min((spentNum / budgetNum) * 100, 100) : 0} >
<a href="/companies/{data.company.id}/projects/{project.id}" class="block"> Recent Expenses
<div class="flex items-center justify-between text-sm"> </h2>
<span class="font-medium text-gray-900 dark:text-white">{project.name}</span> <a
<span class="text-gray-500 dark:text-gray-400"> href="/companies/{data.company.id}/expenses"
{formatCurrency(project.spent, currency)} / {formatCurrency(project.allocatedBudget, currency)} class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
</span> >
</div> View all →
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700"> </a>
<div
class="h-full rounded-full {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-blue-500'}"
style="width: {pct}%"
></div>
</div>
{#if project.pendingCount > 0}
<p class="mt-0.5 text-xs text-amber-600">{project.pendingCount} pending</p>
{/if}
</a>
{/each}
</div> </div>
{/if} {#if data.recentExpenses.length === 0}
</div> <p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No expenses yet.</p>
{:else}
<!-- Recent Expenses --> <ul class="divide-y divide-gray-100 dark:divide-gray-700">
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 lg:col-span-2"> {#each data.recentExpenses as expense (expense.id)}
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Recent Expenses</h2> <li class="flex items-center justify-between gap-3 py-2">
{#if data.recentExpenses.length === 0} <div class="min-w-0 flex-1">
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No expenses yet.</p> <p class="truncate text-sm font-medium text-gray-900 dark:text-white">
{:else} {expense.title}
<table class="w-full text-sm"> </p>
<thead> <p class="truncate text-xs text-gray-500 dark:text-gray-400">
<tr class="border-b border-gray-100 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400"> {expense.projectName} · {expense.expenseDate}
<th class="pb-2 font-medium">Title</th> </p>
<th class="pb-2 font-medium">Project</th> </div>
<th class="pb-2 font-medium">Amount</th> <div class="flex items-center gap-2 text-sm">
<th class="pb-2 font-medium">Date</th> <span class="font-medium text-gray-900 dark:text-white">
<th class="pb-2 font-medium">Status</th> {formatCurrency(expense.amount, currency)}
</tr> </span>
</thead>
<tbody>
{#each data.recentExpenses as expense}
<tr class="border-b border-gray-50 dark:border-gray-700/50">
<td class="py-2 font-medium text-gray-900 dark:text-white">{expense.title}</td>
<td class="py-2 text-gray-500 dark:text-gray-400">{expense.projectName}</td>
<td class="py-2 dark:text-white">{formatCurrency(expense.amount, currency)}</td>
<td class="py-2 text-gray-500 dark:text-gray-400">{expense.expenseDate}</td>
<td class="py-2">
<span <span
class="rounded-full px-2 py-0.5 text-xs font-medium class="rounded-full px-2 py-0.5 text-xs font-medium {expense.status ===
{expense.status === 'approved' 'approved'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: expense.status === 'rejected' : expense.status === 'rejected'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}" : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}"
> >
{expense.status} {expense.status}
</span> </span>
</td> </div>
</tr> </li>
{/each} {/each}
</tbody> </ul>
</table> {/if}
{/if} </div>
</div> </div>
</div> </div>
@@ -0,0 +1,706 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companies,
companyAccounts,
companyAccountTransactions,
externalAccounts
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import {
postTransaction,
postTransfer,
type CompanyAccountTxnType
} from '$lib/server/accounts/ledger.js';
import { fetchRate } from '$lib/server/fx/index.js';
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
async function resolveFxRate(companyId: string, accountCurrency: string): Promise<string> {
const [company] = await db
.select({ currency: companies.currency })
.from(companies)
.where(eq(companies.id, companyId))
.limit(1);
const base = company?.currency ?? 'THB';
if (accountCurrency.toUpperCase() === base.toUpperCase()) return '1';
try {
const rate = await fetchRate(accountCurrency, base);
if (rate !== null && rate > 0) return rate.toFixed(8);
} catch {
// fall through
}
return '1';
}
const MANUAL_TXN_TYPES = ['deposit', 'adjustment'] as const;
type ManualTxnType = (typeof MANUAL_TXN_TYPES)[number];
function parseManualTxnType(v: FormDataEntryValue | null): ManualTxnType | null {
const s = v?.toString();
if (!s) return null;
return (MANUAL_TXN_TYPES as readonly string[]).includes(s) ? (s as ManualTxnType) : null;
}
function parseDate(v: FormDataEntryValue | null): Date | null {
const s = v?.toString();
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
function parsePositiveAmount(v: FormDataEntryValue | null): string | null {
const s = v?.toString();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n <= 0) return null;
return n.toFixed(2);
}
function parseSignedAmount(v: FormDataEntryValue | null): string | null {
const s = v?.toString();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n === 0) return null;
return n.toFixed(2);
}
const ACCOUNT_TYPES = [
'bank',
'credit_card',
'cash',
'mobile_money',
'petty_cash',
'loan',
'other'
] as const;
type AccountType = (typeof ACCOUNT_TYPES)[number];
const CARD_BRANDS = ['visa', 'mastercard', 'amex', 'jcb', 'unionpay', 'discover', 'other'] as const;
type CardBrand = (typeof CARD_BRANDS)[number];
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseIntOrNull(v: FormDataEntryValue | null): number | null {
const s = trimOrNull(v);
if (s === null) return null;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
function parseDecimalOrNull(v: FormDataEntryValue | null): string | null {
const s = trimOrNull(v);
if (s === null) return null;
const n = Number(s);
if (!Number.isFinite(n)) return null;
return n.toFixed(2);
}
function parseAccountType(v: FormDataEntryValue | null): AccountType | null {
const s = v?.toString();
if (!s) return null;
return (ACCOUNT_TYPES as readonly string[]).includes(s) ? (s as AccountType) : null;
}
function parseCardBrand(v: FormDataEntryValue | null): CardBrand | null {
const s = v?.toString();
if (!s) return null;
return (CARD_BRANDS as readonly string[]).includes(s) ? (s as CardBrand) : null;
}
type AccountFields = {
accountType: AccountType;
name: string;
currency: string;
notes: string | null;
bankName: string | null;
accountNumber: string | null;
branch: string | null;
swiftBic: string | null;
iban: string | null;
accountHolderName: string | null;
cardBrand: CardBrand | null;
last4: string | null;
cardholderName: string | null;
expiryMonth: number | null;
expiryYear: number | null;
creditLimit: string | null;
statementCloseDay: number | null;
paymentDueDay: number | null;
fxRateToBase: string;
externalAccountId: string | null;
};
function extractAccountFields(fd: FormData):
| { ok: true; fields: AccountFields }
| { ok: false; error: string } {
const accountType = parseAccountType(fd.get('accountType'));
if (!accountType) return { ok: false, error: 'Account type is required' };
const name = trimOrNull(fd.get('name'));
if (!name) return { ok: false, error: 'Name is required' };
const currency = trimOrNull(fd.get('currency'))?.toUpperCase() ?? 'THB';
if (!/^[A-Z]{3}$/.test(currency)) return { ok: false, error: 'Currency must be a 3-letter code' };
const last4 = trimOrNull(fd.get('last4'));
if (last4 !== null && !/^\d{4}$/.test(last4)) {
return { ok: false, error: 'Last 4 must be exactly 4 digits' };
}
const expiryMonth = parseIntOrNull(fd.get('expiryMonth'));
if (expiryMonth !== null && (expiryMonth < 1 || expiryMonth > 12)) {
return { ok: false, error: 'Expiry month must be 1-12' };
}
const expiryYear = parseIntOrNull(fd.get('expiryYear'));
if (expiryYear !== null && (expiryYear < 2000 || expiryYear > 2100)) {
return { ok: false, error: 'Expiry year is out of range' };
}
const statementCloseDay = parseIntOrNull(fd.get('statementCloseDay'));
if (statementCloseDay !== null && (statementCloseDay < 1 || statementCloseDay > 31)) {
return { ok: false, error: 'Statement close day must be 1-31' };
}
const paymentDueDay = parseIntOrNull(fd.get('paymentDueDay'));
if (paymentDueDay !== null && (paymentDueDay < 1 || paymentDueDay > 31)) {
return { ok: false, error: 'Payment due day must be 1-31' };
}
return {
ok: true,
fields: {
accountType,
name,
currency,
notes: trimOrNull(fd.get('notes')),
bankName: trimOrNull(fd.get('bankName')),
accountNumber: trimOrNull(fd.get('accountNumber')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
accountHolderName: trimOrNull(fd.get('accountHolderName')),
cardBrand: parseCardBrand(fd.get('cardBrand')),
last4,
cardholderName: trimOrNull(fd.get('cardholderName')),
expiryMonth,
expiryYear,
creditLimit: parseDecimalOrNull(fd.get('creditLimit')),
statementCloseDay,
paymentDueDay,
fxRateToBase: parseDecimalOrNull(fd.get('fxRateToBase')) ?? '1',
externalAccountId: trimOrNull(fd.get('externalAccountId'))
}
};
}
type OrderPayload = { id: string; sortOrder: number };
function parseOrderPayload(raw: FormDataEntryValue | null): OrderPayload[] | null {
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const out: OrderPayload[] = [];
for (const row of parsed) {
if (!row || typeof row !== 'object') return null;
const r = row as Record<string, unknown>;
if (typeof r.id !== 'string' || typeof r.sortOrder !== 'number') return null;
out.push({ id: r.id, sortOrder: r.sortOrder });
}
return out;
}
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
await parent();
const showArchived = url.searchParams.get('archived') === '1';
const accountsRaw = await db
.select()
.from(companyAccounts)
.where(
and(eq(companyAccounts.companyId, params.companyId), isNull(companyAccounts.deletedAt))
)
.orderBy(asc(companyAccounts.isArchived), asc(companyAccounts.sortOrder), asc(companyAccounts.name));
const balanceRows = await db
.select({
accountId: companyAccountTransactions.accountId,
balance: sql<string>`coalesce(sum(${companyAccountTransactions.amount}), '0')::text`
})
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.companyId, params.companyId))
.groupBy(companyAccountTransactions.accountId);
const balanceMap = new Map(balanceRows.map((r) => [r.accountId, r.balance]));
const accountsWithBalance = accountsRaw.map((a) => ({
...a,
balance: balanceMap.get(a.id) ?? '0'
}));
const visibleAccounts = showArchived
? accountsWithBalance
: accountsWithBalance.filter((a) => !a.isArchived);
const externalAccountsList = await db
.select({
id: externalAccounts.id,
displayName: externalAccounts.displayName,
provider: externalAccounts.provider
})
.from(externalAccounts)
.where(and(eq(externalAccounts.companyId, params.companyId), eq(externalAccounts.isActive, true)));
const canDelete = roles.includes('admin');
return {
accounts: visibleAccounts,
archivedCount: accountsWithBalance.filter((a) => a.isArchived).length,
showArchived,
externalAccounts: externalAccountsList,
canDelete
};
};
async function nextAccountSortOrder(companyId: string): Promise<number> {
const [row] = await db
.select({ max: sql<number>`coalesce(max(${companyAccounts.sortOrder}), -1)::int` })
.from(companyAccounts)
.where(and(eq(companyAccounts.companyId, companyId), isNull(companyAccounts.deletedAt)));
return (row?.max ?? -1) + 1;
}
export const actions: Actions = {
addAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const parsed = extractAccountFields(fd);
if (!parsed.ok) return fail(400, { action: 'addAccount', error: parsed.error });
const f = parsed.fields;
const sortOrder = await nextAccountSortOrder(params.companyId);
const openingBalance = parseSignedAmount(fd.get('openingBalance'));
const openingBalanceDate =
parseDate(fd.get('openingBalanceDate')) ?? new Date();
// Auto-determine FX rate: 1 for base currency, API rate otherwise
const fxRateToBase = await resolveFxRate(params.companyId, f.currency);
const inserted = await db.transaction(async (tx) => {
const [row] = await tx
.insert(companyAccounts)
.values({
companyId: params.companyId,
sortOrder,
createdBy: user.id,
accountType: f.accountType,
name: f.name,
currency: f.currency,
notes: f.notes,
bankName: f.bankName,
accountNumber: f.accountNumber,
branch: f.branch,
swiftBic: f.swiftBic,
iban: f.iban,
accountHolderName: f.accountHolderName,
cardBrand: f.cardBrand,
last4: f.last4,
cardholderName: f.cardholderName,
expiryMonth: f.expiryMonth,
expiryYear: f.expiryYear,
creditLimit: f.creditLimit,
statementCloseDay: f.statementCloseDay,
paymentDueDay: f.paymentDueDay,
fxRateToBase,
externalAccountId: f.externalAccountId
})
.returning({ id: companyAccounts.id });
if (openingBalance !== null) {
await postTransaction(tx, {
accountId: row.id,
companyId: params.companyId,
type: 'opening_balance',
amount: openingBalance,
currency: f.currency,
occurredAt: openingBalanceDate,
description: 'Opening balance',
createdBy: user.id
});
}
return row;
});
await logCompanyEvent(
params.companyId,
user.id,
'account_created',
`Account "${parsed.fields.name}" created${openingBalance !== null ? ` with opening balance ${openingBalance} ${f.currency}` : ''}`,
{ accountId: inserted.id, accountType: parsed.fields.accountType, openingBalance }
);
return { success: true, action: 'addAccount' };
},
updateAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'updateAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
const parsed = extractAccountFields(fd);
if (!parsed.ok) return fail(400, { action: 'updateAccount', error: parsed.error });
const f = parsed.fields;
await db
.update(companyAccounts)
.set({
accountType: f.accountType,
name: f.name,
currency: f.currency,
notes: f.notes,
bankName: f.bankName,
accountNumber: f.accountNumber,
branch: f.branch,
swiftBic: f.swiftBic,
iban: f.iban,
accountHolderName: f.accountHolderName,
cardBrand: f.cardBrand,
last4: f.last4,
cardholderName: f.cardholderName,
expiryMonth: f.expiryMonth,
expiryYear: f.expiryYear,
creditLimit: f.creditLimit,
statementCloseDay: f.statementCloseDay,
paymentDueDay: f.paymentDueDay,
fxRateToBase: f.fxRateToBase,
externalAccountId: f.externalAccountId,
updatedAt: new Date()
})
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_updated',
`Account "${parsed.fields.name}" updated`,
{ accountId: id }
);
return { success: true, action: 'updateAccount' };
},
archiveAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'archiveAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
await db
.update(companyAccounts)
.set({ isArchived: true, updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_archived',
`Account "${existing.name}" archived`,
{ accountId: id }
);
return { success: true, action: 'archiveAccount' };
},
unarchiveAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'unarchiveAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
await db
.update(companyAccounts)
.set({ isArchived: false, updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_updated',
`Account "${existing.name}" unarchived`,
{ accountId: id }
);
return { success: true, action: 'unarchiveAccount' };
},
deleteAccount: async ({ request, locals, params }) => {
const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
if (!roles.includes('admin')) {
return fail(403, { action: 'deleteAccount', error: 'Only admins can delete accounts' });
}
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
const [txnCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.accountId, id));
if ((txnCount?.count ?? 0) > 0) {
return fail(409, {
action: 'deleteAccount',
error: 'Cannot delete an account that has transactions. Archive it instead.'
});
}
await db
.update(companyAccounts)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_deleted',
`Account "${existing.name}" deleted`,
{ accountId: id }
);
return { success: true, action: 'deleteAccount' };
},
reorderAccounts: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const payload = parseOrderPayload(fd.get('orders'));
if (!payload) return fail(400, { action: 'reorderAccounts', error: 'Invalid order payload' });
await db.transaction(async (tx) => {
for (const { id, sortOrder } of payload) {
await tx
.update(companyAccounts)
.set({ sortOrder, updatedAt: new Date() })
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
);
}
});
return { success: true, action: 'reorderAccounts' };
},
postTransfer: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const fromAccountId = trimOrNull(fd.get('fromAccountId'));
const toAccountId = trimOrNull(fd.get('toAccountId'));
const amount = parsePositiveAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
const fxRate = trimOrNull(fd.get('fxRate'));
const destinationAmount = trimOrNull(fd.get('destinationAmount'));
if (!fromAccountId || !toAccountId) {
return fail(400, { action: 'postTransfer', error: 'Both from and to accounts are required' });
}
if (fromAccountId === toAccountId) {
return fail(400, { action: 'postTransfer', error: 'From and to accounts must differ' });
}
if (!amount) {
return fail(400, { action: 'postTransfer', error: 'Amount must be a positive number' });
}
if (!occurredAt) {
return fail(400, { action: 'postTransfer', error: 'Valid date is required' });
}
try {
await postTransfer({
fromAccountId,
toAccountId,
companyId: params.companyId,
amount,
occurredAt,
description,
reference,
fxRate,
destinationAmount,
createdBy: user.id
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Transfer failed';
return fail(400, { action: 'postTransfer', error: msg });
}
await logCompanyEvent(
params.companyId,
user.id,
'account_transfer_posted',
`Transferred ${amount} from account ${fromAccountId} to ${toAccountId}`,
{ fromAccountId, toAccountId, amount, fxRate, destinationAmount }
);
return { success: true, action: 'postTransfer' };
},
addManualTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const accountId = trimOrNull(fd.get('accountId'));
const type = parseManualTxnType(fd.get('type'));
const amount = parseSignedAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
if (!accountId) {
return fail(400, { action: 'addManualTransaction', error: 'Account is required' });
}
if (!type) {
return fail(400, {
action: 'addManualTransaction',
error: 'Type must be deposit or adjustment'
});
}
if (!amount) {
return fail(400, {
action: 'addManualTransaction',
error: 'Amount must be a non-zero number'
});
}
if (!occurredAt) {
return fail(400, { action: 'addManualTransaction', error: 'Valid date is required' });
}
const [acct] = await db
.select({
id: companyAccounts.id,
currency: companyAccounts.currency,
name: companyAccounts.name
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) error(404, 'Account not found');
const txnType: CompanyAccountTxnType = type;
await postTransaction(db, {
accountId,
companyId: params.companyId,
type: txnType,
amount,
currency: acct.currency,
occurredAt,
description,
reference,
createdBy: user.id
});
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`${type} of ${amount} ${acct.currency} recorded on "${acct.name}"`,
{ accountId, type, amount }
);
return { success: true, action: 'addManualTransaction' };
}
};
@@ -0,0 +1,958 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
type AccountType =
| 'bank'
| 'credit_card'
| 'cash'
| 'mobile_money'
| 'petty_cash'
| 'loan'
| 'other';
const ACCOUNT_TYPE_LABELS: Record<AccountType, string> = {
bank: 'Bank Account',
credit_card: 'Credit Card',
cash: 'Cash',
mobile_money: 'Mobile Money',
petty_cash: 'Petty Cash',
loan: 'Loan',
other: 'Other'
};
const ACCOUNT_TYPE_BADGE: Record<AccountType, string> = {
bank: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
credit_card: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
cash: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
mobile_money: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
petty_cash: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
loan: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
};
const CARD_BRANDS = ['visa', 'mastercard', 'amex', 'jcb', 'unionpay', 'discover', 'other'];
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_LABELS) as AccountType[];
let showAddForm = $state(false);
let showTransferModal = $state(false);
let showManualTxnModal = $state(false);
let editingId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let addType = $state<AccountType>('bank');
let transferFrom = $state<string>('');
let transferTo = $state<string>('');
const activeAccounts = $derived(data.accounts.filter((a) => !a.isArchived));
const fromAccount = $derived(activeAccounts.find((a) => a.id === transferFrom));
const toAccount = $derived(activeAccounts.find((a) => a.id === transferTo));
const isCrossCurrency = $derived(
fromAccount && toAccount && fromAccount.currency !== toAccount.currency
);
function openAdd() {
showAddForm = !showAddForm;
showTransferModal = false;
showManualTxnModal = false;
editingId = null;
confirmDeleteId = null;
}
function openTransfer() {
showTransferModal = true;
showAddForm = false;
showManualTxnModal = false;
editingId = null;
confirmDeleteId = null;
if (activeAccounts.length >= 2) {
transferFrom = activeAccounts[0].id;
transferTo = activeAccounts[1].id;
}
}
function openManualTxn() {
showManualTxnModal = true;
showAddForm = false;
showTransferModal = false;
editingId = null;
confirmDeleteId = null;
}
function todayIso(): string {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function formatAmount(amount: string, currency: string): string {
const n = Number(amount);
const fmt = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `${fmt.format(n)} ${currency}`;
}
function balanceClass(amount: string): string {
const n = Number(amount);
if (n > 0) return 'text-emerald-600 dark:text-emerald-400';
if (n < 0) return 'text-red-600 dark:text-red-400';
return 'text-gray-500 dark:text-gray-400';
}
function utilisation(balance: string, limit: string | null): number | null {
if (!limit) return null;
const lim = Number(limit);
const bal = Number(balance);
if (!Number.isFinite(lim) || lim <= 0) return null;
const used = Math.max(0, -bal);
return Math.min(100, Math.round((used / lim) * 100));
}
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>Accounts - {data.company.name}</title>
</svelte:head>
{#snippet accountFields(
type: AccountType,
prefix: string,
prefill: {
name?: string;
currency?: string;
notes?: string | null;
bankName?: string | null;
accountNumber?: string | null;
branch?: string | null;
swiftBic?: string | null;
iban?: string | null;
accountHolderName?: string | null;
cardBrand?: string | null;
last4?: string | null;
cardholderName?: string | null;
expiryMonth?: number | null;
expiryYear?: number | null;
creditLimit?: string | null;
statementCloseDay?: number | null;
paymentDueDay?: number | null;
fxRateToBase?: string | null;
externalAccountId?: string | null;
} = {}
)}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label for="{prefix}-name" class={labelCls}>Name <span class="text-red-500">*</span></label>
<input
id="{prefix}-name"
name="name"
type="text"
required
value={prefill.name ?? ''}
placeholder={type === 'bank'
? 'e.g. KBank Main'
: type === 'credit_card'
? 'e.g. SCB Platinum •••• 4242'
: 'Account name'}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-currency" class={labelCls}>Currency <span class="text-red-500">*</span></label>
<input
id="{prefix}-currency"
name="currency"
type="text"
required
maxlength="3"
value={prefill.currency ?? 'THB'}
placeholder="THB"
class={inputCls}
/>
</div>
{#if prefix.startsWith('edit-')}
<div>
<label for="{prefix}-fxRate" class={labelCls}>FX Rate to Base</label>
<input
id="{prefix}-fxRate"
name="fxRateToBase"
type="number"
step="0.0001"
min="0.0001"
value={prefill.fxRateToBase ?? '1'}
placeholder="1.0 for THB, 34.5 for USDTHB"
class={inputCls}
/>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">Auto-refreshed daily from FX API. Override here to override.</p>
</div>
{:else}
<div class="text-xs text-gray-500 dark:text-gray-400 self-end pb-2">
FX rate: auto-set on create (1 if base currency, else fetched from FX API)
</div>
{/if}
{#if type === 'bank'}
<div>
<label for="{prefix}-bankName" class={labelCls}>Bank Name</label>
<input
id="{prefix}-bankName"
name="bankName"
type="text"
value={prefill.bankName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-accountNumber" class={labelCls}>Account Number</label>
<input
id="{prefix}-accountNumber"
name="accountNumber"
type="text"
value={prefill.accountNumber ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-branch" class={labelCls}>Branch</label>
<input
id="{prefix}-branch"
name="branch"
type="text"
value={prefill.branch ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-holder" class={labelCls}>Account Holder</label>
<input
id="{prefix}-holder"
name="accountHolderName"
type="text"
value={prefill.accountHolderName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-swift" class={labelCls}>SWIFT/BIC</label>
<input
id="{prefix}-swift"
name="swiftBic"
type="text"
value={prefill.swiftBic ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-iban" class={labelCls}>IBAN</label>
<input
id="{prefix}-iban"
name="iban"
type="text"
value={prefill.iban ?? ''}
class={inputCls}
/>
</div>
{#if data.externalAccounts.length > 0}
<div class="md:col-span-2">
<label for="{prefix}-extAccount" class={labelCls}>Linked Bank Integration</label>
<select
id="{prefix}-extAccount"
name="externalAccountId"
class={inputCls}
value={prefill.externalAccountId ?? ''}
>
<option value="">— none —</option>
{#each data.externalAccounts as ea (ea.id)}
<option value={ea.id}>{ea.displayName} ({ea.provider})</option>
{/each}
</select>
</div>
{/if}
{/if}
{#if type === 'credit_card'}
<div>
<label for="{prefix}-brand" class={labelCls}>Card Brand</label>
<select id="{prefix}-brand" name="cardBrand" class={inputCls} value={prefill.cardBrand ?? 'visa'}>
{#each CARD_BRANDS as b (b)}
<option value={b}>{b.toUpperCase()}</option>
{/each}
</select>
</div>
<div>
<label for="{prefix}-last4" class={labelCls}>Last 4 Digits</label>
<input
id="{prefix}-last4"
name="last4"
type="text"
maxlength="4"
pattern="[0-9]{'{'}4{'}'}"
value={prefill.last4 ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="{prefix}-cardholder" class={labelCls}>Cardholder Name</label>
<input
id="{prefix}-cardholder"
name="cardholderName"
type="text"
value={prefill.cardholderName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-expMonth" class={labelCls}>Expiry Month</label>
<input
id="{prefix}-expMonth"
name="expiryMonth"
type="number"
min="1"
max="12"
value={prefill.expiryMonth ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-expYear" class={labelCls}>Expiry Year</label>
<input
id="{prefix}-expYear"
name="expiryYear"
type="number"
min="2000"
max="2100"
value={prefill.expiryYear ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-limit" class={labelCls}>Credit Limit</label>
<input
id="{prefix}-limit"
name="creditLimit"
type="number"
step="0.01"
min="0"
value={prefill.creditLimit ?? ''}
class={inputCls}
/>
</div>
<div></div>
<div>
<label for="{prefix}-stmtClose" class={labelCls}>Statement Close Day</label>
<input
id="{prefix}-stmtClose"
name="statementCloseDay"
type="number"
min="1"
max="31"
value={prefill.statementCloseDay ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-payDue" class={labelCls}>Payment Due Day</label>
<input
id="{prefix}-payDue"
name="paymentDueDay"
type="number"
min="1"
max="31"
value={prefill.paymentDueDay ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="{prefix}-notes" class={labelCls}>Notes</label>
<textarea id="{prefix}-notes" name="notes" rows="2" class={inputCls}>{prefill.notes ?? ''}</textarea>
</div>
</div>
{/snippet}
<div class="space-y-6">
<header class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Accounts</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Bank accounts, cards, cash, and any other fund source. Balances update automatically from
expenses, invoice payments, transfers, and manual entries.
</p>
</div>
<div class="flex flex-wrap gap-2">
{#if activeAccounts.length >= 1}
<button
type="button"
onclick={openManualTxn}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Record Transaction
</button>
{/if}
{#if activeAccounts.length >= 2}
<button
type="button"
onclick={openTransfer}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Transfer
</button>
{/if}
<button
type="button"
onclick={openAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddForm ? 'Cancel' : '+ New Account'}
</button>
</div>
</header>
{#if form?.error}
<div
class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{form.error}
</div>
{/if}
{#if showAddForm}
<form
method="POST"
action="?/addAccount"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showAddForm = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">New Account</h2>
<div class="mb-4">
<label for="add-accountType" class={labelCls}
>Account Type <span class="text-red-500">*</span></label
>
<select
id="add-accountType"
name="accountType"
required
bind:value={addType}
class={inputCls}
>
{#each ACCOUNT_TYPES as t (t)}
<option value={t}>{ACCOUNT_TYPE_LABELS[t]}</option>
{/each}
</select>
</div>
{@render accountFields(addType, 'add')}
<fieldset class="mt-4 rounded-md border border-gray-200 p-3 dark:border-gray-600">
<legend class="px-2 text-xs font-medium text-gray-500 dark:text-gray-400">
Opening balance (optional)
</legend>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="add-openingBalance" class={labelCls}>
Opening Balance
<span class="ml-1 text-xs text-gray-400">(negative for credit-card debt)</span>
</label>
<input
id="add-openingBalance"
name="openingBalance"
type="number"
step="0.01"
placeholder="0.00"
class={inputCls}
/>
</div>
<div>
<label for="add-openingBalanceDate" class={labelCls}>As of Date</label>
<input
id="add-openingBalanceDate"
name="openingBalanceDate"
type="date"
value={todayIso()}
class={inputCls}
/>
</div>
</div>
</fieldset>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showAddForm = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Create Account
</button>
</div>
</form>
{/if}
{#if showTransferModal}
<form
method="POST"
action="?/postTransfer"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showTransferModal = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Transfer Between Accounts</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="xfer-from" class={labelCls}>From <span class="text-red-500">*</span></label>
<select id="xfer-from" name="fromAccountId" required bind:value={transferFrom} class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="xfer-to" class={labelCls}>To <span class="text-red-500">*</span></label>
<select id="xfer-to" name="toAccountId" required bind:value={transferTo} class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="xfer-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
{#if fromAccount}<span class="ml-1 text-xs text-gray-400">({fromAccount.currency})</span>{/if}
</label>
<input
id="xfer-amount"
name="amount"
type="number"
step="0.01"
min="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="xfer-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="xfer-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
{#if isCrossCurrency}
<div class="md:col-span-2">
<p class="mb-2 text-xs text-amber-600 dark:text-amber-400">
Cross-currency transfer: enter either an FX rate OR a destination amount.
</p>
</div>
<div>
<label for="xfer-rate" class={labelCls}>
FX Rate
{#if fromAccount && toAccount}
<span class="ml-1 text-xs text-gray-400"
>(1 {fromAccount.currency} = ? {toAccount.currency})</span
>
{/if}
</label>
<input
id="xfer-rate"
name="fxRate"
type="number"
step="0.00000001"
min="0"
placeholder="e.g. 36.5"
class={inputCls}
/>
</div>
<div>
<label for="xfer-destAmt" class={labelCls}>
— or — Destination Amount
{#if toAccount}<span class="ml-1 text-xs text-gray-400">({toAccount.currency})</span>{/if}
</label>
<input
id="xfer-destAmt"
name="destinationAmount"
type="number"
step="0.01"
min="0"
placeholder="e.g. 36500.00"
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="xfer-description" class={labelCls}>Description</label>
<input
id="xfer-description"
name="description"
type="text"
placeholder="Optional note"
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="xfer-reference" class={labelCls}>Reference</label>
<input id="xfer-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showTransferModal = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Post Transfer
</button>
</div>
</form>
{/if}
{#if showManualTxnModal}
<form
method="POST"
action="?/addManualTransaction"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showManualTxnModal = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Record Transaction</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="mtxn-account" class={labelCls}>Account <span class="text-red-500">*</span></label>
<select id="mtxn-account" name="accountId" required class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="mtxn-type" class={labelCls}>Type <span class="text-red-500">*</span></label>
<select id="mtxn-type" name="type" required class={inputCls}>
<option value="deposit">Deposit (credit)</option>
<option value="adjustment">Adjustment (debit or credit)</option>
</select>
</div>
<div>
<label for="mtxn-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
<span class="ml-1 text-xs text-gray-400">(positive = credit, negative = debit)</span>
</label>
<input
id="mtxn-amount"
name="amount"
type="number"
step="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="mtxn-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="mtxn-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="mtxn-description" class={labelCls}>Description</label>
<input id="mtxn-description" name="description" type="text" class={inputCls} />
</div>
<div class="md:col-span-2">
<label for="mtxn-reference" class={labelCls}>Reference</label>
<input id="mtxn-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showManualTxnModal = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Record
</button>
</div>
</form>
{/if}
{#if data.accounts.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
No accounts yet. Click "+ New Account" to add one.
</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each data.accounts as acct (acct.id)}
<div
class="group relative flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-500 {acct.isArchived
? 'opacity-60'
: ''}"
>
<a
href={`/companies/${data.company.id}/accounts/${acct.id}`}
aria-label={`Open ${acct.name}`}
class="absolute inset-0 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<span class="sr-only">Open {acct.name}</span>
</a>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">
{acct.name}
</h3>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {ACCOUNT_TYPE_BADGE[
acct.accountType
]}"
>
{ACCOUNT_TYPE_LABELS[acct.accountType]}
</span>
{#if acct.isArchived}
<span
class="rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-600 dark:text-gray-300"
>
Archived
</span>
{/if}
</div>
</div>
</div>
<p class="text-2xl font-bold {balanceClass(acct.balance)}">
{formatAmount(acct.balance, acct.currency)}
</p>
{#if acct.currency !== data.company.currency && acct.fxRateToBase}
{@const baseEquivalent = (Number(acct.balance) * Number(acct.fxRateToBase)).toFixed(2)}
<p class="text-xs text-gray-500 dark:text-gray-400">
{formatAmount(baseEquivalent, data.company.currency)}
<span class="text-gray-400 dark:text-gray-500">(@ {Number(acct.fxRateToBase)})</span>
</p>
{/if}
{#if acct.accountType === 'credit_card' && acct.creditLimit}
{@const pct = utilisation(acct.balance, acct.creditLimit)}
<div class="text-xs text-gray-500 dark:text-gray-400">
Limit {formatAmount(acct.creditLimit, acct.currency)}
{#if pct !== null}
· {pct}% used
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full {pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500'}"
style="width: {pct}%"
></div>
</div>
{/if}
</div>
{/if}
{#if acct.accountType === 'credit_card' && (acct.statementCloseDay || acct.paymentDueDay)}
<p class="text-xs text-gray-500 dark:text-gray-400">
{#if acct.statementCloseDay}Closes day {acct.statementCloseDay}{/if}
{#if acct.statementCloseDay && acct.paymentDueDay} · {/if}
{#if acct.paymentDueDay}Due day {acct.paymentDueDay}{/if}
</p>
{/if}
{#if acct.accountType === 'bank' && (acct.bankName || acct.accountNumber)}
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{acct.bankName ?? ''}{#if acct.accountNumber} · {acct.accountNumber}{/if}
</p>
{/if}
<div
class="relative z-10 mt-auto flex flex-wrap justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700"
>
<button
type="button"
onclick={() => {
editingId = editingId === acct.id ? null : acct.id;
confirmDeleteId = null;
}}
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
{#if acct.isArchived}
<form method="POST" action="?/unarchiveAccount" use:enhance>
<input type="hidden" name="id" value={acct.id} />
<button
type="submit"
class="text-xs font-medium text-gray-600 hover:text-gray-800 dark:text-gray-300"
>
Unarchive
</button>
</form>
{:else}
<form method="POST" action="?/archiveAccount" use:enhance>
<input type="hidden" name="id" value={acct.id} />
<button
type="submit"
class="text-xs font-medium text-gray-600 hover:text-gray-800 dark:text-gray-300"
>
Archive
</button>
</form>
{/if}
{#if data.canDelete}
<button
type="button"
onclick={() => (confirmDeleteId = confirmDeleteId === acct.id ? null : acct.id)}
class="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
{/if}
</div>
{#if confirmDeleteId === acct.id}
<form
method="POST"
action="?/deleteAccount"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
confirmDeleteId = null;
}}
class="relative z-10 mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30"
>
<input type="hidden" name="id" value={acct.id} />
<p class="mb-2 text-red-700 dark:text-red-300">
Delete "{acct.name}"? This only works if the account has zero transactions.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
{/if}
{#if editingId === acct.id}
<form
method="POST"
action="?/updateAccount"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingId = null;
}}
class="relative z-10 mt-2 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50"
>
<input type="hidden" name="id" value={acct.id} />
<input type="hidden" name="accountType" value={acct.accountType} />
{@render accountFields(acct.accountType, 'edit-' + acct.id, {
name: acct.name,
currency: acct.currency,
notes: acct.notes,
bankName: acct.bankName,
accountNumber: acct.accountNumber,
branch: acct.branch,
swiftBic: acct.swiftBic,
iban: acct.iban,
accountHolderName: acct.accountHolderName,
cardBrand: acct.cardBrand,
last4: acct.last4,
cardholderName: acct.cardholderName,
expiryMonth: acct.expiryMonth,
expiryYear: acct.expiryYear,
creditLimit: acct.creditLimit,
statementCloseDay: acct.statementCloseDay,
paymentDueDay: acct.paymentDueDay,
fxRateToBase: acct.fxRateToBase,
externalAccountId: acct.externalAccountId
})}
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (editingId = null)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Save
</button>
</div>
</form>
{/if}
</div>
{/each}
</div>
{/if}
{#if data.archivedCount > 0}
<div class="flex justify-center">
<a
href={data.showArchived
? `/companies/${data.company.id}/accounts`
: `/companies/${data.company.id}/accounts?archived=1`}
class="text-xs text-gray-500 underline hover:text-gray-700 dark:text-gray-400"
>
{data.showArchived ? 'Hide' : 'Show'} archived ({data.archivedCount})
</a>
</div>
{/if}
</div>
@@ -0,0 +1,324 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companyAccounts,
companyAccountTransactions,
users
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { postTransaction } from '$lib/server/accounts/ledger.js';
import { alias } from 'drizzle-orm/pg-core';
import { and, desc, eq, gte, ilike, isNull, lte, or, sql } from 'drizzle-orm';
const ALL_TYPES = [
'opening_balance',
'expense',
'invoice_payment',
'transfer_in',
'transfer_out',
'deposit',
'adjustment',
'reconciliation'
] as const;
type TxnType = (typeof ALL_TYPES)[number];
const EDITABLE_TYPES: readonly TxnType[] = ['deposit', 'adjustment'];
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseSignedAmount(v: FormDataEntryValue | null): string | null {
const s = v?.toString();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n === 0) return null;
return n.toFixed(2);
}
function parseDate(v: FormDataEntryValue | null): Date | null {
const s = v?.toString();
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
function parseTxnType(v: FormDataEntryValue | null): TxnType | null {
const s = v?.toString();
if (!s) return null;
return (ALL_TYPES as readonly string[]).includes(s) ? (s as TxnType) : null;
}
const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const [account] = await db
.select()
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!account) error(404, 'Account not found');
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
const typeParam = url.searchParams.get('type');
const qParam = url.searchParams.get('q');
const page = Math.max(1, Number(url.searchParams.get('page') ?? '1') || 1);
const conditions = [eq(companyAccountTransactions.accountId, params.accountId)];
if (fromParam) conditions.push(gte(companyAccountTransactions.occurredAt, new Date(fromParam)));
if (toParam) {
const toDate = new Date(toParam);
toDate.setHours(23, 59, 59, 999);
conditions.push(lte(companyAccountTransactions.occurredAt, toDate));
}
if (typeParam && (ALL_TYPES as readonly string[]).includes(typeParam)) {
conditions.push(eq(companyAccountTransactions.type, typeParam as TxnType));
}
if (qParam && qParam.trim()) {
const pattern = `%${qParam.trim()}%`;
conditions.push(
or(
ilike(companyAccountTransactions.description, pattern),
ilike(companyAccountTransactions.reference, pattern)
)!
);
}
const [totalRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(companyAccountTransactions)
.where(and(...conditions));
const totalCount = totalRow?.count ?? 0;
const counterparty = alias(companyAccounts, 'counterparty');
const transactions = await db
.select({
id: companyAccountTransactions.id,
type: companyAccountTransactions.type,
amount: companyAccountTransactions.amount,
currency: companyAccountTransactions.currency,
occurredAt: companyAccountTransactions.occurredAt,
description: companyAccountTransactions.description,
reference: companyAccountTransactions.reference,
counterpartyAccountId: companyAccountTransactions.counterpartyAccountId,
counterpartyName: counterparty.name,
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
createdByName: users.displayName,
createdByEmail: users.email,
createdAt: companyAccountTransactions.createdAt
})
.from(companyAccountTransactions)
.leftJoin(counterparty, eq(companyAccountTransactions.counterpartyAccountId, counterparty.id))
.leftJoin(users, eq(companyAccountTransactions.createdBy, users.id))
.where(and(...conditions))
.orderBy(desc(companyAccountTransactions.occurredAt), desc(companyAccountTransactions.createdAt))
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE);
const [balanceRow] = await db
.select({
total: sql<string>`coalesce(sum(${companyAccountTransactions.amount}), '0')::text`
})
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.accountId, params.accountId));
return {
account,
transactions,
balance: balanceRow?.total ?? '0',
totalCount,
page,
pageSize: PAGE_SIZE,
filters: {
from: fromParam ?? '',
to: toParam ?? '',
type: typeParam ?? '',
q: qParam ?? ''
}
};
};
export const actions: Actions = {
addManualTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const type = parseTxnType(fd.get('type'));
const amount = parseSignedAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
if (!type || !(EDITABLE_TYPES as readonly string[]).includes(type)) {
return fail(400, {
action: 'addManualTransaction',
error: 'Type must be deposit or adjustment'
});
}
if (!amount) {
return fail(400, {
action: 'addManualTransaction',
error: 'Amount must be a non-zero number'
});
}
if (!occurredAt) {
return fail(400, { action: 'addManualTransaction', error: 'Valid date is required' });
}
const [acct] = await db
.select({
id: companyAccounts.id,
currency: companyAccounts.currency,
name: companyAccounts.name
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) error(404, 'Account not found');
await postTransaction(db, {
accountId: params.accountId,
companyId: params.companyId,
type,
amount,
currency: acct.currency,
occurredAt,
description,
reference,
createdBy: user.id
});
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`${type} of ${amount} ${acct.currency} recorded on "${acct.name}"`,
{ accountId: params.accountId, type, amount }
);
return { success: true, action: 'addManualTransaction' };
},
editTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
const amount = parseSignedAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
if (!id) return fail(400, { action: 'editTransaction', error: 'Transaction id is required' });
if (!amount) {
return fail(400, { action: 'editTransaction', error: 'Amount must be a non-zero number' });
}
if (!occurredAt) {
return fail(400, { action: 'editTransaction', error: 'Valid date is required' });
}
const [existing] = await db
.select({ id: companyAccountTransactions.id, type: companyAccountTransactions.type })
.from(companyAccountTransactions)
.where(
and(
eq(companyAccountTransactions.id, id),
eq(companyAccountTransactions.accountId, params.accountId)
)
)
.limit(1);
if (!existing) error(404, 'Transaction not found');
if (!(EDITABLE_TYPES as readonly string[]).includes(existing.type)) {
return fail(400, {
action: 'editTransaction',
error: 'This transaction type cannot be edited (auto-posted from expense/invoice/transfer)'
});
}
await db
.update(companyAccountTransactions)
.set({ amount, occurredAt, description, reference, updatedAt: new Date() })
.where(eq(companyAccountTransactions.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Transaction edited on account ${params.accountId}`,
{ accountId: params.accountId, transactionId: id }
);
return { success: true, action: 'editTransaction' };
},
deleteTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteTransaction', error: 'Transaction id is required' });
const [existing] = await db
.select({ id: companyAccountTransactions.id, type: companyAccountTransactions.type })
.from(companyAccountTransactions)
.where(
and(
eq(companyAccountTransactions.id, id),
eq(companyAccountTransactions.accountId, params.accountId)
)
)
.limit(1);
if (!existing) error(404, 'Transaction not found');
if (!(EDITABLE_TYPES as readonly string[]).includes(existing.type)) {
return fail(400, {
action: 'deleteTransaction',
error: 'This transaction type cannot be deleted (auto-posted from expense/invoice/transfer)'
});
}
await db.delete(companyAccountTransactions).where(eq(companyAccountTransactions.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Transaction deleted on account ${params.accountId}`,
{ accountId: params.accountId, transactionId: id }
);
return { success: true, action: 'deleteTransaction' };
}
};
@@ -0,0 +1,556 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
type TxnType =
| 'opening_balance'
| 'expense'
| 'invoice_payment'
| 'transfer_in'
| 'transfer_out'
| 'deposit'
| 'adjustment'
| 'reconciliation';
const TYPE_LABELS: Record<TxnType, string> = {
opening_balance: 'Opening',
expense: 'Expense',
invoice_payment: 'Invoice',
transfer_in: 'Transfer In',
transfer_out: 'Transfer Out',
deposit: 'Deposit',
adjustment: 'Adjustment',
reconciliation: 'Reconciliation'
};
const TYPE_BADGE: Record<TxnType, string> = {
opening_balance: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
expense: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
invoice_payment: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
transfer_in: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
transfer_out: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
deposit: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
adjustment: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
reconciliation: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300'
};
const EDITABLE_TYPES: TxnType[] = ['deposit', 'adjustment'];
let showRecord = $state(false);
let editingId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
function formatAmount(amount: string, currency: string): string {
const n = Number(amount);
const fmt = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `${fmt.format(n)} ${currency}`;
}
function formatDate(d: Date | string): string {
const dt = typeof d === 'string' ? new Date(d) : d;
return dt.toISOString().split('T')[0];
}
function balanceClass(amount: string): string {
const n = Number(amount);
if (n > 0) return 'text-emerald-600 dark:text-emerald-400';
if (n < 0) return 'text-red-600 dark:text-red-400';
return 'text-gray-500 dark:text-gray-400';
}
function todayIso(): string {
const d = new Date();
return d.toISOString().split('T')[0];
}
function utilisation(balance: string, limit: string | null): number | null {
if (!limit) return null;
const lim = Number(limit);
const bal = Number(balance);
if (!Number.isFinite(lim) || lim <= 0) return null;
const used = Math.max(0, -bal);
return Math.min(100, Math.round((used / lim) * 100));
}
const totalPages = $derived(Math.max(1, Math.ceil(data.totalCount / data.pageSize)));
function pageHref(p: number): string {
const params = new URLSearchParams();
if (data.filters.from) params.set('from', data.filters.from);
if (data.filters.to) params.set('to', data.filters.to);
if (data.filters.type) params.set('type', data.filters.type);
if (data.filters.q) params.set('q', data.filters.q);
if (p > 1) params.set('page', String(p));
const qs = params.toString();
return qs ? `?${qs}` : '';
}
const exportHref = $derived.by(() => {
const params = new URLSearchParams();
if (data.filters.from) params.set('from', data.filters.from);
if (data.filters.to) params.set('to', data.filters.to);
if (data.filters.type) params.set('type', data.filters.type);
if (data.filters.q) params.set('q', data.filters.q);
const qs = params.toString();
return `/companies/${data.company.id}/accounts/${data.account.id}/export${qs ? '?' + qs : ''}`;
});
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>{data.account.name} - Accounts - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<header>
<div class="mb-2">
<a
href={`/companies/${data.company.id}/accounts`}
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
&larr; Accounts
</a>
</div>
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.account.name}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{data.account.accountType} · {data.account.currency}
{#if data.account.isArchived} · <span class="font-medium">Archived</span>{/if}
</p>
</div>
<div class="text-right">
<p class="text-3xl font-bold {balanceClass(data.balance)}">
{formatAmount(data.balance, data.account.currency)}
</p>
<p class="text-xs text-gray-400">Current balance</p>
</div>
</div>
{#if data.account.accountType === 'credit_card' && data.account.creditLimit}
{@const pct = utilisation(data.balance, data.account.creditLimit)}
<div class="mt-4 rounded-md border border-gray-200 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">
Credit limit: {formatAmount(data.account.creditLimit, data.account.currency)}
</span>
{#if pct !== null}
<span class="text-xs text-gray-500">{pct}% used</span>
{/if}
</div>
{#if pct !== null}
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full {pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500'}"
style="width: {pct}%"
></div>
</div>
{/if}
{#if data.account.statementCloseDay || data.account.paymentDueDay}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{#if data.account.statementCloseDay}Statement closes day {data.account.statementCloseDay}{/if}
{#if data.account.statementCloseDay && data.account.paymentDueDay} · {/if}
{#if data.account.paymentDueDay}Payment due day {data.account.paymentDueDay}{/if}
</p>
{/if}
</div>
{/if}
</header>
{#if form?.error}
<div
class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{form.error}
</div>
{/if}
<div class="flex flex-wrap items-center justify-between gap-3">
<form method="GET" class="flex flex-wrap items-center gap-2">
<input
type="date"
name="from"
value={data.filters.from}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<span class="text-xs text-gray-400">to</span>
<input
type="date"
name="to"
value={data.filters.to}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<select
name="type"
value={data.filters.type}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">All types</option>
{#each Object.entries(TYPE_LABELS) as [value, label]}
<option {value}>{label}</option>
{/each}
</select>
<input
type="search"
name="q"
value={data.filters.q}
placeholder="Search description / reference"
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<button
type="submit"
class="rounded-md border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Apply
</button>
</form>
<div class="flex gap-2">
<a
href={exportHref}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Export CSV
</a>
<button
type="button"
onclick={() => (showRecord = !showRecord)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showRecord ? 'Cancel' : '+ Record Transaction'}
</button>
</div>
</div>
{#if showRecord}
<form
method="POST"
action="?/addManualTransaction"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showRecord = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label for="rt-type" class={labelCls}>Type <span class="text-red-500">*</span></label>
<select id="rt-type" name="type" required class={inputCls}>
<option value="deposit">Deposit (credit)</option>
<option value="adjustment">Adjustment (debit or credit)</option>
</select>
</div>
<div>
<label for="rt-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
<span class="ml-1 text-xs text-gray-400">(positive = credit, negative = debit)</span>
</label>
<input
id="rt-amount"
name="amount"
type="number"
step="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="rt-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="rt-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
<div></div>
<div class="md:col-span-2">
<label for="rt-description" class={labelCls}>Description</label>
<input id="rt-description" name="description" type="text" class={inputCls} />
</div>
<div class="md:col-span-2">
<label for="rt-reference" class={labelCls}>Reference</label>
<input id="rt-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (showRecord = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Record
</button>
</div>
</form>
{/if}
{#if data.transactions.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">No transactions match the current filters.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-left text-sm">
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th class="px-3 py-2">Date</th>
<th class="px-3 py-2">Type</th>
<th class="px-3 py-2">Description</th>
<th class="px-3 py-2 text-right">Debit</th>
<th class="px-3 py-2 text-right">Credit</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.transactions as txn (txn.id)}
{@const amt = Number(txn.amount)}
{@const isDebit = amt < 0}
<tr class="align-top">
<td class="px-3 py-2 whitespace-nowrap text-gray-700 dark:text-gray-200">
{formatDate(txn.occurredAt)}
</td>
<td class="px-3 py-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {TYPE_BADGE[txn.type as TxnType]}"
>
{TYPE_LABELS[txn.type as TxnType]}
</span>
</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-200">
<div>{txn.description ?? '—'}</div>
{#if txn.reference}
<div class="text-xs text-gray-400">Ref: {txn.reference}</div>
{/if}
{#if txn.counterpartyName}
<div class="text-xs text-gray-400">
Counterparty: {txn.counterpartyName}
</div>
{/if}
{#if txn.sourceExpenseId}
<a
href={`/companies/${data.company.id}/expenses/${txn.sourceExpenseId}`}
class="mt-0.5 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Open expense →
</a>
{/if}
{#if txn.sourceInvoiceId}
<a
href={`/companies/${data.company.id}/invoices/${txn.sourceInvoiceId}`}
class="mt-0.5 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Open invoice →
</a>
{/if}
{#if txn.fxRate && txn.fxAmount}
<div class="text-xs text-amber-600 dark:text-amber-400">
FX: {txn.fxAmount} @ {Number(txn.fxRate).toFixed(4)}
</div>
{/if}
{#if txn.createdByName}
<div class="text-xs text-gray-400">By {txn.createdByName}</div>
{/if}
</td>
<td class="px-3 py-2 text-right font-mono text-red-600 dark:text-red-400">
{#if isDebit}{formatAmount(Math.abs(amt).toFixed(2), txn.currency)}{/if}
</td>
<td class="px-3 py-2 text-right font-mono text-emerald-600 dark:text-emerald-400">
{#if !isDebit}{formatAmount(amt.toFixed(2), txn.currency)}{/if}
</td>
<td class="px-3 py-2">
{#if EDITABLE_TYPES.includes(txn.type as TxnType)}
<div class="flex gap-2 text-xs">
<button
type="button"
onclick={() => {
editingId = editingId === txn.id ? null : txn.id;
confirmDeleteId = null;
}}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
type="button"
onclick={() =>
(confirmDeleteId = confirmDeleteId === txn.id ? null : txn.id)}
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
{:else}
<span
class="text-xs text-gray-400"
title="Auto-posted from expense/invoice/transfer — cannot edit here"
>
Locked
</span>
{/if}
</td>
</tr>
{#if editingId === txn.id}
<tr class="bg-gray-50 dark:bg-gray-700/50">
<td colspan="6" class="px-3 py-3">
<form
method="POST"
action="?/editTransaction"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingId = null;
}}
class="grid grid-cols-1 gap-2 md:grid-cols-4"
>
<input type="hidden" name="id" value={txn.id} />
<div>
<label for="et-date-{txn.id}" class={labelCls}>Date</label>
<input
id="et-date-{txn.id}"
name="occurredAt"
type="date"
required
value={formatDate(txn.occurredAt)}
class={inputCls}
/>
</div>
<div>
<label for="et-amt-{txn.id}" class={labelCls}>Amount (signed)</label>
<input
id="et-amt-{txn.id}"
name="amount"
type="number"
step="0.01"
required
value={txn.amount}
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="et-desc-{txn.id}" class={labelCls}>Description</label>
<input
id="et-desc-{txn.id}"
name="description"
type="text"
value={txn.description ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-4">
<label for="et-ref-{txn.id}" class={labelCls}>Reference</label>
<input
id="et-ref-{txn.id}"
name="reference"
type="text"
value={txn.reference ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (editingId = null)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Save
</button>
</div>
</form>
</td>
</tr>
{/if}
{#if confirmDeleteId === txn.id}
<tr class="bg-red-50 dark:bg-red-900/20">
<td colspan="6" class="px-3 py-3">
<form
method="POST"
action="?/deleteTransaction"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
confirmDeleteId = null;
}}
class="flex items-center justify-between gap-2"
>
<input type="hidden" name="id" value={txn.id} />
<span class="text-sm text-red-700 dark:text-red-300">
Delete this {txn.type} transaction?
</span>
<div class="flex gap-2">
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if totalPages > 1}
<div class="flex items-center justify-between text-sm">
<p class="text-gray-500 dark:text-gray-400">
Page {data.page} of {totalPages} · {data.totalCount} transactions
</p>
<div class="flex gap-2">
{#if data.page > 1}
<a
href={pageHref(data.page - 1)}
class="rounded-md border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
← Prev
</a>
{/if}
{#if data.page < totalPages}
<a
href={pageHref(data.page + 1)}
class="rounded-md border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Next →
</a>
{/if}
</div>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,133 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { companyAccounts, companyAccountTransactions, users } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { csvBuild } from '$lib/utils/csv.js';
import { alias } from 'drizzle-orm/pg-core';
import { and, desc, eq, gte, ilike, isNull, lte, or } from 'drizzle-orm';
function withBom(s: string): string {
return '\uFEFF' + s;
}
export const GET: RequestHandler = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const [account] = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!account) error(404, 'Account not found');
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
const typeParam = url.searchParams.get('type');
const qParam = url.searchParams.get('q');
const conditions = [eq(companyAccountTransactions.accountId, params.accountId)];
if (fromParam) conditions.push(gte(companyAccountTransactions.occurredAt, new Date(fromParam)));
if (toParam) {
const toDate = new Date(toParam);
toDate.setHours(23, 59, 59, 999);
conditions.push(lte(companyAccountTransactions.occurredAt, toDate));
}
if (typeParam) {
conditions.push(eq(companyAccountTransactions.type, typeParam as never));
}
if (qParam && qParam.trim()) {
const pattern = `%${qParam.trim()}%`;
conditions.push(
or(
ilike(companyAccountTransactions.description, pattern),
ilike(companyAccountTransactions.reference, pattern)
)!
);
}
const counterparty = alias(companyAccounts, 'counterparty');
const rows = await db
.select({
id: companyAccountTransactions.id,
occurredAt: companyAccountTransactions.occurredAt,
type: companyAccountTransactions.type,
amount: companyAccountTransactions.amount,
currency: companyAccountTransactions.currency,
description: companyAccountTransactions.description,
reference: companyAccountTransactions.reference,
counterpartyName: counterparty.name,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
createdByName: users.displayName,
createdAt: companyAccountTransactions.createdAt
})
.from(companyAccountTransactions)
.leftJoin(counterparty, eq(companyAccountTransactions.counterpartyAccountId, counterparty.id))
.leftJoin(users, eq(companyAccountTransactions.createdBy, users.id))
.where(and(...conditions))
.orderBy(desc(companyAccountTransactions.occurredAt), desc(companyAccountTransactions.createdAt));
const header = [
'id',
'occurredAt',
'type',
'amount',
'currency',
'description',
'reference',
'counterparty',
'fxRate',
'fxAmount',
'sourceExpenseId',
'sourceInvoiceId',
'sourceExternalTransactionId',
'createdBy',
'createdAt'
];
const csvRows: unknown[][] = [header];
for (const r of rows) {
csvRows.push([
r.id,
r.occurredAt.toISOString(),
r.type,
r.amount,
r.currency,
r.description ?? '',
r.reference ?? '',
r.counterpartyName ?? '',
r.fxRate ?? '',
r.fxAmount ?? '',
r.sourceExpenseId ?? '',
r.sourceInvoiceId ?? '',
r.sourceExternalTransactionId ?? '',
r.createdByName ?? '',
r.createdAt.toISOString()
]);
}
const safeName = account.name.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 60) || 'account';
const filename = `${safeName}-transactions.csv`;
return new Response(withBom(csvBuild(csvRows)), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'private, no-store'
}
});
};
@@ -0,0 +1,579 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
recurringBills,
companyAccounts,
companyServiceAccounts,
projects,
categories,
parties
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { postBillsDue } from '$lib/server/recurring-bills/poster.js';
import { computeNextDueDate, toIsoDate, type Cycle } from '$lib/server/recurring-bills/cycle.js';
import { and, asc, eq, isNull } from 'drizzle-orm';
const CYCLES = ['weekly', 'monthly', 'quarterly', 'yearly'] as const;
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseCycle(v: FormDataEntryValue | null): Cycle | null {
const s = v?.toString();
if (!s) return null;
return (CYCLES as readonly string[]).includes(s) ? (s as Cycle) : null;
}
function parseAmount(v: FormDataEntryValue | null): string | null {
const s = trimOrNull(v);
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n < 0) return null;
return n.toFixed(2);
}
function parseInt0(v: FormDataEntryValue | null): number | null {
const s = trimOrNull(v);
if (!s) return null;
const n = Number(s);
if (!Number.isInteger(n)) return null;
return n;
}
function parseIsoDate(v: FormDataEntryValue | null): string | null {
const s = trimOrNull(v);
if (!s) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const d = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
return s;
}
type BillFormFields = {
name: string;
amount: string;
cycle: Cycle;
accountId: string;
projectId: string;
categoryId: string | null;
partyId: string | null;
serviceAccountId: string | null;
description: string | null;
currency: string;
startDate: string;
endDate: string | null;
dayOfCycle: number | null;
};
function extractFields(fd: FormData): BillFormFields | string {
const name = trimOrNull(fd.get('name'));
const amount = parseAmount(fd.get('amount'));
const cycle = parseCycle(fd.get('cycle'));
const accountId = trimOrNull(fd.get('accountId'));
const projectId = trimOrNull(fd.get('projectId'));
const startDate = parseIsoDate(fd.get('startDate'));
if (!name) return 'Name is required';
if (!amount) return 'Valid amount is required';
if (!cycle) return 'Invalid cycle';
if (!accountId) return 'Account is required';
if (!projectId) return 'Project is required';
if (!startDate) return 'Valid start date is required';
const currency = trimOrNull(fd.get('currency')) ?? 'THB';
const endDate = parseIsoDate(fd.get('endDate'));
const endDateRaw = trimOrNull(fd.get('endDate'));
if (endDateRaw && !endDate) return 'Invalid end date';
const dayOfCycle = parseInt0(fd.get('dayOfCycle'));
if (fd.get('dayOfCycle') && dayOfCycle === null) return 'Invalid day of cycle';
if (dayOfCycle !== null) {
if (cycle === 'weekly' && (dayOfCycle < 0 || dayOfCycle > 6)) {
return 'Weekly day must be 0 (Sun) 6 (Sat)';
}
if (cycle !== 'weekly' && (dayOfCycle < 1 || dayOfCycle > 31)) {
return 'Day of cycle must be 1 31';
}
}
return {
name,
amount,
cycle,
accountId,
projectId,
categoryId: trimOrNull(fd.get('categoryId')),
partyId: trimOrNull(fd.get('partyId')),
serviceAccountId: trimOrNull(fd.get('serviceAccountId')),
description: trimOrNull(fd.get('description')),
currency,
startDate,
endDate,
dayOfCycle
};
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const [billRows, accountRows, projectRows, categoryRows, partyRows, serviceAccountRows] =
await Promise.all([
db
.select({
id: recurringBills.id,
name: recurringBills.name,
description: recurringBills.description,
cycle: recurringBills.cycle,
defaultAmount: recurringBills.defaultAmount,
nextCycleAmount: recurringBills.nextCycleAmount,
currency: recurringBills.currency,
dayOfCycle: recurringBills.dayOfCycle,
startDate: recurringBills.startDate,
endDate: recurringBills.endDate,
nextDueDate: recurringBills.nextDueDate,
lastPostedDate: recurringBills.lastPostedDate,
status: recurringBills.status,
pausedAt: recurringBills.pausedAt,
skipNext: recurringBills.skipNext,
accountId: recurringBills.accountId,
accountName: companyAccounts.name,
projectId: recurringBills.projectId,
projectName: projects.name,
categoryId: recurringBills.categoryId,
categoryName: categories.name,
partyId: recurringBills.partyId,
partyName: parties.name,
serviceAccountId: recurringBills.serviceAccountId,
serviceAccountProvider: companyServiceAccounts.providerName,
serviceAccountNumber: companyServiceAccounts.accountNumber,
createdAt: recurringBills.createdAt,
updatedAt: recurringBills.updatedAt
})
.from(recurringBills)
.leftJoin(companyAccounts, eq(recurringBills.accountId, companyAccounts.id))
.leftJoin(projects, eq(recurringBills.projectId, projects.id))
.leftJoin(categories, eq(recurringBills.categoryId, categories.id))
.leftJoin(parties, eq(recurringBills.partyId, parties.id))
.leftJoin(companyServiceAccounts, eq(recurringBills.serviceAccountId, companyServiceAccounts.id))
.where(
and(eq(recurringBills.companyId, params.companyId), isNull(recurringBills.deletedAt))
)
.orderBy(asc(recurringBills.status), asc(recurringBills.nextDueDate)),
db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt),
eq(companyAccounts.isArchived, false)
)
)
.orderBy(asc(companyAccounts.name)),
db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
.orderBy(asc(projects.name)),
db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.companyId, params.companyId))
.orderBy(asc(categories.name)),
db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
.orderBy(asc(parties.name)),
db
.select({
id: companyServiceAccounts.id,
type: companyServiceAccounts.type,
providerName: companyServiceAccounts.providerName,
accountNumber: companyServiceAccounts.accountNumber,
customLabel: companyServiceAccounts.customLabel
})
.from(companyServiceAccounts)
.where(
and(
eq(companyServiceAccounts.companyId, params.companyId),
isNull(companyServiceAccounts.deletedAt),
eq(companyServiceAccounts.isActive, true)
)
)
.orderBy(asc(companyServiceAccounts.type), asc(companyServiceAccounts.providerName))
]);
return {
bills: billRows,
accounts: accountRows,
projects: projectRows,
categories: categoryRows,
parties: partyRows,
serviceAccounts: serviceAccountRows
};
};
export const actions: Actions = {
createBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const parsed = extractFields(fd);
if (typeof parsed === 'string') {
return fail(400, { action: 'createBill', error: parsed });
}
// Verify account + project belong to this company
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, parsed.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { action: 'createBill', error: 'Account not found' });
const [proj] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, parsed.projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!proj) return fail(400, { action: 'createBill', error: 'Project not found' });
const dayOfCycle =
parsed.dayOfCycle ??
(parsed.cycle === 'weekly'
? new Date(`${parsed.startDate}T00:00:00Z`).getUTCDay()
: new Date(`${parsed.startDate}T00:00:00Z`).getUTCDate());
const nextDueDate = toIsoDate(
computeNextDueDate(
parsed.startDate,
parsed.cycle,
dayOfCycle,
new Date(`${parsed.startDate}T00:00:00Z`)
)
);
const [inserted] = await db
.insert(recurringBills)
.values({
companyId: params.companyId,
projectId: parsed.projectId,
accountId: parsed.accountId,
categoryId: parsed.categoryId,
partyId: parsed.partyId,
serviceAccountId: parsed.serviceAccountId,
name: parsed.name,
description: parsed.description,
cycle: parsed.cycle,
defaultAmount: parsed.amount,
currency: parsed.currency,
dayOfCycle,
startDate: parsed.startDate,
endDate: parsed.endDate,
nextDueDate,
status: 'active',
createdBy: user.id
})
.returning({ id: recurringBills.id });
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_created',
`Recurring bill "${parsed.name}" created (${parsed.cycle}, ${parsed.amount} ${parsed.currency})`,
{ billId: inserted.id, cycle: parsed.cycle, amount: parsed.amount }
);
return { success: true, action: 'createBill' };
},
updateBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'updateBill', error: 'Bill id is required' });
const [existing] = await db
.select()
.from(recurringBills)
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Bill not found');
const parsed = extractFields(fd);
if (typeof parsed === 'string') {
return fail(400, { action: 'updateBill', error: parsed });
}
const dayOfCycle =
parsed.dayOfCycle ??
(parsed.cycle === 'weekly'
? new Date(`${parsed.startDate}T00:00:00Z`).getUTCDay()
: new Date(`${parsed.startDate}T00:00:00Z`).getUTCDate());
const scheduleChanged =
existing.startDate !== parsed.startDate ||
existing.cycle !== parsed.cycle ||
existing.dayOfCycle !== dayOfCycle;
const nextDueDate =
existing.lastPostedDate === null && scheduleChanged
? toIsoDate(
computeNextDueDate(
parsed.startDate,
parsed.cycle,
dayOfCycle,
new Date(`${parsed.startDate}T00:00:00Z`)
)
)
: existing.nextDueDate;
await db
.update(recurringBills)
.set({
projectId: parsed.projectId,
accountId: parsed.accountId,
categoryId: parsed.categoryId,
partyId: parsed.partyId,
serviceAccountId: parsed.serviceAccountId,
name: parsed.name,
description: parsed.description,
cycle: parsed.cycle,
defaultAmount: parsed.amount,
currency: parsed.currency,
dayOfCycle,
startDate: parsed.startDate,
endDate: parsed.endDate,
nextDueDate,
updatedAt: new Date()
})
.where(eq(recurringBills.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_updated',
`Recurring bill "${parsed.name}" updated`,
{ billId: id }
);
return { success: true, action: 'updateBill' };
},
deleteBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteBill', error: 'Bill id is required' });
const [existing] = await db
.select({ id: recurringBills.id, name: recurringBills.name })
.from(recurringBills)
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Bill not found');
await db
.update(recurringBills)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(recurringBills.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_deleted',
`Recurring bill "${existing.name}" deleted`,
{ billId: id }
);
return { success: true, action: 'deleteBill' };
},
pauseBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'pauseBill', error: 'Bill id is required' });
const result = await db
.update(recurringBills)
.set({ status: 'paused', pausedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.returning({ name: recurringBills.name });
if (result.length === 0) error(404, 'Bill not found');
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_paused',
`Recurring bill "${result[0].name}" paused`,
{ billId: id }
);
return { success: true, action: 'pauseBill' };
},
resumeBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'resumeBill', error: 'Bill id is required' });
const result = await db
.update(recurringBills)
.set({ status: 'active', pausedAt: null, updatedAt: new Date() })
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.returning({ name: recurringBills.name });
if (result.length === 0) error(404, 'Bill not found');
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_resumed',
`Recurring bill "${result[0].name}" resumed`,
{ billId: id }
);
return { success: true, action: 'resumeBill' };
},
skipNextBill: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'skipNextBill', error: 'Bill id is required' });
const result = await db
.update(recurringBills)
.set({ skipNext: true, updatedAt: new Date() })
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
)
.returning({ name: recurringBills.name, nextDueDate: recurringBills.nextDueDate });
if (result.length === 0) error(404, 'Bill not found');
await logCompanyEvent(
params.companyId,
user.id,
'recurring_bill_skipped',
`Next cycle (${result[0].nextDueDate}) of "${result[0].name}" will be skipped`,
{ billId: id, intent: true }
);
return { success: true, action: 'skipNextBill' };
},
setNextCycleAmount: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'setNextCycleAmount', error: 'Bill id is required' });
const amountRaw = fd.get('amount');
const cleared = amountRaw === null || amountRaw === '';
const amount = cleared ? null : parseAmount(amountRaw);
if (!cleared && !amount) return fail(400, { action: 'setNextCycleAmount', error: 'Valid amount required' });
await db
.update(recurringBills)
.set({ nextCycleAmount: amount, updatedAt: new Date() })
.where(
and(
eq(recurringBills.id, id),
eq(recurringBills.companyId, params.companyId),
isNull(recurringBills.deletedAt)
)
);
return { success: true, action: 'setNextCycleAmount' };
},
runBillsNow: async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const result = await postBillsDue(params.companyId, new Date());
return {
success: true,
action: 'runBillsNow',
postedCount: result.postedCount,
skippedCount: result.skippedCount,
errorCount: result.errors.length,
errors: result.errors.map((e) => `${e.billId}: ${e.error}`)
};
}
};
@@ -0,0 +1,593 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
import { formatDate } from '$lib/utils/date.js';
let { data, form }: { data: PageData; form: ActionData } = $props();
type StatusFilter = 'all' | 'active' | 'paused' | 'ended';
let statusFilter = $state<StatusFilter>('all');
let showAddForm = $state(false);
let editingBillId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
const CYCLE_LABELS: Record<string, string> = {
weekly: 'Weekly',
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly'
};
const STATUS_BADGE: Record<string, string> = {
active: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
paused: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
ended: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
};
const todayIso = new Date().toISOString().slice(0, 10);
const filteredBills = $derived(
statusFilter === 'all' ? data.bills : data.bills.filter((b) => b.status === statusFilter)
);
function isOverdue(bill: (typeof data.bills)[number]): boolean {
return bill.status === 'active' && bill.nextDueDate < todayIso;
}
function formatAmount(value: string, currency: string): string {
const n = Number(value);
if (!Number.isFinite(n)) return value;
return `${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
}
function startEdit(id: string) {
editingBillId = id;
confirmDeleteId = null;
showAddForm = false;
}
function openAdd() {
showAddForm = !showAddForm;
editingBillId = null;
confirmDeleteId = null;
}
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>Bills - {data.company.name}</title>
</svelte:head>
{#snippet billForm(
action: string,
values: {
name?: string;
amount?: string;
cycle?: string;
accountId?: string;
projectId?: string;
categoryId?: string;
partyId?: string;
serviceAccountId?: string;
description?: string;
currency?: string;
startDate?: string;
endDate?: string;
dayOfCycle?: string;
} = {},
billId?: string
)}
<form
method="POST"
{action}
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
if (!billId) {
showAddForm = false;
formElement.reset();
} else {
editingBillId = null;
}
}
}}
class="mt-4 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-4 dark:bg-gray-700/50 md:grid-cols-2"
>
{#if billId}
<input type="hidden" name="id" value={billId} />
{/if}
<div class="md:col-span-2">
<label class={labelCls} for="bill-name-{billId ?? 'new'}">Name <span class="text-red-500">*</span></label>
<input
id="bill-name-{billId ?? 'new'}"
name="name"
type="text"
required
value={values.name ?? ''}
class={inputCls}
placeholder="e.g. Office rent"
/>
</div>
<div>
<label class={labelCls} for="bill-amount-{billId ?? 'new'}">Amount <span class="text-red-500">*</span></label>
<input
id="bill-amount-{billId ?? 'new'}"
name="amount"
type="number"
step="0.01"
min="0"
required
value={values.amount ?? ''}
class={inputCls}
/>
</div>
<div>
<label class={labelCls} for="bill-currency-{billId ?? 'new'}">Currency</label>
<input
id="bill-currency-{billId ?? 'new'}"
name="currency"
type="text"
value={values.currency ?? 'THB'}
class={inputCls}
/>
</div>
<div>
<label class={labelCls} for="bill-cycle-{billId ?? 'new'}">Cycle <span class="text-red-500">*</span></label>
<select id="bill-cycle-{billId ?? 'new'}" name="cycle" required value={values.cycle ?? 'monthly'} class={inputCls}>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div>
<label class={labelCls} for="bill-dayOfCycle-{billId ?? 'new'}">Day of cycle</label>
<input
id="bill-dayOfCycle-{billId ?? 'new'}"
name="dayOfCycle"
type="number"
value={values.dayOfCycle ?? ''}
class={inputCls}
placeholder="06 weekly · 131 monthly/qtr/yr"
/>
</div>
<div>
<label class={labelCls} for="bill-account-{billId ?? 'new'}">Account <span class="text-red-500">*</span></label>
<select
id="bill-account-{billId ?? 'new'}"
name="accountId"
required
value={values.accountId ?? ''}
class={inputCls}
>
<option value="" disabled>Select an account</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-project-{billId ?? 'new'}">Project <span class="text-red-500">*</span></label>
<select
id="bill-project-{billId ?? 'new'}"
name="projectId"
required
value={values.projectId ?? ''}
class={inputCls}
>
<option value="" disabled>Select a project</option>
{#each data.projects as proj (proj.id)}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-category-{billId ?? 'new'}">Category</label>
<select
id="bill-category-{billId ?? 'new'}"
name="categoryId"
value={values.categoryId ?? ''}
class={inputCls}
>
<option value=""></option>
{#each data.categories as cat (cat.id)}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-party-{billId ?? 'new'}">Vendor / Party</label>
<select
id="bill-party-{billId ?? 'new'}"
name="partyId"
value={values.partyId ?? ''}
class={inputCls}
>
<option value=""></option>
{#each data.parties as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-sa-{billId ?? 'new'}">Service Account</label>
<select
id="bill-sa-{billId ?? 'new'}"
name="serviceAccountId"
value={values.serviceAccountId ?? ''}
class={inputCls}
>
<option value=""></option>
{#each data.serviceAccounts as sa (sa.id)}
<option value={sa.id}>[{sa.type}] {sa.providerName} #{sa.accountNumber}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls} for="bill-start-{billId ?? 'new'}">Start date <span class="text-red-500">*</span></label>
<input
id="bill-start-{billId ?? 'new'}"
name="startDate"
type="date"
required
value={values.startDate ?? todayIso}
class={inputCls}
/>
</div>
<div>
<label class={labelCls} for="bill-end-{billId ?? 'new'}">End date</label>
<input
id="bill-end-{billId ?? 'new'}"
name="endDate"
type="date"
value={values.endDate ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label class={labelCls} for="bill-desc-{billId ?? 'new'}">Description</label>
<textarea
id="bill-desc-{billId ?? 'new'}"
name="description"
rows="2"
class={inputCls}>{values.description ?? ''}</textarea
>
</div>
<div class="md:col-span-2 flex justify-end gap-2">
<button
type="button"
onclick={() => {
if (billId) editingBillId = null;
else showAddForm = false;
}}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{billId ? 'Save' : 'Create Bill'}
</button>
</div>
</form>
{/snippet}
<div class="space-y-6">
<header class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Recurring Bills</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Auto-post rent, utilities, SaaS, and other recurring expenses on their due date. Scheduler
runs every 15 minutes; use "Run Now" to trigger an immediate pass.
</p>
</div>
<form
method="POST"
action="?/runBillsNow"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
}}
>
<button
type="submit"
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
Run Now
</button>
</form>
</header>
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
{#if form?.action === 'runBillsNow'}
<div
class="rounded-md border border-gray-200 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<p class="font-medium text-gray-900 dark:text-white">
Posted: {form.postedCount} · Skipped: {form.skippedCount} · Errors: {form.errorCount}
</p>
{#if form.errors && form.errors.length > 0}
<ul class="mt-2 list-inside list-disc text-xs text-red-600 dark:text-red-400">
{#each form.errors as err (err)}
<li>{err}</li>
{/each}
</ul>
{/if}
</div>
{/if}
<section
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Add Bill</h2>
<button
type="button"
onclick={openAdd}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddForm ? 'Cancel' : '+ New Bill'}
</button>
</div>
{#if showAddForm}
{@render billForm('?/createBill', { currency: 'THB', startDate: todayIso, cycle: 'monthly' })}
{/if}
</section>
<div class="flex flex-wrap gap-2">
{#each ['all', 'active', 'paused', 'ended'] as s (s)}
<button
type="button"
onclick={() => (statusFilter = s as StatusFilter)}
class="rounded-full px-3 py-1 text-xs font-medium {statusFilter === s
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
{/each}
</div>
{#if filteredBills.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
No {statusFilter === 'all' ? '' : statusFilter} bills yet.
</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Amount</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Cycle</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Account</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Next Due</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each filteredBills as bill (bill.id)}
<tr class="align-top">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white">{bill.name}</div>
{#if bill.projectName}
<div class="text-xs text-gray-500 dark:text-gray-400">
Project: {bill.projectName}
</div>
{/if}
{#if bill.partyName}
<div class="text-xs text-gray-500 dark:text-gray-400">
Vendor: {bill.partyName}
</div>
{/if}
{#if bill.serviceAccountProvider}
<div class="text-xs text-indigo-600 dark:text-indigo-400">
{bill.serviceAccountProvider} #{bill.serviceAccountNumber}
</div>
{/if}
{#if bill.status === 'paused' && bill.pausedAt}
<div class="text-xs text-amber-600 dark:text-amber-400">
Paused since {formatDate(bill.pausedAt)}
</div>
{/if}
{#if bill.skipNext}
<div class="text-xs text-blue-600 dark:text-blue-400">Next cycle will be skipped</div>
{/if}
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
{formatAmount(bill.defaultAmount, bill.currency)}
{#if bill.nextCycleAmount}
<div class="text-xs text-blue-600 dark:text-blue-400">
Override: {formatAmount(bill.nextCycleAmount, bill.currency)}
</div>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
{CYCLE_LABELS[bill.cycle] ?? bill.cycle}
{#if bill.dayOfCycle !== null}
<div class="text-xs text-gray-400">day {bill.dayOfCycle}</div>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
{bill.accountName ?? '—'}
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
<span class={isOverdue(bill) ? 'font-medium text-red-600 dark:text-red-400' : ''}>
{bill.nextDueDate}
</span>
{#if isOverdue(bill)}
<div class="text-xs text-red-500">Overdue</div>
{/if}
{#if bill.lastPostedDate}
<div class="text-xs text-gray-400">
Last: {bill.lastPostedDate}
</div>
{/if}
</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[bill.status] ??
'bg-gray-100 text-gray-700'}"
>
{bill.status}
</span>
</td>
<td class="px-4 py-3 text-right text-xs">
<div class="flex flex-wrap justify-end gap-2">
<button
type="button"
onclick={() => startEdit(bill.id)}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
{#if bill.status === 'active'}
<form
method="POST"
action="?/pauseBill"
use:enhance={() => async ({ update }) => await update({ reset: false })}
>
<input type="hidden" name="id" value={bill.id} />
<button type="submit" class="font-medium text-amber-600 hover:text-amber-700 dark:text-amber-400">
Pause
</button>
</form>
<form
method="POST"
action="?/skipNextBill"
use:enhance={() => async ({ update }) => await update({ reset: false })}
>
<input type="hidden" name="id" value={bill.id} />
<button type="submit" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
Skip next
</button>
</form>
{:else if bill.status === 'paused'}
<form
method="POST"
action="?/resumeBill"
use:enhance={() => async ({ update }) => await update({ reset: false })}
>
<input type="hidden" name="id" value={bill.id} />
<button type="submit" class="font-medium text-emerald-600 hover:text-emerald-700 dark:text-emerald-400">
Resume
</button>
</form>
{/if}
<button
type="button"
onclick={() => (confirmDeleteId = confirmDeleteId === bill.id ? null : bill.id)}
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
</td>
</tr>
{#if confirmDeleteId === bill.id}
<tr>
<td colspan="7" class="bg-red-50 px-4 py-3 dark:bg-red-900/20">
<form
method="POST"
action="?/deleteBill"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
confirmDeleteId = null;
}}
class="flex items-center justify-between gap-3 text-xs"
>
<input type="hidden" name="id" value={bill.id} />
<p class="text-red-700 dark:text-red-300">
Delete "{bill.name}"? This soft-deletes the bill; already-posted expenses remain.
</p>
<div class="flex gap-2">
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
</td>
</tr>
{/if}
{#if editingBillId === bill.id}
<tr>
<td colspan="7" class="bg-gray-50 px-4 py-3 dark:bg-gray-700/30">
{@render billForm(
'?/updateBill',
{
name: bill.name,
amount: bill.defaultAmount,
cycle: bill.cycle,
accountId: bill.accountId,
projectId: bill.projectId,
categoryId: bill.categoryId ?? '',
partyId: bill.partyId ?? '',
serviceAccountId: bill.serviceAccountId ?? '',
description: bill.description ?? '',
currency: bill.currency,
startDate: bill.startDate,
endDate: bill.endDate ?? '',
dayOfCycle: bill.dayOfCycle?.toString() ?? ''
},
bill.id
)}
<form
method="POST"
action="?/setNextCycleAmount"
use:enhance={() => async ({ update }) => await update({ reset: false })}
class="mt-3 flex flex-wrap items-end gap-2 rounded-md bg-white p-3 text-xs dark:bg-gray-800"
>
<input type="hidden" name="id" value={bill.id} />
<div>
<label class={labelCls} for="override-{bill.id}">Next-cycle amount override</label>
<input
id="override-{bill.id}"
name="amount"
type="number"
step="0.01"
min="0"
value={bill.nextCycleAmount ?? ''}
class={inputCls}
placeholder="Leave blank to clear"
/>
</div>
<button
type="submit"
class="rounded-md bg-gray-700 px-3 py-2 font-medium text-white hover:bg-gray-800 dark:bg-gray-600"
>
Save override
</button>
</form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -7,9 +7,12 @@ import {
companies, companies,
users, users,
expenses, expenses,
companyLog companyLog,
sales,
saleLineItems,
companyAccounts
} from '$lib/server/db/schema.js'; } from '$lib/server/db/schema.js';
import { eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
@@ -17,19 +20,50 @@ import { formatCurrency } from '$lib/utils/currency.js';
export const load: PageServerLoad = async ({ parent, params }) => { export const load: PageServerLoad = async ({ parent, params }) => {
const { company } = await parent(); const { company } = await parent();
const projectList = await db const projectListRaw = await db
.select({ .select({
id: projects.id, id: projects.id,
name: projects.name, name: projects.name,
allocatedBudget: projects.allocatedBudget, allocatedBudget: projects.allocatedBudget,
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)` spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`
}) })
.from(projects) .from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id)) .leftJoin(expenses, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(eq(projects.companyId, params.companyId)) .where(eq(projects.companyId, params.companyId))
.groupBy(projects.id) .groupBy(projects.id)
.orderBy(projects.name); .orderBy(projects.name);
// Income per project from confirmed sales (gross - withholding = net receivable)
const incomeRows = await db
.select({
projectId: sales.projectId,
income: sql<string>`coalesce(sum(
(select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate})) from sale_line_items where sale_id = ${sales.id})
* (1 - ${sales.withholdingTaxRate})
), '0')::text`
})
.from(sales)
.where(
and(
eq(sales.companyId, params.companyId),
eq(sales.status, 'confirmed')
)
)
.groupBy(sales.projectId);
const incomeByProject = new Map<string | null, string>();
for (const row of incomeRows) {
incomeByProject.set(row.projectId, row.income);
}
const projectList = projectListRaw.map((p) => ({
...p,
income: incomeByProject.get(p.id) ?? '0'
}));
const unassignedIncome = incomeByProject.get(null) ?? '0';
const allocations = await db const allocations = await db
.select({ .select({
id: budgetAllocations.id, id: budgetAllocations.id,
@@ -64,48 +98,20 @@ export const load: PageServerLoad = async ({ parent, params }) => {
.limit(100); .limit(100);
const totalAllocated = projectList.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0); const totalAllocated = projectList.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0);
const totalIncome =
projectList.reduce((s, p) => s + parseFloat(p.income), 0) + parseFloat(unassignedIncome);
return { projects: projectList, allocations, totalAllocated, changelog }; return {
projects: projectList,
allocations,
totalAllocated,
totalIncome,
unassignedIncome,
changelog
};
}; };
export const actions: Actions = { export const actions: Actions = {
addBudget: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const amount = parseFloat(formData.get('amount')?.toString() || '0');
if (isNaN(amount) || amount <= 0) {
return fail(400, { error: 'Amount must be a positive number' });
}
// Get current budget for the log
const [company] = await db
.select({ totalBudget: companies.totalBudget, currency: companies.currency })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
await db
.update(companies)
.set({
totalBudget: sql`${companies.totalBudget}::numeric + ${amount.toFixed(2)}::numeric`,
updatedAt: new Date()
})
.where(eq(companies.id, params.companyId));
const newTotal = parseFloat(company.totalBudget) + amount;
await logCompanyEvent(
params.companyId,
user.id,
'budget_added',
`Budget increased by ${formatCurrency(amount, company.currency)} (new total: ${formatCurrency(newTotal, company.currency)})`,
{ amount: amount.toFixed(2), previousTotal: company.totalBudget, newTotal: newTotal.toFixed(2) }
);
return { success: true };
},
allocate: async ({ request, locals, params }) => { allocate: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager'); const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
@@ -118,12 +124,13 @@ export const actions: Actions = {
return fail(400, { error: 'Project and non-zero amount are required' }); return fail(400, { error: 'Project and non-zero amount are required' });
} }
// Get project name and company currency for the log // Verify project belongs to this company
const [project] = await db const [project] = await db
.select({ name: projects.name }) .select({ name: projects.name })
.from(projects) .from(projects)
.where(eq(projects.id, projectId)) .where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
.limit(1); .limit(1);
if (!project) return fail(400, { error: 'Project not found in this company' });
const [company] = await db const [company] = await db
.select({ currency: companies.currency }) .select({ currency: companies.currency })
@@ -137,7 +144,7 @@ export const actions: Actions = {
allocatedBudget: sql`${projects.allocatedBudget}::numeric + ${amount.toFixed(2)}::numeric`, allocatedBudget: sql`${projects.allocatedBudget}::numeric + ${amount.toFixed(2)}::numeric`,
updatedAt: new Date() updatedAt: new Date()
}) })
.where(eq(projects.id, projectId)); .where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)));
await db.insert(budgetAllocations).values({ await db.insert(budgetAllocations).values({
companyId: params.companyId, companyId: params.companyId,
@@ -14,7 +14,7 @@
const canAllocate = $derived(data.companyRoles.includes('admin') || data.companyRoles.includes('manager')); const canAllocate = $derived(data.companyRoles.includes('admin') || data.companyRoles.includes('manager'));
const isAdmin = $derived(data.companyRoles.includes('admin')); const isAdmin = $derived(data.companyRoles.includes('admin'));
let showAddBudget = $state(false); // Budget total now comes from account balances — no manual add
function getEventStyle(event: string) { function getEventStyle(event: string) {
const styles: Record<string, { icon: string; bg: string; text: string; badge: string; label: string }> = { const styles: Record<string, { icon: string; bg: string; text: string; badge: string; label: string }> = {
@@ -44,65 +44,17 @@
</svelte:head> </svelte:head>
<div> <div>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Budget Allocation</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Budget Allocation</h2>
{#if isAdmin} <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<button Total budget reflects the sum of your account balances. Manage funds via the Accounts tab.
onclick={() => (showAddBudget = !showAddBudget)} </p>
class="flex items-center gap-1 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Budget
</button>
{/if}
</div> </div>
{#if form?.error} {#if form?.error}
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-300">{form.error}</div> <div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-300">{form.error}</div>
{/if} {/if}
<!-- Add Budget form (admin only) -->
{#if showAddBudget && isAdmin}
<div class="mb-6 rounded-lg border-2 border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/30 p-5">
<h3 class="mb-3 font-medium text-gray-900 dark:text-white">Replenish Company Budget</h3>
<form method="POST" action="?/addBudget" use:enhance={() => {
return async ({ update }) => {
await update();
showAddBudget = false;
};
}} class="flex items-end gap-3">
<div class="flex-1">
<label for="addAmount" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Amount to Add ({currency})</label>
<input
type="number"
id="addAmount"
name="amount"
step="0.01"
min="0.01"
required
placeholder="e.g. 100000"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-green-500 focus:ring-1 focus:ring-green-500 focus:outline-none"
/>
</div>
<button
type="submit"
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
Add to Budget
</button>
<button
type="button"
onclick={() => (showAddBudget = false)}
class="rounded-md px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Cancel
</button>
</form>
</div>
{/if}
<!-- Summary --> <!-- Summary -->
<div class="mb-6 grid gap-4 sm:grid-cols-4"> <div class="mb-6 grid gap-4 sm:grid-cols-4">
<!-- Remaining — hero card --> <!-- Remaining — hero card -->
@@ -41,8 +41,9 @@ export const GET: RequestHandler = async ({ locals, params }) => {
return new Response(new Blob([buf as BlobPart], { type: row.mimeType }), { return new Response(new Blob([buf as BlobPart], { type: row.mimeType }), {
headers: { headers: {
'Content-Disposition': `inline; filename="${safeName}"`, 'Content-Disposition': `attachment; filename="${safeName}"`,
'Cache-Control': 'private, no-store' 'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
} }
}); });
}; };
@@ -1,18 +1,35 @@
import { fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { expenses, projects, users, categories } from '$lib/server/db/schema.js'; import {
import { eq, and, sql } from 'drizzle-orm'; expenses,
import { requireCompanyRole } from '$lib/server/authorization.js'; projects,
users,
categories,
companyAccounts,
companies,
invoices,
parties,
packages,
expensePackages
} from '$lib/server/db/schema.js';
import { asc, eq, and, ne, sql, isNull } from 'drizzle-orm';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
import {
postExpenseTransaction,
removeExpenseTransaction
} from '$lib/server/accounts/ledger.js';
import { saveCompanyFile, isAllowedMime, MAX_BYTES } from '$lib/server/uploads/index.js';
import { uploadToPaperless, isPaperlessEnabled } from '$lib/server/paperless/index.js';
export const load: PageServerLoad = async ({ parent, params, url }) => { export const load: PageServerLoad = async ({ parent, params, url }) => {
await parent(); await parent();
const status = url.searchParams.get('status') || 'all'; const status = url.searchParams.get('status') || 'all';
let query = db const expenseList = await db
.select({ .select({
id: expenses.id, id: expenses.id,
title: expenses.title, title: expenses.title,
@@ -28,12 +45,19 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
projectId: projects.id, projectId: projects.id,
projectName: projects.name, projectName: projects.name,
categoryName: categories.name, categoryName: categories.name,
accountId: expenses.accountId,
accountName: companyAccounts.name,
invoiceId: expenses.invoiceId,
invoiceFileUrl: expenses.invoiceFileUrl,
invoiceFileName: expenses.invoiceFileName,
paperlessUrl: expenses.paperlessUrl,
createdAt: expenses.createdAt createdAt: expenses.createdAt
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.innerJoin(users, eq(expenses.submittedBy, users.id)) .innerJoin(users, eq(expenses.submittedBy, users.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id)) .leftJoin(categories, eq(expenses.categoryId, categories.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
status === 'all' status === 'all'
? eq(projects.companyId, params.companyId) ? eq(projects.companyId, params.companyId)
@@ -45,12 +69,182 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
.orderBy(sql`${expenses.createdAt} desc`) .orderBy(sql`${expenses.createdAt} desc`)
.limit(100); .limit(100);
const expenseList = await query; const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
return { expenses: expenseList, statusFilter: status }; const projectList = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
.orderBy(asc(projects.name));
const categoryList = await db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.companyId, params.companyId))
.orderBy(asc(categories.name));
const invoiceList = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction,
partyName: parties.name,
total: invoices.total,
currency: invoices.currency
})
.from(invoices)
.innerJoin(parties, eq(invoices.partyId, parties.id))
.where(
and(
eq(invoices.companyId, params.companyId),
ne(invoices.status, 'voided'),
ne(invoices.status, 'cancelled')
)
)
.orderBy(asc(invoices.invoiceNumber));
const packageList = await db
.select({
id: packages.id,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
direction: packages.direction,
status: packages.status
})
.from(packages)
.where(eq(packages.companyId, params.companyId))
.orderBy(sql`${packages.createdAt} desc`);
const expensePackageLinks = await db
.select({
expenseId: expensePackages.expenseId,
packageId: expensePackages.packageId
})
.from(expensePackages)
.innerJoin(expenses, eq(expensePackages.expenseId, expenses.id))
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, params.companyId));
return {
expenses: expenseList,
statusFilter: status,
accounts: accountsList,
projects: projectList,
categories: categoryList,
invoices: invoiceList,
packages: packageList,
expensePackageLinks
};
}; };
async function ensureGeneralProject(companyId: string): Promise<string> {
const [existing] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.companyId, companyId), eq(projects.name, 'General')))
.limit(1);
if (existing) return existing.id;
const [created] = await db
.insert(projects)
.values({ companyId, name: 'General', description: 'Company-wide expenses' })
.returning({ id: projects.id });
return created.id;
}
export const actions: Actions = { export const actions: Actions = {
submitExpense: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'hr', 'accountant'
]);
const fd = await request.formData();
const title = fd.get('title')?.toString().trim();
const amountStr = fd.get('amount')?.toString().trim();
const projectId = fd.get('projectId')?.toString().trim() || null;
const categoryId = fd.get('categoryId')?.toString().trim() || null;
const accountId = fd.get('accountId')?.toString().trim() || null;
const invoiceId = fd.get('invoiceId')?.toString().trim() || null;
const expenseDate = fd.get('expenseDate')?.toString().trim();
const description = fd.get('description')?.toString().trim() || null;
if (!title) return fail(400, { action: 'submitExpense', error: 'Title is required' });
if (!amountStr || isNaN(Number(amountStr)) || Number(amountStr) <= 0) {
return fail(400, { action: 'submitExpense', error: 'Valid positive amount is required' });
}
if (!expenseDate) return fail(400, { action: 'submitExpense', error: 'Date is required' });
const resolvedProjectId = projectId || (await ensureGeneralProject(params.companyId));
const [proj] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, resolvedProjectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!proj) return fail(400, { action: 'submitExpense', error: 'Project not found' });
// Resolve currency: use the selected account's currency, else company base
let resolvedCurrency = 'THB';
if (accountId) {
const [acct] = await db
.select({ currency: companyAccounts.currency })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId)
)
)
.limit(1);
if (acct) resolvedCurrency = acct.currency;
} else {
const [company] = await db
.select({ currency: companies.currency })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
if (company) resolvedCurrency = company.currency;
}
await db.insert(expenses).values({
projectId: resolvedProjectId,
categoryId: categoryId || null,
accountId: accountId || null,
invoiceId: invoiceId || null,
submittedBy: user.id,
title,
description,
amount: Number(amountStr).toFixed(2),
currency: resolvedCurrency,
expenseDate,
status: 'pending'
});
await logCompanyEvent(
params.companyId,
user.id,
'expense_submitted',
`Expense "${title}" submitted for ${formatCurrency(amountStr, 'THB')}`,
{ projectId: resolvedProjectId }
);
return { success: true, action: 'submitExpense' };
},
approve: async ({ request, locals, params }) => { approve: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager'); const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData(); const formData = await request.formData();
@@ -58,27 +252,42 @@ export const actions: Actions = {
if (!expenseId) return fail(400, { error: 'Missing expense ID' }); if (!expenseId) return fail(400, { error: 'Missing expense ID' });
// Get expense details for the log
const [expense] = await db const [expense] = await db
.select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency }) .select({
.from(expenses) title: expenses.title,
.where(eq(expenses.id, expenseId)) amount: expenses.amount,
.limit(1); currency: expenses.currency,
accountId: expenses.accountId
await db
.update(expenses)
.set({
status: 'approved',
approvedBy: user.id,
reviewedAt: new Date(),
updatedAt: new Date()
}) })
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending'))); .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!expense) return fail(404, { error: 'Expense not found' });
await db.transaction(async (tx) => {
await tx
.update(expenses)
.set({
status: 'approved',
approvedBy: user.id,
reviewedAt: new Date(),
updatedAt: new Date()
})
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
if (expense?.accountId) {
await postExpenseTransaction(expenseId, expense.accountId, user.id, tx);
}
});
if (expense) { if (expense) {
await logCompanyEvent(params.companyId, user.id, 'expense_approved', await logCompanyEvent(
params.companyId,
user.id,
'expense_approved',
`Approved expense "${expense.title}" for ${formatCurrency(expense.amount, expense.currency)}`, `Approved expense "${expense.title}" for ${formatCurrency(expense.amount, expense.currency)}`,
{ expenseId, amount: expense.amount } { expenseId, amount: expense.amount, accountId: expense.accountId }
); );
} }
@@ -96,27 +305,272 @@ export const actions: Actions = {
const [expense] = await db const [expense] = await db
.select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency }) .select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency })
.from(expenses) .from(expenses)
.where(eq(expenses.id, expenseId)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
.limit(1); .limit(1);
if (!expense) return fail(404, { error: 'Expense not found' });
await db await db.transaction(async (tx) => {
.update(expenses) await tx
.set({ .update(expenses)
status: 'rejected', .set({
approvedBy: user.id, status: 'rejected',
reviewedAt: new Date(), approvedBy: user.id,
rejectionReason: reason, reviewedAt: new Date(),
updatedAt: new Date() rejectionReason: reason,
}) updatedAt: new Date()
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending'))); })
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
// Defensive: remove any prior ledger post (e.g. if this expense was previously approved then reopened)
await removeExpenseTransaction(expenseId, tx);
});
if (expense) { if (expense) {
await logCompanyEvent(params.companyId, user.id, 'expense_rejected', await logCompanyEvent(
params.companyId,
user.id,
'expense_rejected',
`Rejected expense "${expense.title}" (${formatCurrency(expense.amount, expense.currency)})${reason ? `${reason}` : ''}`, `Rejected expense "${expense.title}" (${formatCurrency(expense.amount, expense.currency)})${reason ? `${reason}` : ''}`,
{ expenseId, amount: expense.amount, reason } { expenseId, amount: expense.amount, reason }
); );
} }
return { success: true }; return { success: true };
},
updateExpenseAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const formData = await request.formData();
const expenseId = formData.get('expenseId')?.toString();
const rawAccountId = formData.get('accountId')?.toString().trim() ?? '';
const accountId = rawAccountId === '' ? null : rawAccountId;
if (!expenseId) return fail(400, { error: 'Missing expense ID' });
const [expense] = await db
.select({
id: expenses.id,
status: expenses.status,
title: expenses.title,
accountId: expenses.accountId,
projectCompanyId: projects.companyId
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(expenses.id, expenseId))
.limit(1);
if (!expense) error(404, 'Expense not found');
if (expense.projectCompanyId !== params.companyId) error(403, 'Forbidden');
if (accountId) {
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid account' });
}
await db.transaction(async (tx) => {
await tx.update(expenses).set({ accountId, updatedAt: new Date() }).where(eq(expenses.id, expenseId));
// Only post to ledger if the expense is approved. Otherwise leave ledger untouched.
if (expense.status === 'approved') {
if (accountId) {
await postExpenseTransaction(expenseId, accountId, user.id, tx);
} else {
await removeExpenseTransaction(expenseId, tx);
}
}
});
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Expense "${expense.title}" ${accountId ? 'assigned to account' : 'unassigned from account'}`,
{ expenseId, accountId, previousAccountId: expense.accountId }
);
return { success: true };
},
linkInvoice: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const expenseId = fd.get('expenseId')?.toString();
const invoiceId = fd.get('invoiceId')?.toString().trim() || null;
if (!expenseId) return fail(400, { error: 'Expense ID required' });
await db
.update(expenses)
.set({ invoiceId, updatedAt: new Date() })
.where(eq(expenses.id, expenseId));
return { success: true };
},
uploadExpenseInvoice: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const fd = await request.formData();
const expenseId = fd.get('expenseId')?.toString();
const file = fd.get('file') as File | null;
if (!expenseId) return fail(400, { error: 'Expense ID required' });
if (!file || !(file instanceof File) || file.size === 0) {
return fail(400, { action: 'uploadExpenseInvoice', error: 'File is required' });
}
if (file.size > MAX_BYTES) {
return fail(400, {
action: 'uploadExpenseInvoice',
error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)`
});
}
const mime = file.type || 'application/octet-stream';
if (!isAllowedMime(mime)) {
return fail(400, {
action: 'uploadExpenseInvoice',
error: `File type not allowed: ${mime}`
});
}
// Verify expense belongs to this company
const [exp] = await db
.select({ id: expenses.id, title: expenses.title })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!exp) return fail(404, { error: 'Expense not found' });
let saved;
try {
saved = await saveCompanyFile(params.companyId, file);
} catch (err) {
console.error('saveCompanyFile failed', err);
return fail(500, { action: 'uploadExpenseInvoice', error: 'Failed to save file' });
}
// Fire-and-forget Paperless push if configured
let paperlessTaskId: string | null = null;
if (isPaperlessEnabled()) {
const paperlessResult = await uploadToPaperless(file, exp.title);
paperlessTaskId = paperlessResult?.taskId ?? null;
}
await db
.update(expenses)
.set({
invoiceFileUrl: saved.storedPath,
invoiceFileName: file.name,
updatedAt: new Date()
})
.where(eq(expenses.id, expenseId));
await logCompanyEvent(
params.companyId,
user.id,
'expense_invoice_uploaded',
`Invoice attached to expense "${exp.title}"`,
{ expenseId, fileName: file.name, paperlessTaskId }
);
return { success: true, action: 'uploadExpenseInvoice' };
},
linkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const expenseId = fd.get('expenseId')?.toString();
const packageId = fd.get('packageId')?.toString();
if (!expenseId || !packageId) return fail(400, { error: 'Expense and package IDs required' });
// Verify expense belongs to this company
const [exp] = await db
.select({ id: expenses.id })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!exp) return fail(404, { error: 'Expense not found' });
// Verify package belongs to this company
const [pkg] = await db
.select({ id: packages.id })
.from(packages)
.where(and(eq(packages.id, packageId), eq(packages.companyId, params.companyId)))
.limit(1);
if (!pkg) return fail(404, { error: 'Package not found' });
await db
.insert(expensePackages)
.values({ expenseId, packageId })
.onConflictDoNothing();
return { success: true, action: 'linkPackage' };
},
unlinkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const expenseId = fd.get('expenseId')?.toString();
const packageId = fd.get('packageId')?.toString();
if (!expenseId || !packageId) return fail(400, { error: 'Expense and package IDs required' });
await db
.delete(expensePackages)
.where(
and(eq(expensePackages.expenseId, expenseId), eq(expensePackages.packageId, packageId))
);
return { success: true, action: 'unlinkPackage' };
},
setExpensePaperlessLink: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const fd = await request.formData();
const expenseId = fd.get('expenseId')?.toString();
const url = fd.get('paperlessUrl')?.toString().trim() || null;
if (!expenseId) return fail(400, { error: 'Expense ID required' });
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
return fail(400, {
action: 'setExpensePaperlessLink',
error: 'URL must start with http:// or https://'
});
}
const [exp] = await db
.select({ id: expenses.id, title: expenses.title })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!exp) return fail(404, { error: 'Expense not found' });
await db
.update(expenses)
.set({ paperlessUrl: url, updatedAt: new Date() })
.where(eq(expenses.id, expenseId));
return { success: true, action: 'setExpensePaperlessLink' };
} }
}; };
@@ -3,13 +3,24 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData } from './$types'; import type { PageData, ActionData } from './$types';
let { data } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
const currency = $derived(data.company.currency); const currency = $derived(data.company.currency);
const canApprove = $derived( const canApprove = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager') data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
); );
const canAssignAccount = $derived(
data.companyRoles.includes('admin') ||
data.companyRoles.includes('manager') ||
data.companyRoles.includes('accountant')
);
let showAddForm = $state(false);
const todayIso = new Date().toISOString().slice(0, 10);
const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script> </script>
<svelte:head> <svelte:head>
@@ -19,8 +30,90 @@
<div> <div>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Expenses</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Expenses</h2>
<button type="button" onclick={() => (showAddForm = !showAddForm)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddForm ? 'Cancel' : '+ New Expense'}
</button>
</div> </div>
{#if form?.action === 'submitExpense' && form.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
{#if showAddForm}
<section class="mb-6 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 class="mb-3 font-semibold text-gray-900 dark:text-white">Add Expense</h3>
<form method="POST" action="?/submitExpense"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') { showAddForm = false; formElement.reset(); }
}}
class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="md:col-span-2">
<label for="exp-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
<input id="exp-title" name="title" type="text" required class={inputCls} placeholder="e.g. Office supplies" />
</div>
<div>
<label for="exp-amount" class={labelCls}>Amount <span class="text-red-500">*</span></label>
<input id="exp-amount" name="amount" type="number" step="0.01" min="0.01" required class={inputCls} />
</div>
<div>
<label for="exp-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input id="exp-date" name="expenseDate" type="date" required value={todayIso} class={inputCls} />
</div>
<div>
<label for="exp-project" class={labelCls}>Project</label>
<select id="exp-project" name="projectId" class={inputCls}>
<option value="">Company-wide (General)</option>
{#each data.projects as proj (proj.id)}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
</div>
<div>
<label for="exp-category" class={labelCls}>Category</label>
<select id="exp-category" name="categoryId" class={inputCls}>
<option value=""></option>
{#each data.categories as cat (cat.id)}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<div>
<label for="exp-account" class={labelCls}>Account</label>
<select id="exp-account" name="accountId" class={inputCls}>
<option value=""></option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</div>
<div>
<label for="exp-invoice" class={labelCls}>Invoice</label>
<select id="exp-invoice" name="invoiceId" class={inputCls}>
<option value=""></option>
{#each data.invoices as inv (inv.id)}
<option value={inv.id}>{inv.invoiceNumber} {inv.partyName} ({inv.direction})</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label for="exp-desc" class={labelCls}>Description</label>
<textarea id="exp-desc" name="description" rows="2" class={inputCls}></textarea>
</div>
<div class="md:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (showAddForm = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">Cancel</button>
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Submit Expense</button>
</div>
</form>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Expense will be submitted as pending. A manager can approve or reject it.
</p>
</section>
{/if}
<!-- Status filter --> <!-- Status filter -->
<div class="mb-4 flex gap-2"> <div class="mb-4 flex gap-2">
{#each ['all', 'pending', 'approved', 'rejected'] as status} {#each ['all', 'pending', 'approved', 'rejected'] as status}
@@ -42,8 +135,13 @@
</div> </div>
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each data.expenses as expense} {#each data.expenses as expense (expense.id)}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4"> {@const linkedPkgIds = data.expensePackageLinks.filter((l) => l.expenseId === expense.id).map((l) => l.packageId)}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 transition-colors hover:border-blue-400 dark:hover:border-blue-500">
<a href={`/companies/${data.company.id}/expenses/${expense.id}`}
class="mb-1 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
View details →
</a>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h3 class="font-medium text-gray-900 dark:text-white">{expense.title}</h3> <h3 class="font-medium text-gray-900 dark:text-white">{expense.title}</h3>
@@ -57,6 +155,66 @@
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate} By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate}
</p> </p>
{#if expense.accountName}
<p class="mt-1 text-xs">
<span
class="rounded-full bg-blue-100 px-2 py-0.5 font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
Account: {expense.accountName}
</span>
</p>
{/if}
{#if expense.invoiceId}
{@const inv = data.invoices.find((i) => i.id === expense.invoiceId)}
{#if inv}
<p class="mt-1 text-xs">
<a
href={`/companies/${data.company.id}/invoices/${inv.id}`}
class="rounded-full bg-indigo-100 px-2 py-0.5 font-medium text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/40 dark:text-indigo-300"
>
Invoice: {inv.invoiceNumber}
</a>
</p>
{/if}
{/if}
<p class="mt-1 flex flex-wrap items-center gap-2 text-xs">
{#if expense.invoiceFileUrl}
<a
href={`/companies/${data.company.id}/expenses/${expense.id}/invoice`}
class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300"
>
📄 {expense.invoiceFileName ?? 'Invoice file'}
</a>
{/if}
{#if expense.paperlessUrl}
<a
href={expense.paperlessUrl}
target="_blank"
rel="noopener noreferrer"
class="rounded-full bg-purple-100 px-2 py-0.5 font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300"
>
🗂 Paperless
</a>
{/if}
{#if !expense.invoiceFileUrl && !expense.paperlessUrl}
<span class="rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
Pending invoice
</span>
{/if}
</p>
{#if linkedPkgIds.length > 0}
<p class="mt-1 flex flex-wrap gap-1 text-xs">
{#each linkedPkgIds as pkgId (pkgId)}
{@const pkg = data.packages.find((p) => p.id === pkgId)}
{#if pkg}
<a href={`/companies/${data.company.id}/packages/${pkg.id}`}
class="rounded-full bg-cyan-100 px-2 py-0.5 font-medium text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-300">
📦 {pkg.trackingNumber}
</a>
{/if}
{/each}
</p>
{/if}
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p> <p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
@@ -80,7 +238,7 @@
{/if} {/if}
{#if canApprove && expense.status === 'pending'} {#if canApprove && expense.status === 'pending'}
<div class="mt-3 flex gap-2 border-t border-gray-100 dark:border-gray-700 pt-3"> <div class="mt-3 flex flex-wrap gap-2 border-t border-gray-100 dark:border-gray-700 pt-3">
<form method="POST" action="?/approve" use:enhance> <form method="POST" action="?/approve" use:enhance>
<input type="hidden" name="expenseId" value={expense.id} /> <input type="hidden" name="expenseId" value={expense.id} />
<button <button
@@ -107,6 +265,112 @@
</form> </form>
</div> </div>
{/if} {/if}
{#if canAssignAccount && data.accounts.length > 0}
<form
method="POST"
action="?/updateExpenseAccount"
use:enhance
class="mt-3 flex flex-wrap items-center gap-2 border-t border-gray-100 pt-3 text-sm dark:border-gray-700"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-xs text-gray-500 dark:text-gray-400" for="acct-{expense.id}">
Assign to account:
</label>
<select
id="acct-{expense.id}"
name="accountId"
value={expense.accountId ?? ''}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— none —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
<button
type="submit"
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Save
</button>
</form>
{/if}
{#if canAssignAccount && data.packages.length > 0}
{@const linkedPkgIds = data.expensePackageLinks.filter((l) => l.expenseId === expense.id).map((l) => l.packageId)}
<details class="mt-3 border-t border-gray-100 pt-3 dark:border-gray-700">
<summary class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
Link packages ({linkedPkgIds.length})
</summary>
<div class="mt-2 space-y-1">
{#each data.packages as pkg (pkg.id)}
{@const isLinked = linkedPkgIds.includes(pkg.id)}
<form
method="POST"
action={isLinked ? '?/unlinkPackage' : '?/linkPackage'}
use:enhance
class="flex items-center gap-2 text-xs"
>
<input type="hidden" name="expenseId" value={expense.id} />
<input type="hidden" name="packageId" value={pkg.id} />
<button type="submit"
class="flex h-4 w-4 items-center justify-center rounded border-2 {isLinked
? 'border-cyan-500 bg-cyan-500 text-white'
: 'border-gray-300 hover:border-cyan-400 dark:border-gray-600'}">
{#if isLinked}{/if}
</button>
<span class="text-gray-700 dark:text-gray-300">
{pkg.trackingNumber}{pkg.carrier} ({pkg.direction})
</span>
</form>
{/each}
</div>
</details>
{/if}
{#if canAssignAccount}
<details class="mt-3 border-t border-gray-100 pt-3 dark:border-gray-700">
<summary class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
{expense.invoiceFileUrl || expense.paperlessUrl ? 'Manage invoice' : '+ Attach invoice'}
</summary>
<div class="mt-2 space-y-2">
<form
method="POST"
action="?/uploadExpenseInvoice"
enctype="multipart/form-data"
use:enhance
class="flex flex-wrap items-center gap-2 text-xs"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-gray-500 dark:text-gray-400" for="inv-file-{expense.id}">File:</label>
<input id="inv-file-{expense.id}" name="file" type="file" accept="application/pdf,image/*" required
class="text-xs text-gray-700 dark:text-gray-300" />
<button type="submit"
class="rounded-md bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700">
Upload
</button>
</form>
<form
method="POST"
action="?/setExpensePaperlessLink"
use:enhance
class="flex flex-wrap items-center gap-2 text-xs"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-gray-500 dark:text-gray-400" for="pless-{expense.id}">Paperless URL:</label>
<input id="pless-{expense.id}" name="paperlessUrl" type="url"
value={expense.paperlessUrl ?? ''}
placeholder="https://paperless.example.com/documents/123"
class="flex-1 rounded border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit"
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
Save link
</button>
</form>
</div>
</details>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -0,0 +1,453 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
expenses,
projects,
users,
categories,
parties,
companyAccounts,
invoices,
packages,
expensePackages
} from '$lib/server/db/schema.js';
import { and, asc, eq, isNull, ne } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js';
import {
postExpenseTransaction,
removeExpenseTransaction
} from '$lib/server/accounts/ledger.js';
import { saveCompanyFile, isAllowedMime, MAX_BYTES } from '$lib/server/uploads/index.js';
import { uploadToPaperless, isPaperlessEnabled } from '$lib/server/paperless/index.js';
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'accountant', 'hr'
]);
await parent();
const [row] = await db
.select({
id: expenses.id,
title: expenses.title,
description: expenses.description,
amount: expenses.amount,
currency: expenses.currency,
status: expenses.status,
expenseDate: expenses.expenseDate,
rejectionReason: expenses.rejectionReason,
reviewedAt: expenses.reviewedAt,
createdAt: expenses.createdAt,
updatedAt: expenses.updatedAt,
projectId: expenses.projectId,
projectName: projects.name,
partyId: expenses.partyId,
partyName: parties.name,
categoryId: expenses.categoryId,
categoryName: categories.name,
accountId: expenses.accountId,
accountName: companyAccounts.name,
invoiceId: expenses.invoiceId,
invoiceFileUrl: expenses.invoiceFileUrl,
invoiceFileName: expenses.invoiceFileName,
paperlessUrl: expenses.paperlessUrl,
voidedAt: expenses.voidedAt,
voidReason: expenses.voidReason,
submitterName: users.displayName,
submitterEmail: users.email
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.innerJoin(users, eq(expenses.submittedBy, users.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id))
.leftJoin(parties, eq(expenses.partyId, parties.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!row) error(404, 'Expense not found');
const projectList = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
.orderBy(asc(projects.name));
const categoryList = await db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.companyId, params.companyId))
.orderBy(asc(categories.name));
const accountList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
const partyList = await db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
.orderBy(asc(parties.name));
const invoiceList = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction
})
.from(invoices)
.where(
and(
eq(invoices.companyId, params.companyId),
ne(invoices.status, 'voided'),
ne(invoices.status, 'cancelled')
)
)
.orderBy(asc(invoices.invoiceNumber));
const linkedPackages = await db
.select({
id: packages.id,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
direction: packages.direction,
status: packages.status
})
.from(expensePackages)
.innerJoin(packages, eq(expensePackages.packageId, packages.id))
.where(eq(expensePackages.expenseId, params.expenseId));
const availablePackages = await db
.select({
id: packages.id,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
direction: packages.direction
})
.from(packages)
.where(eq(packages.companyId, params.companyId))
.orderBy(packages.createdAt);
return {
expense: row,
projects: projectList,
categories: categoryList,
accounts: accountList,
parties: partyList,
invoices: invoiceList,
linkedPackages,
availablePackages
};
};
export const actions: Actions = {
updateExpense: async ({ request, locals, params }) => {
const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const canManage = roles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant');
if (!canManage) return fail(403, { error: 'Not permitted' });
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
const amountStr = fd.get('amount')?.toString().trim();
const expenseDate = trimOrNull(fd.get('expenseDate'));
const description = trimOrNull(fd.get('description'));
const projectId = trimOrNull(fd.get('projectId'));
const categoryId = trimOrNull(fd.get('categoryId'));
const partyId = trimOrNull(fd.get('partyId'));
const accountId = trimOrNull(fd.get('accountId'));
const invoiceId = trimOrNull(fd.get('invoiceId'));
if (!title) return fail(400, { action: 'updateExpense', error: 'Title is required' });
if (!amountStr || isNaN(Number(amountStr)) || Number(amountStr) <= 0) {
return fail(400, { action: 'updateExpense', error: 'Valid positive amount required' });
}
if (!expenseDate) return fail(400, { action: 'updateExpense', error: 'Date is required' });
if (!projectId) return fail(400, { action: 'updateExpense', error: 'Project is required' });
// Verify current expense belongs to this company
const [existing] = await db
.select({
id: expenses.id,
title: expenses.title,
amount: expenses.amount,
status: expenses.status,
accountId: expenses.accountId
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))
)
.limit(1);
if (!existing) error(404, 'Expense not found');
// Verify target project belongs to this company
const [proj] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!proj) return fail(400, { action: 'updateExpense', error: 'Project not in this company' });
const newAmount = Number(amountStr).toFixed(2);
const amountChanged = newAmount !== existing.amount;
const accountChanged = (accountId ?? null) !== (existing.accountId ?? null);
// Resolve currency from the (possibly new) account
let resolvedCurrency: string | undefined = undefined;
if (accountId) {
const [acct] = await db
.select({ currency: companyAccounts.currency })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId)
)
)
.limit(1);
if (acct) resolvedCurrency = acct.currency;
}
await db.transaction(async (tx) => {
await tx
.update(expenses)
.set({
title,
description,
amount: newAmount,
expenseDate,
projectId,
categoryId,
partyId,
accountId,
invoiceId,
...(resolvedCurrency ? { currency: resolvedCurrency } : {}),
updatedAt: new Date()
})
.where(eq(expenses.id, params.expenseId));
// Re-post ledger entry if approved and amount or account changed
if (existing.status === 'approved' && (amountChanged || accountChanged)) {
if (accountId) {
await postExpenseTransaction(params.expenseId, accountId, user.id, tx);
} else {
await removeExpenseTransaction(params.expenseId, tx);
}
}
});
await logCompanyEvent(
params.companyId,
user.id,
'expense_updated',
`Expense "${title}" edited (was ${formatCurrency(existing.amount, 'THB')})`,
{
expenseId: params.expenseId,
previousTitle: existing.title,
previousAmount: existing.amount,
newAmount,
amountChanged,
accountChanged
}
);
return { success: true, action: 'updateExpense' };
},
uploadInvoice: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const fd = await request.formData();
const file = fd.get('file') as File | null;
if (!file || !(file instanceof File) || file.size === 0) {
return fail(400, { action: 'uploadInvoice', error: 'File is required' });
}
if (file.size > MAX_BYTES) {
return fail(400, {
action: 'uploadInvoice',
error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)`
});
}
const mime = file.type || 'application/octet-stream';
if (!isAllowedMime(mime)) {
return fail(400, { action: 'uploadInvoice', error: `File type not allowed: ${mime}` });
}
const [exp] = await db
.select({ id: expenses.id, title: expenses.title })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!exp) return fail(404, { error: 'Expense not found' });
let saved;
try {
saved = await saveCompanyFile(params.companyId, file);
} catch (err) {
console.error('saveCompanyFile failed', err);
return fail(500, { action: 'uploadInvoice', error: 'Failed to save file' });
}
if (isPaperlessEnabled()) {
await uploadToPaperless(file, exp.title);
}
await db
.update(expenses)
.set({
invoiceFileUrl: saved.storedPath,
invoiceFileName: file.name,
updatedAt: new Date()
})
.where(eq(expenses.id, params.expenseId));
await logCompanyEvent(params.companyId, user.id, 'expense_invoice_uploaded',
`Invoice attached to expense "${exp.title}"`,
{ expenseId: params.expenseId, fileName: file.name });
return { success: true, action: 'uploadInvoice' };
},
setPaperlessLink: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const url = fd.get('paperlessUrl')?.toString().trim() || null;
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
return fail(400, {
action: 'setPaperlessLink',
error: 'URL must start with http:// or https://'
});
}
const [exp] = await db
.select({ id: expenses.id })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!exp) return fail(404, { error: 'Expense not found' });
await db
.update(expenses)
.set({ paperlessUrl: url, updatedAt: new Date() })
.where(eq(expenses.id, params.expenseId));
return { success: true, action: 'setPaperlessLink' };
},
linkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const packageId = fd.get('packageId')?.toString();
if (!packageId) return fail(400, { error: 'Package id required' });
// Verify expense and package belong to this company
const [exp] = await db
.select({ id: expenses.id })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!exp) return fail(404, { error: 'Expense not found' });
const [pkg] = await db
.select({ id: packages.id })
.from(packages)
.where(and(eq(packages.id, packageId), eq(packages.companyId, params.companyId)))
.limit(1);
if (!pkg) return fail(404, { error: 'Package not found' });
await db
.insert(expensePackages)
.values({ expenseId: params.expenseId, packageId })
.onConflictDoNothing();
return { success: true, action: 'linkPackage' };
},
voidExpense: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const reason = fd.get('reason')?.toString().trim();
if (!reason) return fail(400, { action: 'voidExpense', error: 'Void reason is required' });
const [existing] = await db
.select({ id: expenses.id, title: expenses.title, status: expenses.status })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Expense not found' });
if (existing.status === 'voided') return fail(400, { error: 'Expense is already voided' });
await db.transaction(async (tx) => {
await tx
.update(expenses)
.set({
status: 'voided',
voidedAt: new Date(),
voidReason: reason,
updatedAt: new Date()
})
.where(eq(expenses.id, params.expenseId));
// Reverse any ledger post for this expense
await removeExpenseTransaction(params.expenseId, tx);
});
await logCompanyEvent(
params.companyId,
user.id,
'expense_voided',
`Expense "${existing.title}" voided: ${reason}`,
{ expenseId: params.expenseId, reason, previousStatus: existing.status }
);
return { success: true, action: 'voidExpense' };
},
unlinkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const packageId = fd.get('packageId')?.toString();
if (!packageId) return fail(400, { error: 'Package id required' });
await db
.delete(expensePackages)
.where(
and(
eq(expensePackages.expenseId, params.expenseId),
eq(expensePackages.packageId, packageId)
)
);
return { success: true, action: 'unlinkPackage' };
}
};
@@ -0,0 +1,323 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let editing = $state(false);
const canManage = $derived(
data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
);
const STATUS_BADGE: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
approved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300'
};
let showVoidForm = $state(false);
const canVoid = $derived(canManage && data.expense.status !== 'voided');
const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>{data.expense.title} - Expense</title>
</svelte:head>
<div class="space-y-6">
<header>
<a href={`/companies/${data.company.id}/expenses`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Expenses</a>
<div class="mt-1 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.expense.title}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.expense.status]}">
{data.expense.status}
</span>
<span>{data.expense.expenseDate}</span>
<span>By {data.expense.submitterName ?? data.expense.submitterEmail}</span>
</div>
</div>
{#if canManage && !editing}
<div class="flex gap-2">
<button type="button" onclick={() => (editing = true)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Edit
</button>
{#if canVoid}
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
class="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Void
</button>
{/if}
</div>
{/if}
</div>
</header>
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
{#if data.expense.status === 'voided' && data.expense.voidReason}
<div class="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/20">
<span class="text-sm font-medium text-red-700 dark:text-red-300">Voided:</span>
<span class="text-sm text-red-600 dark:text-red-400">{data.expense.voidReason}</span>
</div>
{/if}
{#if showVoidForm && canVoid}
<form method="POST" action="?/voidExpense"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') showVoidForm = false;
}}
class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
<p class="mb-2 text-sm font-medium text-red-700 dark:text-red-300">
Void this expense? This reverses any ledger entry. Cannot be undone.
</p>
<label for="void-reason" class={labelCls}>Reason <span class="text-red-500">*</span></label>
<textarea id="void-reason" name="reason" rows="2" required
placeholder="e.g. Duplicate, wrong supplier, incorrect amount"
class={inputCls}></textarea>
<div class="mt-2 flex justify-end gap-2">
<button type="button" onclick={() => (showVoidForm = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Confirm Void</button>
</div>
</form>
{/if}
{#if editing && canManage}
<form method="POST" action="?/updateExpense"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editing = false;
}}
class="grid grid-cols-1 gap-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
<div class="md:col-span-2">
<label for="e-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
<input id="e-title" name="title" type="text" required value={data.expense.title} class={inputCls} />
</div>
<div>
<label for="e-amount" class={labelCls}>Amount <span class="text-red-500">*</span></label>
<input id="e-amount" name="amount" type="number" step="0.01" min="0.01" required value={data.expense.amount} class={inputCls} />
</div>
<div>
<label for="e-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input id="e-date" name="expenseDate" type="date" required value={data.expense.expenseDate} class={inputCls} />
</div>
<div>
<label for="e-project" class={labelCls}>Project <span class="text-red-500">*</span></label>
<select id="e-project" name="projectId" required value={data.expense.projectId} class={inputCls}>
{#each data.projects as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="e-category" class={labelCls}>Category</label>
<select id="e-category" name="categoryId" value={data.expense.categoryId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.categories as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
</div>
<div>
<label for="e-party" class={labelCls}>Supplier</label>
<select id="e-party" name="partyId" value={data.expense.partyId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.parties as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="e-account" class={labelCls}>Account</label>
<select id="e-account" name="accountId" value={data.expense.accountId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.accounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="e-invoice" class={labelCls}>Linked Invoice</label>
<select id="e-invoice" name="invoiceId" value={data.expense.invoiceId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.invoices as inv (inv.id)}
<option value={inv.id}>{inv.invoiceNumber} ({inv.direction})</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label for="e-desc" class={labelCls}>Description</label>
<textarea id="e-desc" name="description" rows="2" class={inputCls}>{data.expense.description ?? ''}</textarea>
</div>
<div class="md:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editing = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200">Cancel</button>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save changes</button>
</div>
<p class="md:col-span-2 text-xs text-gray-500 dark:text-gray-400">Edits are audit-logged. If status is approved and amount/account changes, the ledger entry is re-posted.</p>
</form>
{:else}
<div class="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Amount</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{formatCurrency(data.expense.amount, data.expense.currency)}</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Project</p>
<p class="text-sm text-gray-900 dark:text-white">
<a href={`/companies/${data.company.id}/projects/${data.expense.projectId}`}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{data.expense.projectName}</a>
</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Category</p>
<p class="text-sm text-gray-900 dark:text-white">{data.expense.categoryName ?? '—'}</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Supplier</p>
<p class="text-sm text-gray-900 dark:text-white">{data.expense.partyName ?? '—'}</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Account</p>
<p class="text-sm text-gray-900 dark:text-white">{data.expense.accountName ?? '—'}</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Created</p>
<p class="text-sm text-gray-900 dark:text-white">{formatDate(data.expense.createdAt)}</p>
</div>
{#if data.expense.description}
<div class="md:col-span-2">
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Description</p>
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.expense.description}</p>
</div>
{/if}
{#if data.expense.rejectionReason}
<div class="md:col-span-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20">
<p class="text-xs font-medium uppercase tracking-wider text-red-500">Rejected</p>
<p class="text-sm text-red-700 dark:text-red-300">{data.expense.rejectionReason}</p>
</div>
{/if}
</div>
<!-- Invoice attachments -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Invoice</h2>
<div class="mb-3 flex flex-wrap gap-2 text-sm">
{#if data.expense.invoiceFileUrl}
<a href={`/companies/${data.company.id}/expenses/${data.expense.id}/invoice`}
class="rounded-full bg-emerald-100 px-3 py-1 text-sm font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300">
📄 {data.expense.invoiceFileName ?? 'Invoice file'}
</a>
{/if}
{#if data.expense.paperlessUrl}
<a href={data.expense.paperlessUrl} target="_blank" rel="noopener noreferrer"
class="rounded-full bg-purple-100 px-3 py-1 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300">
🗂 Paperless
</a>
{/if}
{#if !data.expense.invoiceFileUrl && !data.expense.paperlessUrl}
<span class="rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
No invoice attached
</span>
{/if}
</div>
{#if canManage}
<div class="space-y-3 border-t border-gray-100 pt-3 dark:border-gray-700">
<form method="POST" action="?/uploadInvoice" enctype="multipart/form-data" use:enhance
class="flex flex-wrap items-center gap-2 text-sm">
<label class={labelCls + ' mb-0'} for="inv-file">Upload file:</label>
<input id="inv-file" name="file" type="file" accept="application/pdf,image/*" required
class="text-xs text-gray-700 dark:text-gray-300" />
<button type="submit"
class="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700">
Upload
</button>
</form>
<form method="POST" action="?/setPaperlessLink" use:enhance
class="flex flex-wrap items-center gap-2 text-sm">
<label class={labelCls + ' mb-0'} for="pless-url">Paperless URL:</label>
<input id="pless-url" name="paperlessUrl" type="url"
value={data.expense.paperlessUrl ?? ''}
placeholder="https://paperless.example.com/documents/123"
class="flex-1 min-w-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit"
class="rounded-md border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
Save
</button>
</form>
</div>
{/if}
</div>
<!-- Packages -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
{#if canManage}
<a href={`/companies/${data.company.id}/packages/new`}
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700">
+ New Package
</a>
{/if}
</div>
{#if data.linkedPackages.length > 0}
<div class="mb-3 flex flex-wrap gap-2">
{#each data.linkedPackages as pkg (pkg.id)}
<span class="inline-flex items-center gap-2 rounded-full bg-cyan-100 px-3 py-1 text-sm font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
<a href={`/companies/${data.company.id}/packages/${pkg.id}`} class="hover:underline">
📦 {pkg.trackingNumber}{pkg.carrier}
</a>
{#if canManage}
<form method="POST" action="?/unlinkPackage" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="packageId" value={pkg.id} />
<button type="submit" class="text-cyan-800 hover:text-red-600 dark:text-cyan-200">×</button>
</form>
{/if}
</span>
{/each}
</div>
{:else}
<p class="mb-3 text-sm text-gray-500 dark:text-gray-400">No packages linked yet.</p>
{/if}
{#if canManage}
{@const selectable = data.availablePackages.filter((p) => !data.linkedPackages.find((l) => l.id === p.id))}
{#if selectable.length > 0}
<form method="POST" action="?/linkPackage" use:enhance={() => async ({ update, formElement }) => {
await update({ reset: false });
formElement.reset();
}} class="flex items-center gap-2 border-t border-gray-100 pt-3 text-sm dark:border-gray-700">
<select name="packageId" required class={inputCls + ' flex-1'}>
<option value="" disabled selected>Select an existing package</option>
{#each selectable as pkg (pkg.id)}
<option value={pkg.id}>{pkg.trackingNumber} {pkg.carrier} ({pkg.direction})</option>
{/each}
</select>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Link
</button>
</form>
{:else if data.availablePackages.length === 0}
<p class="border-t border-gray-100 pt-3 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
No packages exist yet. Use "+ New Package" above to create one.
</p>
{/if}
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,43 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { expenses, projects } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { readCompanyFile } from '$lib/server/uploads/index.js';
import { and, eq } from 'drizzle-orm';
export const GET: RequestHandler = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'accountant'
]);
const [row] = await db
.select({
invoiceFileUrl: expenses.invoiceFileUrl,
invoiceFileName: expenses.invoiceFileName
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!row || !row.invoiceFileUrl) error(404, 'Invoice file not found');
let buf: Buffer;
try {
buf = await readCompanyFile(row.invoiceFileUrl);
} catch (err) {
console.error('readCompanyFile failed', err);
error(404, 'File missing on disk');
}
const safeName = (row.invoiceFileName ?? 'invoice').replace(/[\r\n"\\]/g, '_');
return new Response(new Blob([buf as BlobPart]), {
headers: {
'Content-Disposition': `attachment; filename="${safeName}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
}
});
};
@@ -1,10 +1,18 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { externalAccounts, externalTransactions, expenses, projects } from '$lib/server/db/schema.js'; import {
import { eq, and, isNull, isNotNull, desc } from 'drizzle-orm'; externalAccounts,
externalTransactions,
expenses,
projects,
companyAccounts,
companyAccountTransactions
} from '$lib/server/db/schema.js';
import { eq, and, isNull, isNotNull, desc, inArray } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { postReconciliationTransaction } from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params, url }) => { export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRole(locals, params.companyId, 'admin'); await requireCompanyRole(locals, params.companyId, 'admin');
@@ -82,11 +90,53 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
.orderBy(desc(expenses.createdAt)) .orderBy(desc(expenses.createdAt))
.limit(200); .limit(200);
const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
// Determine which external transactions are already posted to a ledger account
const txIds = transactions.map((t) => t.id);
const postedMap: Record<string, { accountId: string; accountName: string | null }> = {};
if (txIds.length > 0) {
const postedRows = await db
.select({
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
accountId: companyAccountTransactions.accountId,
accountName: companyAccounts.name
})
.from(companyAccountTransactions)
.leftJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
.where(inArray(companyAccountTransactions.sourceExternalTransactionId, txIds));
for (const row of postedRows) {
if (row.sourceExternalTransactionId) {
postedMap[row.sourceExternalTransactionId] = {
accountId: row.accountId,
accountName: row.accountName
};
}
}
}
return { return {
transactions, transactions,
matchedExpenseTitles, matchedExpenseTitles,
matchableExpenses, matchableExpenses,
matchedFilter: matched matchedFilter: matched,
accounts: accountsList,
postedMap
}; };
}; };
@@ -144,6 +194,72 @@ export const actions: Actions = {
.set({ matchedExpenseId: null }) .set({ matchedExpenseId: null })
.where(eq(externalTransactions.id, txId)); .where(eq(externalTransactions.id, txId));
return { success: true };
},
postToAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
const accountId = formData.get('accountId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
if (!accountId) return fail(400, { error: 'Account is required' });
const [tx] = await db
.select({ id: externalTransactions.id })
.from(externalTransactions)
.where(
and(eq(externalTransactions.id, txId), eq(externalTransactions.companyId, params.companyId))
)
.limit(1);
if (!tx) return fail(404, { error: 'Transaction not found' });
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid account' });
await postReconciliationTransaction(txId, accountId, params.companyId, user.id);
await logCompanyEvent(
params.companyId,
user.id,
'account_reconciled',
`External transaction posted to account`,
{ externalTransactionId: txId, accountId }
);
return { success: true };
},
unpostFromAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
await db
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExternalTransactionId, txId));
await logCompanyEvent(
params.companyId,
user.id,
'account_reconciled',
`Reconciliation reversed for external transaction`,
{ externalTransactionId: txId }
);
return { success: true }; return { success: true };
} }
}; };
@@ -82,6 +82,7 @@
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Description</th> <th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Description</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Counterparty</th> <th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Counterparty</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Matched Expense</th> <th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Matched Expense</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Ledger Account</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -152,6 +153,44 @@
</form> </form>
{/if} {/if}
</td> </td>
<td class="px-4 py-3">
{#if data.postedMap[tx.id]}
<div class="flex items-center gap-2">
<span class="truncate max-w-[140px] text-xs text-blue-700 dark:text-blue-300 font-medium" title={data.postedMap[tx.id].accountName ?? ''}>
{data.postedMap[tx.id].accountName ?? 'Account'}
</span>
<form method="POST" action="?/unpostFromAccount" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<button
type="submit"
class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 whitespace-nowrap"
>
Unpost
</button>
</form>
</div>
{:else if data.accounts.length === 0}
<span class="text-xs text-gray-400">No accounts</span>
{:else}
<form method="POST" action="?/postToAccount" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<select
name="accountId"
onchange={(e) => {
if ((e.target as HTMLSelectElement).value) {
(e.target as HTMLSelectElement).closest('form')?.requestSubmit();
}
}}
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-1.5 py-1 text-xs text-gray-700 dark:text-gray-300"
>
<option value="">Post to account…</option>
{#each data.accounts as acct}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</form>
{/if}
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -1,10 +1,14 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { invoices, parties } from '$lib/server/db/schema.js'; import { invoices, parties, companyAccounts } from '$lib/server/db/schema.js';
import { eq, and, sql, gte, lte } from 'drizzle-orm'; import { eq, and, sql, gte, lte, isNull } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import {
postInvoicePaymentTransaction,
removeInvoicePaymentTransaction
} from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params, url }) => { export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
@@ -52,7 +56,31 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
.orderBy(sql`${invoices.issueDate} desc`) .orderBy(sql`${invoices.issueDate} desc`)
.limit(200); .limit(200);
return { invoices: invoiceList, directionFilter, statusFilter, fromDate, toDate }; const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
return {
invoices: invoiceList,
directionFilter,
statusFilter,
fromDate,
toDate,
accounts: accountsList
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -90,7 +118,10 @@ export const actions: Actions = {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData(); const formData = await request.formData();
const invoiceId = formData.get('invoiceId')?.toString(); const invoiceId = formData.get('invoiceId')?.toString();
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
if (!invoiceId) return fail(400, { error: 'Missing invoice ID' }); if (!invoiceId) return fail(400, { error: 'Missing invoice ID' });
if (!paymentAccountId)
return fail(400, { error: 'Payment account is required to mark an invoice paid' });
const [inv] = await db const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber, total: invoices.total, currency: invoices.currency }) .select({ invoiceNumber: invoices.invoiceNumber, total: invoices.total, currency: invoices.currency })
@@ -100,17 +131,34 @@ export const actions: Actions = {
if (!inv) return fail(404, { error: 'Invoice not found' }); if (!inv) return fail(404, { error: 'Invoice not found' });
await db const [acct] = await db
.update(invoices) .select({ id: companyAccounts.id })
.set({ status: 'paid', updatedAt: new Date() }) .from(companyAccounts)
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId))); .where(
and(
eq(companyAccounts.id, paymentAccountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid payment account' });
await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({ status: 'paid', paymentAccountId, updatedAt: new Date() })
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId)));
await postInvoicePaymentTransaction(invoiceId, paymentAccountId, user.id, tx);
});
await logCompanyEvent( await logCompanyEvent(
params.companyId, params.companyId,
user.id, user.id,
'invoice_paid', 'invoice_paid',
`Marked invoice ${inv.invoiceNumber} as paid`, `Marked invoice ${inv.invoiceNumber} as paid`,
{ invoiceId } { invoiceId, paymentAccountId }
); );
return { success: true }; return { success: true };
@@ -141,13 +141,29 @@
</form> </form>
{/if} {/if}
{#if inv.status === 'sent' || inv.status === 'overdue'} {#if inv.status === 'sent' || inv.status === 'overdue'}
<form method="POST" action="?/markPaid" use:enhance> {#if data.accounts.length === 0}
<input type="hidden" name="invoiceId" value={inv.id} /> <span class="text-xs text-gray-400" title="Create an account first to mark invoices paid">
<button type="submit" No account
class="rounded px-2 py-1 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/50"> </span>
Mark Paid {:else}
</button> <form method="POST" action="?/markPaid" use:enhance class="flex items-center gap-1">
</form> <input type="hidden" name="invoiceId" value={inv.id} />
<select
name="paymentAccountId"
required
class="rounded border border-gray-300 bg-white px-1 py-0.5 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— Account —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name}</option>
{/each}
</select>
<button type="submit"
class="rounded px-2 py-1 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/50">
Mark Paid
</button>
</form>
{/if}
{/if} {/if}
</div> </div>
</td> </td>
@@ -7,11 +7,16 @@ import {
parties, parties,
expenses, expenses,
projects, projects,
packages packages,
companyAccounts
} from '$lib/server/db/schema.js'; } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm'; import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import {
postInvoicePaymentTransaction,
removeInvoicePaymentTransaction
} from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params }) => { export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
@@ -29,7 +34,10 @@ export const load: PageServerLoad = async ({ locals, params }) => {
total: invoices.total, total: invoices.total,
currency: invoices.currency, currency: invoices.currency,
notes: invoices.notes, notes: invoices.notes,
voidedAt: invoices.voidedAt,
voidReason: invoices.voidReason,
expenseId: invoices.expenseId, expenseId: invoices.expenseId,
paymentAccountId: invoices.paymentAccountId,
createdAt: invoices.createdAt, createdAt: invoices.createdAt,
partyId: invoices.partyId, partyId: invoices.partyId,
partyName: parties.name, partyName: parties.name,
@@ -49,6 +57,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
if (!invoice) error(404, 'Invoice not found'); if (!invoice) error(404, 'Invoice not found');
const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
const lineItems = await db const lineItems = await db
.select() .select()
.from(invoiceLineItems) .from(invoiceLineItems)
@@ -72,7 +97,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(packages.invoiceId, params.invoiceId)) .where(eq(packages.invoiceId, params.invoiceId))
.orderBy(packages.createdAt); .orderBy(packages.createdAt);
return { invoice, lineItems, projects: projectList, linkedPackages }; return { invoice, lineItems, projects: projectList, linkedPackages, accounts: accountsList };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -86,6 +111,7 @@ export const actions: Actions = {
| 'overdue' | 'overdue'
| 'cancelled' | 'cancelled'
| undefined; | undefined;
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled']; const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'];
if (!newStatus || !validStatuses.includes(newStatus)) { if (!newStatus || !validStatuses.includes(newStatus)) {
@@ -93,27 +119,108 @@ export const actions: Actions = {
} }
const [inv] = await db const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber }) .select({
invoiceNumber: invoices.invoiceNumber,
status: invoices.status,
paymentAccountId: invoices.paymentAccountId
})
.from(invoices) .from(invoices)
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId))) .where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1); .limit(1);
if (!inv) return fail(404, { error: 'Invoice not found' }); if (!inv) return fail(404, { error: 'Invoice not found' });
await db if (newStatus === 'paid') {
.update(invoices) if (!paymentAccountId) {
.set({ status: newStatus, updatedAt: new Date() }) return fail(400, { error: 'Payment account is required to mark an invoice paid' });
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId))); }
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, paymentAccountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid payment account' });
}
await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({
status: newStatus,
paymentAccountId: newStatus === 'paid' ? paymentAccountId : null,
updatedAt: new Date()
})
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)));
if (newStatus === 'paid' && paymentAccountId) {
await postInvoicePaymentTransaction(params.invoiceId, paymentAccountId, user.id, tx);
} else if (inv.status === 'paid') {
// Status moved away from paid — remove ledger post
await removeInvoicePaymentTransaction(params.invoiceId, tx);
}
});
if (newStatus === 'sent') { if (newStatus === 'sent') {
await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId }); await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId });
} else if (newStatus === 'paid') { } else if (newStatus === 'paid') {
await logCompanyEvent(params.companyId, user.id, 'invoice_paid', `Marked invoice ${inv.invoiceNumber} as paid`, { invoiceId: params.invoiceId }); await logCompanyEvent(params.companyId, user.id, 'invoice_paid', `Marked invoice ${inv.invoiceNumber} as paid`, { invoiceId: params.invoiceId, paymentAccountId });
} }
return { success: true }; return { success: true };
}, },
voidInvoice: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'accountant'
]);
const fd = await request.formData();
const reason = fd.get('reason')?.toString().trim();
if (!reason) return fail(400, { error: 'Void reason is required' });
const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber, status: invoices.status })
.from(invoices)
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1);
if (!inv) return fail(404, { error: 'Invoice not found' });
if (inv.status === 'voided') return fail(400, { error: 'Invoice is already voided' });
await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({
status: 'voided',
voidedAt: new Date(),
voidReason: reason,
updatedAt: new Date()
})
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)));
if (inv.status === 'paid') {
await removeInvoicePaymentTransaction(params.invoiceId, tx);
}
});
await logCompanyEvent(
params.companyId,
user.id,
'invoice_voided',
`Invoice ${inv.invoiceNumber} voided: ${reason}`,
{ invoiceId: params.invoiceId, reason }
);
return { success: true, voided: true };
},
linkExpense: async ({ request, locals, params }) => { linkExpense: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData(); const formData = await request.formData();
@@ -16,7 +16,8 @@
sent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', sent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
paid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', paid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
overdue: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', overdue: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500' cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500',
voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300'
}; };
const nextStatuses: Record<string, string[]> = { const nextStatuses: Record<string, string[]> = {
@@ -24,9 +25,17 @@
sent: ['paid', 'overdue', 'cancelled'], sent: ['paid', 'overdue', 'cancelled'],
overdue: ['paid', 'cancelled'], overdue: ['paid', 'cancelled'],
paid: [], paid: [],
cancelled: [] cancelled: [],
voided: []
}; };
const canVoid = $derived(
data.companyRoles.some((r: string) => r === 'admin' || r === 'accountant') &&
inv.status !== 'voided' &&
inv.status !== 'cancelled'
);
let showVoidForm = $state(false);
let showLinkExpense = $state(false); let showLinkExpense = $state(false);
let selectedProject = $state(''); let selectedProject = $state('');
</script> </script>
@@ -202,17 +211,37 @@
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<!-- Status transitions --> <!-- Status transitions -->
{#each nextStatuses[inv.status] ?? [] as targetStatus} {#each nextStatuses[inv.status] ?? [] as targetStatus}
<form method="POST" action="?/updateStatus" use:enhance> <form method="POST" action="?/updateStatus" use:enhance class="flex items-center gap-1">
<input type="hidden" name="status" value={targetStatus} /> <input type="hidden" name="status" value={targetStatus} />
<button type="submit" {#if targetStatus === 'paid'}
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {#if data.accounts.length === 0}
{targetStatus === 'paid' ? 'bg-green-600 text-white hover:bg-green-700' : <span class="text-xs text-gray-400" title="Create an account first to mark as paid">
targetStatus === 'sent' ? 'bg-blue-600 text-white hover:bg-blue-700' : No account
targetStatus === 'cancelled' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' : </span>
targetStatus === 'overdue' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200' : {:else}
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"> <select
Mark {targetStatus.charAt(0).toUpperCase() + targetStatus.slice(1)} name="paymentAccountId"
</button> required
class="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— Account —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
{/if}
{/if}
{#if targetStatus !== 'paid' || data.accounts.length > 0}
<button type="submit"
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
{targetStatus === 'paid' ? 'bg-green-600 text-white hover:bg-green-700' :
targetStatus === 'sent' ? 'bg-blue-600 text-white hover:bg-blue-700' :
targetStatus === 'cancelled' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' :
targetStatus === 'overdue' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200' :
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}">
Mark {targetStatus.charAt(0).toUpperCase() + targetStatus.slice(1)}
</button>
{/if}
</form> </form>
{/each} {/each}
@@ -221,8 +250,56 @@
class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"> class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Download PDF Download PDF
</a> </a>
{#if canVoid}
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
class="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Void Invoice
</button>
{/if}
</div> </div>
{#if showVoidForm}
<form method="POST" action="?/voidInvoice"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
showVoidForm = false;
}}
class="mt-4 rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
<p class="mb-2 text-sm font-medium text-red-700 dark:text-red-300">
Void invoice {inv.invoiceNumber}? This will reverse any ledger entry and cannot be undone.
</p>
<label for="void-reason" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Reason <span class="text-red-500">*</span>
</label>
<textarea
id="void-reason"
name="reason"
rows="2"
required
placeholder="e.g. Duplicate invoice, incorrect amount, wrong vendor"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
></textarea>
<div class="mt-2 flex justify-end gap-2">
<button type="button" onclick={() => (showVoidForm = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">
Cancel
</button>
<button type="submit"
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">
Confirm Void
</button>
</div>
</form>
{/if}
{#if inv.status === 'voided' && inv.voidReason}
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-700 dark:bg-red-900/20">
<span class="font-medium text-red-700 dark:text-red-300">Voided:</span>
<span class="text-red-600 dark:text-red-400">{inv.voidReason}</span>
</div>
{/if}
<!-- Link expense (incoming only, not already linked) --> <!-- Link expense (incoming only, not already linked) -->
{#if inv.direction === 'incoming' && !inv.expenseId} {#if inv.direction === 'incoming' && !inv.expenseId}
<div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4"> <div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4">
@@ -0,0 +1,198 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
procedureTemplates,
procedureSteps,
procedureInstances
} from '$lib/server/db/schema.js';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, eq, isNull, sql, ne } from 'drizzle-orm';
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'viewer', 'hr', 'accountant'
]);
await parent();
const canManage = roles.some((r) => r === 'admin' || r === 'manager');
const whereClause = canManage
? and(eq(procedureTemplates.companyId, params.companyId), isNull(procedureTemplates.deletedAt))
: and(
eq(procedureTemplates.companyId, params.companyId),
isNull(procedureTemplates.deletedAt),
eq(procedureTemplates.isPublished, true)
);
const templates = await db
.select({
id: procedureTemplates.id,
title: procedureTemplates.title,
description: procedureTemplates.description,
category: procedureTemplates.category,
isPublished: procedureTemplates.isPublished,
createdAt: procedureTemplates.createdAt,
stepCount: sql<number>`(select count(*)::int from procedure_steps where template_id = ${procedureTemplates.id})`,
instanceCount: sql<number>`(select count(*)::int from procedure_instances where template_id = ${procedureTemplates.id} and status != 'cancelled')`
})
.from(procedureTemplates)
.where(whereClause)
.orderBy(asc(procedureTemplates.title));
return { templates, canManage };
};
export const actions: Actions = {
createTemplate: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
const description = trimOrNull(fd.get('description'));
const category = trimOrNull(fd.get('category'));
if (!title) return fail(400, { action: 'createTemplate', error: 'Title is required' });
const [inserted] = await db
.insert(procedureTemplates)
.values({
companyId: params.companyId,
title,
description,
category,
createdBy: user.id
})
.returning({ id: procedureTemplates.id });
await logCompanyEvent(
params.companyId,
user.id,
'procedure_template_created',
`Procedure "${title}" created`,
{ templateId: inserted.id }
);
return { success: true, action: 'createTemplate' };
},
updateTemplate: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
const title = trimOrNull(fd.get('title'));
const description = trimOrNull(fd.get('description'));
const category = trimOrNull(fd.get('category'));
if (!id) return fail(400, { action: 'updateTemplate', error: 'Template id required' });
if (!title) return fail(400, { action: 'updateTemplate', error: 'Title is required' });
const result = await db
.update(procedureTemplates)
.set({ title, description, category, updatedAt: new Date() })
.where(
and(
eq(procedureTemplates.id, id),
eq(procedureTemplates.companyId, params.companyId),
isNull(procedureTemplates.deletedAt)
)
)
.returning({ id: procedureTemplates.id });
if (result.length === 0) error(404, 'Template not found');
await logCompanyEvent(params.companyId, user.id, 'procedure_template_updated',
`Procedure "${title}" updated`, { templateId: id });
return { success: true, action: 'updateTemplate' };
},
deleteTemplate: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteTemplate', error: 'Template id required' });
const [existing] = await db
.select({ id: procedureTemplates.id, title: procedureTemplates.title })
.from(procedureTemplates)
.where(
and(
eq(procedureTemplates.id, id),
eq(procedureTemplates.companyId, params.companyId),
isNull(procedureTemplates.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Template not found');
// Check for active instances (RESTRICT FK would block, but give a nice error)
const [activeCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(procedureInstances)
.where(
and(eq(procedureInstances.templateId, id), ne(procedureInstances.status, 'cancelled'))
);
if (activeCount && activeCount.count > 0) {
return fail(400, {
action: 'deleteTemplate',
error: `Cannot delete — ${activeCount.count} active instance(s) exist`
});
}
await db
.update(procedureTemplates)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(procedureTemplates.id, id));
await logCompanyEvent(params.companyId, user.id, 'procedure_template_deleted',
`Procedure "${existing.title}" deleted`, { templateId: id });
return { success: true, action: 'deleteTemplate' };
},
publishTemplate: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'publishTemplate', error: 'Template id required' });
await db
.update(procedureTemplates)
.set({ isPublished: true, updatedAt: new Date() })
.where(
and(
eq(procedureTemplates.id, id),
eq(procedureTemplates.companyId, params.companyId),
isNull(procedureTemplates.deletedAt)
)
);
return { success: true, action: 'publishTemplate' };
},
unpublishTemplate: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'unpublishTemplate', error: 'Template id required' });
await db
.update(procedureTemplates)
.set({ isPublished: false, updatedAt: new Date() })
.where(
and(
eq(procedureTemplates.id, id),
eq(procedureTemplates.companyId, params.companyId),
isNull(procedureTemplates.deletedAt)
)
);
return { success: true, action: 'unpublishTemplate' };
}
};
@@ -0,0 +1,131 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showAddForm = $state(false);
let editingId = $state<string | null>(null);
let deletingId = $state<string | null>(null);
const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>Procedures - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<header>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Procedures</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Reusable checklists for standard processes. Start an instance to track progress on a specific case.
</p>
</header>
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
{#if data.canManage}
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">New Procedure</h2>
<button type="button" onclick={() => { showAddForm = !showAddForm; editingId = null; }}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddForm ? 'Cancel' : '+ New'}
</button>
</div>
{#if showAddForm}
<form method="POST" action="?/createTemplate"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') { showAddForm = false; formElement.reset(); }
}}
class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="md:col-span-2">
<label for="new-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
<input id="new-title" name="title" type="text" required class={inputCls} placeholder="e.g. Purchase Order Process" />
</div>
<div>
<label for="new-category" class={labelCls}>Category</label>
<input id="new-category" name="category" type="text" class={inputCls} placeholder="e.g. Finance, HR, Operations" />
</div>
<div class="md:col-span-2">
<label for="new-desc" class={labelCls}>Description</label>
<textarea id="new-desc" name="description" rows="2" class={inputCls}></textarea>
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Create</button>
</div>
</form>
{/if}
</section>
{/if}
{#if data.templates.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-sm text-gray-500 dark:text-gray-400">No procedures yet.</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each data.templates as tmpl (tmpl.id)}
<div class="group relative flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-500">
<a href={`/companies/${data.company.id}/procedures/${tmpl.id}`}
class="absolute inset-0 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
<span class="sr-only">Open {tmpl.title}</span>
</a>
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">{tmpl.title}</h3>
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {tmpl.isPublished
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}">
{tmpl.isPublished ? 'Published' : 'Draft'}
</span>
</div>
{#if tmpl.category}
<span class="text-xs text-gray-500 dark:text-gray-400">{tmpl.category}</span>
{/if}
{#if tmpl.description}
<p class="line-clamp-2 text-xs text-gray-600 dark:text-gray-300">{tmpl.description}</p>
{/if}
<div class="mt-auto flex gap-3 text-xs text-gray-500 dark:text-gray-400">
<span>{tmpl.stepCount} {tmpl.stepCount === 1 ? 'step' : 'steps'}</span>
<span>{tmpl.instanceCount} {tmpl.instanceCount === 1 ? 'instance' : 'instances'}</span>
</div>
{#if data.canManage}
<div class="relative z-10 flex justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700">
{#if tmpl.isPublished}
<form method="POST" action="?/unpublishTemplate" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="id" value={tmpl.id} />
<button type="submit" class="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400">Unpublish</button>
</form>
{:else}
<form method="POST" action="?/publishTemplate" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="id" value={tmpl.id} />
<button type="submit" class="text-xs font-medium text-green-600 hover:text-green-700 dark:text-green-400">Publish</button>
</form>
{/if}
<button type="button" onclick={() => (deletingId = deletingId === tmpl.id ? null : tmpl.id)}
class="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400">Delete</button>
</div>
{#if deletingId === tmpl.id}
<form method="POST" action="?/deleteTemplate"
use:enhance={() => async ({ update }) => { await update({ reset: false }); deletingId = null; }}
class="relative z-10 mt-1 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30">
<input type="hidden" name="id" value={tmpl.id} />
<p class="mb-2 text-red-700 dark:text-red-300">Delete "{tmpl.title}"?</p>
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (deletingId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">Cancel</button>
<button type="submit" class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700">Delete</button>
</div>
</form>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,230 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
procedureTemplates,
procedureSteps,
procedureInstances,
procedureInstanceSteps,
users
} from '$lib/server/db/schema.js';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'viewer', 'hr', 'accountant'
]);
await parent();
const canManage = roles.some((r) => r === 'admin' || r === 'manager');
const [template] = await db
.select()
.from(procedureTemplates)
.where(
and(
eq(procedureTemplates.id, params.templateId),
eq(procedureTemplates.companyId, params.companyId),
isNull(procedureTemplates.deletedAt)
)
)
.limit(1);
if (!template) error(404, 'Procedure not found');
if (!canManage && !template.isPublished) error(404, 'Procedure not found');
const steps = await db
.select()
.from(procedureSteps)
.where(eq(procedureSteps.templateId, params.templateId))
.orderBy(asc(procedureSteps.stepNumber));
const instances = await db
.select({
id: procedureInstances.id,
title: procedureInstances.title,
status: procedureInstances.status,
startedByName: users.displayName,
createdAt: procedureInstances.createdAt,
completedAt: procedureInstances.completedAt
})
.from(procedureInstances)
.leftJoin(users, eq(procedureInstances.startedBy, users.id))
.where(
and(
eq(procedureInstances.templateId, params.templateId),
eq(procedureInstances.companyId, params.companyId)
)
)
.orderBy(desc(procedureInstances.createdAt));
return { template, steps, instances, canManage };
};
export const actions: Actions = {
addStep: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
const description = trimOrNull(fd.get('description'));
const assigneeRole = trimOrNull(fd.get('assigneeRole'));
const estimatedMinutes = fd.get('estimatedMinutes')
? parseInt(fd.get('estimatedMinutes')!.toString())
: null;
if (!title) return fail(400, { action: 'addStep', error: 'Step title is required' });
const [maxRow] = await db
.select({ max: sql<number>`coalesce(max(${procedureSteps.stepNumber}), 0)::int` })
.from(procedureSteps)
.where(eq(procedureSteps.templateId, params.templateId));
await db.insert(procedureSteps).values({
templateId: params.templateId,
stepNumber: (maxRow?.max ?? 0) + 1,
title,
description,
assigneeRole,
estimatedMinutes: estimatedMinutes && !isNaN(estimatedMinutes) ? estimatedMinutes : null
});
return { success: true, action: 'addStep' };
},
updateStep: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const stepId = trimOrNull(fd.get('stepId'));
const title = trimOrNull(fd.get('title'));
const description = trimOrNull(fd.get('description'));
const assigneeRole = trimOrNull(fd.get('assigneeRole'));
const estimatedMinutes = fd.get('estimatedMinutes')
? parseInt(fd.get('estimatedMinutes')!.toString())
: null;
if (!stepId) return fail(400, { action: 'updateStep', error: 'Step id required' });
if (!title) return fail(400, { action: 'updateStep', error: 'Step title is required' });
await db
.update(procedureSteps)
.set({
title,
description,
assigneeRole,
estimatedMinutes: estimatedMinutes && !isNaN(estimatedMinutes) ? estimatedMinutes : null
})
.where(
and(eq(procedureSteps.id, stepId), eq(procedureSteps.templateId, params.templateId))
);
return { success: true, action: 'updateStep' };
},
removeStep: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const stepId = trimOrNull(fd.get('stepId'));
if (!stepId) return fail(400, { action: 'removeStep', error: 'Step id required' });
try {
await db
.delete(procedureSteps)
.where(
and(eq(procedureSteps.id, stepId), eq(procedureSteps.templateId, params.templateId))
);
} catch {
return fail(400, {
action: 'removeStep',
error: 'Cannot remove — step is referenced by active instances'
});
}
return { success: true, action: 'removeStep' };
},
reorderSteps: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const raw = fd.get('steps')?.toString();
if (!raw) return fail(400, { action: 'reorderSteps', error: 'Steps data required' });
let parsed: Array<{ id: string; stepNumber: number }>;
try {
parsed = JSON.parse(raw);
} catch {
return fail(400, { action: 'reorderSteps', error: 'Invalid JSON' });
}
await db.transaction(async (tx) => {
for (const { id, stepNumber } of parsed) {
await tx
.update(procedureSteps)
.set({ stepNumber })
.where(
and(eq(procedureSteps.id, id), eq(procedureSteps.templateId, params.templateId))
);
}
});
return { success: true, action: 'reorderSteps' };
},
startInstance: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'hr', 'accountant'
]);
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
if (!title) return fail(400, { action: 'startInstance', error: 'Instance title is required' });
const steps = await db
.select()
.from(procedureSteps)
.where(eq(procedureSteps.templateId, params.templateId))
.orderBy(asc(procedureSteps.stepNumber));
if (steps.length === 0) {
return fail(400, { action: 'startInstance', error: 'Template has no steps' });
}
const [instance] = await db
.insert(procedureInstances)
.values({
templateId: params.templateId,
companyId: params.companyId,
title,
startedBy: user.id
})
.returning({ id: procedureInstances.id });
await db.insert(procedureInstanceSteps).values(
steps.map((s) => ({
instanceId: instance.id,
stepId: s.id,
stepNumber: s.stepNumber,
title: s.title,
description: s.description
}))
);
await logCompanyEvent(
params.companyId,
user.id,
'procedure_instance_started',
`Started "${title}" (from procedure template)`,
{ instanceId: instance.id, templateId: params.templateId }
);
redirect(
303,
`/companies/${params.companyId}/procedures/${params.templateId}/instances/${instance.id}`
);
}
};
@@ -0,0 +1,190 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showAddStep = $state(false);
let editingStepId = $state<string | null>(null);
let showStartInstance = $state(false);
const STATUS_BADGE: Record<string, string> = {
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
completed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
cancelled: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
};
const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>{data.template.title} - Procedures</title>
</svelte:head>
<div class="space-y-6">
<header class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2">
<a href={`/companies/${data.company.id}/procedures`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Procedures</a>
</div>
<h1 class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{data.template.title}</h1>
{#if data.template.description}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{data.template.description}</p>
{/if}
<div class="mt-2 flex gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {data.template.isPublished
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}">
{data.template.isPublished ? 'Published' : 'Draft'}
</span>
{#if data.template.category}
<span class="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">{data.template.category}</span>
{/if}
</div>
</div>
<button type="button" onclick={() => { showStartInstance = !showStartInstance; }}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Start Instance
</button>
</header>
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
{#if showStartInstance}
<form method="POST" action="?/startInstance"
use:enhance
class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/20">
<label for="instance-title" class={labelCls}>Instance Title <span class="text-red-500">*</span></label>
<input id="instance-title" name="title" type="text" required value={data.template.title} class={inputCls} />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Customize for this specific case, e.g. "Onboard ABC Corp"</p>
<div class="mt-3 flex justify-end gap-2">
<button type="button" onclick={() => (showStartInstance = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Start</button>
</div>
</form>
{/if}
<!-- Steps -->
<section class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Steps ({data.steps.length})
</h2>
{#if data.steps.length === 0}
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No steps yet. Add the first step below.</p>
{:else}
<ol class="space-y-3">
{#each data.steps as step, i (step.id)}
<li class="flex items-start gap-3 rounded-md border border-gray-100 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-700/30">
<span class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-bold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
{step.stepNumber}
</span>
<div class="min-w-0 flex-1">
{#if editingStepId === step.id && data.canManage}
<form method="POST" action="?/updateStep"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingStepId = null;
}}
class="space-y-2">
<input type="hidden" name="stepId" value={step.id} />
<input name="title" type="text" required value={step.title} class={inputCls} />
<textarea name="description" rows="2" class={inputCls}>{step.description ?? ''}</textarea>
<div class="flex gap-2">
<input name="assigneeRole" type="text" value={step.assigneeRole ?? ''} placeholder="Role (e.g. manager)" class={inputCls} />
<input name="estimatedMinutes" type="number" min="0" value={step.estimatedMinutes ?? ''} placeholder="Est. min" class={inputCls} />
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (editingStepId = null)} class="text-xs text-gray-500">Cancel</button>
<button type="submit" class="text-xs font-medium text-blue-600">Save</button>
</div>
</form>
{:else}
<div class="text-sm font-medium text-gray-900 dark:text-white">{step.title}</div>
{#if step.description}
<p class="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{step.description}</p>
{/if}
<div class="mt-1 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
{#if step.assigneeRole}
<span class="rounded bg-gray-200 px-1.5 py-0.5 dark:bg-gray-600 dark:text-gray-300">{step.assigneeRole}</span>
{/if}
{#if step.estimatedMinutes}
<span>~{step.estimatedMinutes} min</span>
{/if}
</div>
{#if data.canManage}
<div class="mt-2 flex gap-2 text-xs">
<button type="button" onclick={() => { editingStepId = step.id; }} class="font-medium text-blue-600 dark:text-blue-400">Edit</button>
<form method="POST" action="?/removeStep" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="stepId" value={step.id} />
<button type="submit" class="font-medium text-red-600 dark:text-red-400">Remove</button>
</form>
</div>
{/if}
{/if}
</div>
</li>
{/each}
</ol>
{/if}
{#if data.canManage}
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
{#if showAddStep}
<form method="POST" action="?/addStep"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') formElement.reset();
}}
class="space-y-2">
<input name="title" type="text" required placeholder="Step title" class={inputCls} />
<textarea name="description" rows="2" placeholder="Description (optional)" class={inputCls}></textarea>
<div class="flex gap-2">
<input name="assigneeRole" type="text" placeholder="Role (optional)" class={inputCls} />
<input name="estimatedMinutes" type="number" min="0" placeholder="Est. minutes" class={inputCls} />
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (showAddStep = false)} class="text-xs text-gray-500">Cancel</button>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Add Step</button>
</div>
</form>
{:else}
<button type="button" onclick={() => (showAddStep = true)}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">+ Add Step</button>
{/if}
</div>
{/if}
</section>
<!-- Instances -->
<section class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Instances ({data.instances.length})
</h2>
{#if data.instances.length === 0}
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No instances started yet.</p>
{:else}
<div class="space-y-2">
{#each data.instances as inst (inst.id)}
<a href={`/companies/${data.company.id}/procedures/${data.template.id}/instances/${inst.id}`}
class="flex items-center justify-between rounded-md border border-gray-100 bg-gray-50 p-3 transition-colors hover:border-blue-300 dark:border-gray-700 dark:bg-gray-700/30 dark:hover:border-blue-500">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">{inst.title}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Started by {inst.startedByName ?? 'Unknown'} · {formatDate(inst.createdAt)}
</div>
</div>
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[inst.status] ?? STATUS_BADGE.in_progress}">
{inst.status.replace('_', ' ')}
</span>
</a>
{/each}
</div>
{/if}
</section>
</div>
@@ -0,0 +1,220 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
procedureInstances,
procedureInstanceSteps,
procedureTemplates,
users
} from '$lib/server/db/schema.js';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'viewer', 'hr', 'accountant'
]);
await parent();
const canManage = roles.some((r) => r === 'admin' || r === 'manager');
const [instance] = await db
.select({
id: procedureInstances.id,
templateId: procedureInstances.templateId,
title: procedureInstances.title,
status: procedureInstances.status,
startedByName: users.displayName,
notes: procedureInstances.notes,
completedAt: procedureInstances.completedAt,
cancelledAt: procedureInstances.cancelledAt,
createdAt: procedureInstances.createdAt
})
.from(procedureInstances)
.leftJoin(users, eq(procedureInstances.startedBy, users.id))
.where(
and(
eq(procedureInstances.id, params.instanceId),
eq(procedureInstances.companyId, params.companyId)
)
)
.limit(1);
if (!instance) error(404, 'Instance not found');
const steps = await db
.select({
id: procedureInstanceSteps.id,
stepNumber: procedureInstanceSteps.stepNumber,
title: procedureInstanceSteps.title,
description: procedureInstanceSteps.description,
isCompleted: procedureInstanceSteps.isCompleted,
completedByName: users.displayName,
completedAt: procedureInstanceSteps.completedAt,
notes: procedureInstanceSteps.notes
})
.from(procedureInstanceSteps)
.leftJoin(users, eq(procedureInstanceSteps.completedBy, users.id))
.where(eq(procedureInstanceSteps.instanceId, params.instanceId))
.orderBy(asc(procedureInstanceSteps.stepNumber));
return { instance, instanceSteps: steps, canManage };
};
async function checkAutoComplete(instanceId: string, companyId: string, userId: string) {
const [counts] = await db
.select({
total: sql<number>`count(*)::int`,
done: sql<number>`count(*) filter (where ${procedureInstanceSteps.isCompleted})::int`
})
.from(procedureInstanceSteps)
.where(eq(procedureInstanceSteps.instanceId, instanceId));
if (counts && counts.total > 0 && counts.done === counts.total) {
await db
.update(procedureInstances)
.set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() })
.where(eq(procedureInstances.id, instanceId));
await logCompanyEvent(companyId, userId, 'procedure_instance_completed',
'All steps completed — instance auto-completed',
{ instanceId });
}
}
export const actions: Actions = {
completeStep: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'hr', 'accountant'
]);
const fd = await request.formData();
const stepId = trimOrNull(fd.get('stepId'));
if (!stepId) return fail(400, { action: 'completeStep', error: 'Step id required' });
await db
.update(procedureInstanceSteps)
.set({
isCompleted: true,
completedBy: user.id,
completedAt: new Date()
})
.where(
and(
eq(procedureInstanceSteps.id, stepId),
eq(procedureInstanceSteps.instanceId, params.instanceId)
)
);
await logCompanyEvent(params.companyId, user.id, 'procedure_step_completed',
'Procedure step completed', { instanceId: params.instanceId, stepId });
await checkAutoComplete(params.instanceId, params.companyId, user.id);
return { success: true, action: 'completeStep' };
},
uncompleteStep: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'hr', 'accountant'
]);
const fd = await request.formData();
const stepId = trimOrNull(fd.get('stepId'));
if (!stepId) return fail(400, { action: 'uncompleteStep', error: 'Step id required' });
await db
.update(procedureInstanceSteps)
.set({ isCompleted: false, completedBy: null, completedAt: null })
.where(
and(
eq(procedureInstanceSteps.id, stepId),
eq(procedureInstanceSteps.instanceId, params.instanceId)
)
);
// If instance was completed, revert to in_progress
const [inst] = await db
.select({ status: procedureInstances.status })
.from(procedureInstances)
.where(eq(procedureInstances.id, params.instanceId))
.limit(1);
if (inst?.status === 'completed') {
await db
.update(procedureInstances)
.set({ status: 'in_progress', completedAt: null, updatedAt: new Date() })
.where(eq(procedureInstances.id, params.instanceId));
}
return { success: true, action: 'uncompleteStep' };
},
addStepNote: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'hr', 'accountant'
]);
const fd = await request.formData();
const stepId = trimOrNull(fd.get('stepId'));
const notes = trimOrNull(fd.get('notes'));
if (!stepId) return fail(400, { action: 'addStepNote', error: 'Step id required' });
await db
.update(procedureInstanceSteps)
.set({ notes })
.where(
and(
eq(procedureInstanceSteps.id, stepId),
eq(procedureInstanceSteps.instanceId, params.instanceId)
)
);
return { success: true, action: 'addStepNote' };
},
completeInstance: async ({ locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'hr', 'accountant'
]);
await db
.update(procedureInstances)
.set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(procedureInstances.id, params.instanceId),
eq(procedureInstances.companyId, params.companyId)
)
);
await logCompanyEvent(params.companyId, user.id, 'procedure_instance_completed',
'Procedure instance manually completed',
{ instanceId: params.instanceId });
return { success: true, action: 'completeInstance' };
},
cancelInstance: async ({ locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
await db
.update(procedureInstances)
.set({ status: 'cancelled', cancelledAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(procedureInstances.id, params.instanceId),
eq(procedureInstances.companyId, params.companyId)
)
);
await logCompanyEvent(params.companyId, user.id, 'procedure_instance_cancelled',
'Procedure instance cancelled',
{ instanceId: params.instanceId });
return { success: true, action: 'cancelInstance' };
}
};
@@ -0,0 +1,160 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const STATUS_BADGE: Record<string, string> = {
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
completed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
cancelled: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
};
const completedCount = $derived(data.instanceSteps.filter((s) => s.isCompleted).length);
const totalSteps = $derived(data.instanceSteps.length);
const progressPct = $derived(totalSteps > 0 ? (completedCount / totalSteps) * 100 : 0);
const isLive = $derived(data.instance.status === 'in_progress');
let editingNoteId = $state<string | null>(null);
const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
</script>
<svelte:head>
<title>{data.instance.title} - Procedure Instance</title>
</svelte:head>
<div class="space-y-6">
<header>
<a href={`/companies/${data.company.id}/procedures/${data.instance.templateId}`}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Back to template</a>
<h1 class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{data.instance.title}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.instance.status]}">
{data.instance.status.replace('_', ' ')}
</span>
<span>Started by {data.instance.startedByName ?? 'Unknown'}</span>
<span>{formatDate(data.instance.createdAt)}</span>
{#if data.instance.completedAt}
<span>Completed {formatDate(data.instance.completedAt)}</span>
{/if}
</div>
</header>
<!-- Progress bar -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">Progress</span>
<span class="text-gray-500 dark:text-gray-400">{completedCount} of {totalSteps} steps</span>
</div>
<div class="mt-2 h-2.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div
class="h-full rounded-full transition-all {progressPct === 100 ? 'bg-green-500' : 'bg-blue-500'}"
style="width: {progressPct}%"
></div>
</div>
</div>
<!-- Steps checklist -->
<section class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<ol class="divide-y divide-gray-100 dark:divide-gray-700">
{#each data.instanceSteps as step (step.id)}
<li class="flex items-start gap-3 p-4 {step.isCompleted ? 'bg-green-50/50 dark:bg-green-900/10' : ''}">
<div class="pt-0.5">
{#if isLive}
<form method="POST" action={step.isCompleted ? '?/uncompleteStep' : '?/completeStep'}
use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="stepId" value={step.id} />
<button type="submit"
class="flex h-5 w-5 items-center justify-center rounded border-2 transition-colors {step.isCompleted
? 'border-green-500 bg-green-500 text-white'
: 'border-gray-300 hover:border-blue-400 dark:border-gray-600'}">
{#if step.isCompleted}
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</button>
</form>
{:else}
<div class="flex h-5 w-5 items-center justify-center rounded border-2 {step.isCompleted
? 'border-green-500 bg-green-500 text-white'
: 'border-gray-300 dark:border-gray-600'}">
{#if step.isCompleted}
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div>
<span class="text-xs font-bold text-gray-400 dark:text-gray-500">Step {step.stepNumber}</span>
<p class="text-sm font-medium {step.isCompleted ? 'text-gray-500 line-through dark:text-gray-400' : 'text-gray-900 dark:text-white'}">
{step.title}
</p>
</div>
</div>
{#if step.description}
<p class="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{step.description}</p>
{/if}
{#if step.isCompleted && step.completedByName}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">
Completed by {step.completedByName}
{#if step.completedAt} on {formatDate(step.completedAt)}{/if}
</p>
{/if}
{#if step.notes}
<p class="mt-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{step.notes}
</p>
{/if}
{#if isLive}
{#if editingNoteId === step.id}
<form method="POST" action="?/addStepNote"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingNoteId = null;
}}
class="mt-2">
<input type="hidden" name="stepId" value={step.id} />
<textarea name="notes" rows="2" class={inputCls} placeholder="Add a note...">{step.notes ?? ''}</textarea>
<div class="mt-1 flex justify-end gap-2">
<button type="button" onclick={() => (editingNoteId = null)} class="text-xs text-gray-500">Cancel</button>
<button type="submit" class="text-xs font-medium text-blue-600">Save note</button>
</div>
</form>
{:else}
<button type="button" onclick={() => (editingNoteId = step.id)}
class="mt-1 text-xs text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400">
{step.notes ? 'Edit note' : 'Add note'}
</button>
{/if}
{/if}
</div>
</li>
{/each}
</ol>
</section>
<!-- Actions -->
{#if isLive}
<div class="flex gap-3">
<form method="POST" action="?/completeInstance" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<button type="submit" class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
Complete Instance
</button>
</form>
{#if data.canManage}
<form method="POST" action="?/cancelInstance" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<button type="submit" class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Cancel Instance
</button>
</form>
{/if}
</div>
{/if}
</div>
@@ -1,300 +1,33 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { import { companyAddresses } from '$lib/server/db/schema.js';
companyBankAccounts, import { eq, and, desc, asc } from 'drizzle-orm';
companyCards,
companyAddresses
} from '$lib/server/db/schema.js';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
const ALL_ADDRESS_TYPES = ['legal', 'shipping', 'billing', 'other'] as const; const ALL_ADDRESS_TYPES = ['legal', 'shipping', 'billing', 'other'] as const;
const ALL_CARD_BRANDS = [
'visa',
'mastercard',
'amex',
'jcb',
'unionpay',
'discover',
'other'
] as const;
type AddressType = (typeof ALL_ADDRESS_TYPES)[number]; type AddressType = (typeof ALL_ADDRESS_TYPES)[number];
type CardBrand = (typeof ALL_CARD_BRANDS)[number];
function trimOrNull(v: FormDataEntryValue | null): string | null { function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim(); const s = v?.toString().trim();
return s ? s : null; return s ? s : null;
} }
function parseInt0(v: FormDataEntryValue | null): number | null {
const s = v?.toString().trim();
if (!s) return null;
const n = parseInt(s, 10);
return isNaN(n) ? null : n;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => { export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent(); await parent();
const bankAccounts = await db
.select()
.from(companyBankAccounts)
.where(eq(companyBankAccounts.companyId, params.companyId))
.orderBy(desc(companyBankAccounts.isPrimary), asc(companyBankAccounts.bankName));
const cards = await db
.select({
id: companyCards.id,
brand: companyCards.brand,
last4: companyCards.last4,
cardholderName: companyCards.cardholderName,
expiryMonth: companyCards.expiryMonth,
expiryYear: companyCards.expiryYear,
nickname: companyCards.nickname,
isActive: companyCards.isActive,
notes: companyCards.notes,
bankAccountId: companyCards.bankAccountId,
bankAccountLabel: sql<string | null>`(
SELECT ${companyBankAccounts.bankName} || ' · ' || RIGHT(${companyBankAccounts.accountNumber}, 4)
FROM ${companyBankAccounts}
WHERE ${companyBankAccounts.id} = ${companyCards.bankAccountId}
)`,
createdAt: companyCards.createdAt
})
.from(companyCards)
.where(eq(companyCards.companyId, params.companyId))
.orderBy(asc(companyCards.brand));
const addresses = await db const addresses = await db
.select() .select()
.from(companyAddresses) .from(companyAddresses)
.where(eq(companyAddresses.companyId, params.companyId)) .where(eq(companyAddresses.companyId, params.companyId))
.orderBy(asc(companyAddresses.type), desc(companyAddresses.isDefault)); .orderBy(asc(companyAddresses.type), desc(companyAddresses.isDefault));
return { bankAccounts, cards, addresses }; return { addresses };
}; };
export const actions: Actions = { export const actions: Actions = {
addBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const bankName = trimOrNull(fd.get('bankName'));
const accountName = trimOrNull(fd.get('accountName'));
const accountNumber = trimOrNull(fd.get('accountNumber'));
if (!bankName) return fail(400, { error: 'Bank name is required' });
if (!accountName) return fail(400, { error: 'Account name is required' });
if (!accountNumber) return fail(400, { error: 'Account number is required' });
const isPrimary = fd.get('isPrimary') === 'on';
// If marking primary, demote others first
if (isPrimary) {
await db
.update(companyBankAccounts)
.set({ isPrimary: false })
.where(eq(companyBankAccounts.companyId, params.companyId));
}
await db.insert(companyBankAccounts).values({
companyId: params.companyId,
bankName,
accountName,
accountNumber,
accountType: trimOrNull(fd.get('accountType')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
currency: trimOrNull(fd.get('currency')) ?? 'THB',
isPrimary,
notes: trimOrNull(fd.get('notes'))
});
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_added',
`Bank account "${bankName}" added`
);
return { success: true };
},
updateBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const bankName = trimOrNull(fd.get('bankName'));
const accountName = trimOrNull(fd.get('accountName'));
const accountNumber = trimOrNull(fd.get('accountNumber'));
if (!bankName || !accountName || !accountNumber) {
return fail(400, { error: 'Bank name, account name, and account number are required' });
}
await db
.update(companyBankAccounts)
.set({
bankName,
accountName,
accountNumber,
accountType: trimOrNull(fd.get('accountType')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
currency: trimOrNull(fd.get('currency')) ?? 'THB',
isActive: fd.get('isActive') === 'on',
notes: trimOrNull(fd.get('notes')),
updatedAt: new Date()
})
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_updated',
`Bank account "${bankName}" updated`
);
return { success: true };
},
setPrimaryBankAccount: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
await db
.update(companyBankAccounts)
.set({ isPrimary: false })
.where(eq(companyBankAccounts.companyId, params.companyId));
await db
.update(companyBankAccounts)
.set({ isPrimary: true, updatedAt: new Date() })
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
return { success: true };
},
removeBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const [ba] = await db
.select({ bankName: companyBankAccounts.bankName })
.from(companyBankAccounts)
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
)
.limit(1);
await db
.delete(companyBankAccounts)
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
if (ba) {
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_removed',
`Bank account "${ba.bankName}" removed`
);
}
return { success: true };
},
addCard: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const brand = fd.get('brand')?.toString() as CardBrand | undefined;
const last4 = fd.get('last4')?.toString().trim();
const cardholderName = trimOrNull(fd.get('cardholderName'));
if (!brand || !ALL_CARD_BRANDS.includes(brand)) {
return fail(400, { error: 'Card brand is required' });
}
if (!last4 || !/^\d{4}$/.test(last4)) {
return fail(400, { error: 'Last 4 digits must be exactly 4 numbers' });
}
if (!cardholderName) return fail(400, { error: 'Cardholder name is required' });
const bankAccountId = trimOrNull(fd.get('bankAccountId'));
await db.insert(companyCards).values({
companyId: params.companyId,
brand,
last4,
cardholderName,
expiryMonth: parseInt0(fd.get('expiryMonth')),
expiryYear: parseInt0(fd.get('expiryYear')),
nickname: trimOrNull(fd.get('nickname')),
bankAccountId,
notes: trimOrNull(fd.get('notes'))
});
await logCompanyEvent(
params.companyId,
user.id,
'card_added',
`Card ${brand.toUpperCase()} •••• ${last4} added`
);
return { success: true };
},
removeCard: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const [c] = await db
.select({ brand: companyCards.brand, last4: companyCards.last4 })
.from(companyCards)
.where(and(eq(companyCards.id, id), eq(companyCards.companyId, params.companyId)))
.limit(1);
await db
.delete(companyCards)
.where(and(eq(companyCards.id, id), eq(companyCards.companyId, params.companyId)));
if (c) {
await logCompanyEvent(
params.companyId,
user.id,
'card_removed',
`Card ${c.brand.toUpperCase()} •••• ${c.last4} removed`
);
}
return { success: true };
},
addAddress: async ({ request, locals, params }) => { addAddress: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin'); const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData(); const fd = await request.formData();
@@ -6,22 +6,9 @@
const isAdmin = $derived(data.companyRoles.includes('admin')); const isAdmin = $derived(data.companyRoles.includes('admin'));
let showAddBank = $state(false);
let editBankId = $state<string | null>(null);
let showAddCard = $state(false);
let showAddAddress = $state(false); let showAddAddress = $state(false);
let editAddressId = $state<string | null>(null); let editAddressId = $state<string | null>(null);
const BRAND_LABELS: Record<string, string> = {
visa: 'Visa',
mastercard: 'Mastercard',
amex: 'American Express',
jcb: 'JCB',
unionpay: 'UnionPay',
discover: 'Discover',
other: 'Other'
};
const ADDRESS_TYPE_LABELS: Record<string, string> = { const ADDRESS_TYPE_LABELS: Record<string, string> = {
legal: 'Legal', legal: 'Legal',
shipping: 'Shipping', shipping: 'Shipping',
@@ -36,18 +23,7 @@
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}; };
function maskAccount(n: string): string { function fullAddress(a: (typeof data.addresses)[number]): string {
if (!n) return '';
if (n.length <= 4) return n;
return '••••' + n.slice(-4);
}
function formatExpiry(m: number | null, y: number | null): string {
if (!m || !y) return '—';
return `${String(m).padStart(2, '0')}/${String(y).slice(-2)}`;
}
function fullAddress(a: typeof data.addresses[number]): string {
return [ return [
a.addressLine1, a.addressLine1,
a.addressLine2, a.addressLine2,
@@ -87,7 +63,10 @@
<header> <header>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Company Profile</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Company Profile</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Reference data for accounting, payments, and shipping. Visible to admin, manager, and accountant. Editing is admin-only. Legal and shipping addresses. For bank accounts, cards, cash, and anything with a balance, use the <a
href={`/companies/${data.company.id}/accounts`}
class="font-medium text-blue-600 underline hover:text-blue-700 dark:text-blue-400">Accounts</a
> tab.
</p> </p>
</header> </header>
@@ -97,234 +76,30 @@
</div> </div>
{/if} {/if}
<!-- ========== Bank Accounts ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Bank Accounts</h2>
{#if isAdmin}
<button onclick={() => (showAddBank = !showAddBank)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddBank ? 'Cancel' : '+ Add Bank Account'}
</button>
{/if}
</div>
{#if showAddBank && isAdmin}
<form method="POST" action="?/addBankAccount"
use:enhance={() => async ({ update }) => { await update(); showAddBank = false; }}
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
<div><label for="bankName" class={labelCls}>Bank Name *</label><input id="bankName" name="bankName" required class={inputCls} /></div>
<div><label for="accountName" class={labelCls}>Account Name *</label><input id="accountName" name="accountName" required class={inputCls} /></div>
<div><label for="accountNumber" class={labelCls}>Account Number *</label><input id="accountNumber" name="accountNumber" required class={inputCls} /></div>
<div><label for="accountType" class={labelCls}>Type</label><input id="accountType" name="accountType" placeholder="savings / current" class={inputCls} /></div>
<div><label for="branch" class={labelCls}>Branch</label><input id="branch" name="branch" class={inputCls} /></div>
<div><label for="currency" class={labelCls}>Currency</label><input id="currency" name="currency" value="THB" maxlength="3" class={inputCls} /></div>
<div><label for="swiftBic" class={labelCls}>SWIFT/BIC</label><input id="swiftBic" name="swiftBic" class={inputCls} /></div>
<div><label for="iban" class={labelCls}>IBAN</label><input id="iban" name="iban" class={inputCls} /></div>
<div class="sm:col-span-2"><label for="ba-notes" class={labelCls}>Notes</label><textarea id="ba-notes" name="notes" rows="2" class={inputCls}></textarea></div>
<label class="sm:col-span-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="isPrimary" class="rounded" /> Mark as primary account
</label>
<div class="sm:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
{/if}
{#if data.bankAccounts.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No bank accounts on file.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 font-medium">Bank</th>
<th class="px-3 py-2 font-medium">Account Name</th>
<th class="px-3 py-2 font-medium">Account Number</th>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Currency</th>
<th class="px-3 py-2 font-medium">Status</th>
{#if isAdmin}<th class="px-3 py-2 font-medium">Actions</th>{/if}
</tr>
</thead>
<tbody>
{#each data.bankAccounts as ba}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">
{ba.bankName}
{#if ba.branch}<span class="ml-1 text-xs text-gray-400">({ba.branch})</span>{/if}
</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{ba.accountName}</td>
<td class="px-3 py-2 font-mono text-gray-700 dark:text-gray-300">{maskAccount(ba.accountNumber)}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{ba.accountType ?? '—'}</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{ba.currency}</td>
<td class="px-3 py-2">
{#if ba.isPrimary}<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Primary</span>{/if}
{#if !ba.isActive}<span class="ml-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">Inactive</span>{/if}
</td>
{#if isAdmin}
<td class="px-3 py-2">
<div class="flex gap-2 text-xs">
<button type="button" onclick={() => (editBankId = editBankId === ba.id ? null : ba.id)}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400">{editBankId === ba.id ? 'Close' : 'Edit'}</button>
{#if !ba.isPrimary}
<form method="POST" action="?/setPrimaryBankAccount" use:enhance={enhanceNoReset} class="inline">
<input type="hidden" name="id" value={ba.id} />
<button type="submit" class="text-gray-600 hover:text-gray-800 dark:text-gray-300">Set Primary</button>
</form>
{/if}
<form method="POST" action="?/removeBankAccount"
use:enhance={({ cancel }) => {
if (!confirm('Remove this bank account?')) { cancel(); return; }
return async ({ update }) => await update({ reset: false });
}} class="inline">
<input type="hidden" name="id" value={ba.id} />
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
</form>
</div>
</td>
{/if}
</tr>
{#if editBankId === ba.id && isAdmin}
<tr class="border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<td colspan="7" class="px-3 py-3">
<form method="POST" action="?/updateBankAccount"
use:enhance={() => async ({ update }) => { await update({ reset: false }); editBankId = null; }}
class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input type="hidden" name="id" value={ba.id} />
<div><span class={labelCls}>Bank Name *</span><input name="bankName" required value={ba.bankName} class={inputCls} /></div>
<div><span class={labelCls}>Account Name *</span><input name="accountName" required value={ba.accountName} class={inputCls} /></div>
<div><span class={labelCls}>Account Number *</span><input name="accountNumber" required value={ba.accountNumber} class={inputCls + ' font-mono'} /></div>
<div><span class={labelCls}>Type</span><input name="accountType" value={ba.accountType ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Branch</span><input name="branch" value={ba.branch ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Currency</span><input name="currency" value={ba.currency} maxlength="3" class={inputCls} /></div>
<div><span class={labelCls}>SWIFT/BIC</span><input name="swiftBic" value={ba.swiftBic ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>IBAN</span><input name="iban" value={ba.iban ?? ''} class={inputCls} /></div>
<div class="sm:col-span-2"><span class={labelCls}>Notes</span><textarea name="notes" rows="2" class={inputCls}>{ba.notes ?? ''}</textarea></div>
<label class="sm:col-span-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="isActive" checked={ba.isActive} class="rounded" /> Active
</label>
<div class="sm:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editBankId = null)} class="rounded-md px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<!-- ========== Cards ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Credit / Debit Cards</h2>
{#if isAdmin}
<button onclick={() => (showAddCard = !showAddCard)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddCard ? 'Cancel' : '+ Add Card'}
</button>
{/if}
</div>
<div class="mb-4 rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<strong>Last 4 digits only.</strong> Never enter a full card number — this app does not store full PANs.
</div>
{#if showAddCard && isAdmin}
<form method="POST" action="?/addCard"
use:enhance={() => async ({ update }) => { await update(); showAddCard = false; }}
class="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
<div>
<label for="brand" class={labelCls}>Brand *</label>
<select id="brand" name="brand" required class={inputCls}>
{#each Object.entries(BRAND_LABELS) as [val, label]}<option value={val}>{label}</option>{/each}
</select>
</div>
<div><label for="last4" class={labelCls}>Last 4 *</label><input id="last4" name="last4" required maxlength="4" minlength="4" pattern="[0-9]{'{'}4{'}'}" inputmode="numeric" placeholder="1234" class={inputCls + ' font-mono'} /></div>
<div><label for="cardholderName" class={labelCls}>Cardholder *</label><input id="cardholderName" name="cardholderName" required class={inputCls} /></div>
<div><label for="expiryMonth" class={labelCls}>Expiry Month</label><input id="expiryMonth" name="expiryMonth" type="number" min="1" max="12" placeholder="MM" class={inputCls} /></div>
<div><label for="expiryYear" class={labelCls}>Expiry Year</label><input id="expiryYear" name="expiryYear" type="number" min="2024" max="2099" placeholder="YYYY" class={inputCls} /></div>
<div><label for="nickname" class={labelCls}>Nickname</label><input id="nickname" name="nickname" placeholder="e.g. Ops Visa" class={inputCls} /></div>
<div class="sm:col-span-3">
<label for="bankAccountId" class={labelCls}>Linked Bank Account</label>
<select id="bankAccountId" name="bankAccountId" class={inputCls}>
<option value="">— None —</option>
{#each data.bankAccounts as ba}<option value={ba.id}>{ba.bankName} · {maskAccount(ba.accountNumber)}</option>{/each}
</select>
</div>
<div class="sm:col-span-3"><label for="card-notes" class={labelCls}>Notes</label><textarea id="card-notes" name="notes" rows="2" class={inputCls}></textarea></div>
<div class="sm:col-span-3 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
{/if}
{#if data.cards.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No cards on file.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 font-medium">Brand</th>
<th class="px-3 py-2 font-medium">Card</th>
<th class="px-3 py-2 font-medium">Cardholder</th>
<th class="px-3 py-2 font-medium">Expiry</th>
<th class="px-3 py-2 font-medium">Linked Bank</th>
<th class="px-3 py-2 font-medium">Nickname</th>
{#if isAdmin}<th class="px-3 py-2 font-medium"></th>{/if}
</tr>
</thead>
<tbody>
{#each data.cards as c}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">{BRAND_LABELS[c.brand] ?? c.brand}</td>
<td class="px-3 py-2 font-mono text-gray-700 dark:text-gray-300">•••• {c.last4}</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{c.cardholderName}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{formatExpiry(c.expiryMonth, c.expiryYear)}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{c.bankAccountLabel ?? '—'}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{c.nickname ?? '—'}</td>
{#if isAdmin}
<td class="px-3 py-2 text-right">
<form method="POST" action="?/removeCard"
use:enhance={({ cancel }) => {
if (!confirm('Remove this card?')) { cancel(); return; }
return async ({ update }) => await update({ reset: false });
}} class="inline">
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
</form>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<!-- ========== Addresses ========== --> <!-- ========== Addresses ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"> <section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Addresses</h2> <h2 class="font-semibold text-gray-900 dark:text-white">Addresses</h2>
{#if isAdmin} {#if isAdmin}
<button onclick={() => (showAddAddress = !showAddAddress)} <button
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"> onclick={() => (showAddAddress = !showAddAddress)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddAddress ? 'Cancel' : '+ Add Address'} {showAddAddress ? 'Cancel' : '+ Add Address'}
</button> </button>
{/if} {/if}
</div> </div>
{#if showAddAddress && isAdmin} {#if showAddAddress && isAdmin}
<form method="POST" action="?/addAddress" <form
use:enhance={() => async ({ update }) => { await update(); showAddAddress = false; }} method="POST"
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20"> action="?/addAddress"
use:enhance={() => async ({ update }) => {
await update();
showAddAddress = false;
}}
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20"
>
<div> <div>
<label for="addr-type" class={labelCls}>Type *</label> <label for="addr-type" class={labelCls}>Type *</label>
<select id="addr-type" name="type" required class={inputCls}> <select id="addr-type" name="type" required class={inputCls}>
@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { projects, expenses } from '$lib/server/db/schema.js'; import { projects, expenses, companyAccounts } from '$lib/server/db/schema.js';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent }) => { export const load: PageServerLoad = async ({ parent }) => {
@@ -13,12 +13,13 @@ export const load: PageServerLoad = async ({ parent }) => {
description: projects.description, description: projects.description,
allocatedBudget: projects.allocatedBudget, allocatedBudget: projects.allocatedBudget,
isActive: projects.isActive, isActive: projects.isActive,
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`, spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
expenseCount: sql<number>`count(${expenses.id})::int`, expenseCount: sql<number>`count(${expenses.id})::int`,
pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int` pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
}) })
.from(projects) .from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id)) .leftJoin(expenses, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(eq(projects.companyId, company.id)) .where(eq(projects.companyId, company.id))
.groupBy(projects.id) .groupBy(projects.id)
.orderBy(projects.name); .orderBy(projects.name);
@@ -1,8 +1,10 @@
import { error } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { projects, expenses, users, categories } from '$lib/server/db/schema.js'; import { projects, expenses, users, categories, companyAccounts } from '$lib/server/db/schema.js';
import { eq, and, sql } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ params, parent }) => { export const load: PageServerLoad = async ({ params, parent }) => {
await parent(); await parent();
@@ -39,12 +41,50 @@ export const load: PageServerLoad = async ({ params, parent }) => {
const [stats] = await db const [stats] = await db
.select({ .select({
totalApproved: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`, totalApproved: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
totalPending: sql<string>`coalesce(sum(case when ${expenses.status} = 'pending' then ${expenses.amount} else 0 end), 0)`, totalPending: sql<string>`coalesce(sum(case when ${expenses.status} = 'pending' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
count: sql<number>`count(*)::int` count: sql<number>`count(*)::int`
}) })
.from(expenses) .from(expenses)
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(eq(expenses.projectId, params.projectId)); .where(eq(expenses.projectId, params.projectId));
return { project, expenses: expenseList, stats }; return { project, expenses: expenseList, stats };
}; };
export const actions: Actions = {
updateProject: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const name = fd.get('name')?.toString().trim();
const description = fd.get('description')?.toString().trim() || null;
if (!name) return fail(400, { action: 'updateProject', error: 'Project name is required' });
const [existing] = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.id, params.projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!existing) error(404, 'Project not found');
await db
.update(projects)
.set({ name, description, updatedAt: new Date() })
.where(eq(projects.id, params.projectId));
const renamed = existing.name !== name;
await logCompanyEvent(
params.companyId,
user.id,
'project_updated',
renamed
? `Project renamed from "${existing.name}" to "${name}"`
: `Project "${name}" updated`,
{ projectId: params.projectId, previousName: existing.name, newName: name }
);
return { success: true, action: 'updateProject' };
}
};
@@ -1,10 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
let { data } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
const currency = $derived(data.company.currency); const currency = $derived(data.company.currency);
const canAddExpense = $derived(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')); const canAddExpense = $derived(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr'));
const canEdit = $derived(data.companyRoles.some(r => r === 'admin' || r === 'manager'));
let editing = $state(false);
</script> </script>
<svelte:head> <svelte:head>
@@ -12,14 +16,68 @@
</svelte:head> </svelte:head>
<div> <div>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-start justify-between gap-3">
<div> <div class="min-w-0 flex-1">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{data.project.name}</h2> {#if editing && canEdit}
{#if data.project.description} <form
<p class="text-sm text-gray-500 dark:text-gray-400">{data.project.description}</p> method="POST"
action="?/updateProject"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editing = false;
}}
class="space-y-2"
>
<input
name="name"
type="text"
required
value={data.project.name}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-lg font-semibold dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<textarea
name="description"
rows="2"
placeholder="Description (optional)"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">{data.project.description ?? ''}</textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Save
</button>
<button
type="button"
onclick={() => (editing = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
{#if form?.action === 'updateProject' && form.error}
<p class="text-sm text-red-600 dark:text-red-400">{form.error}</p>
{/if}
</form>
{:else}
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{data.project.name}</h2>
{#if canEdit}
<button
type="button"
onclick={() => (editing = true)}
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
{/if}
</div>
{#if data.project.description}
<p class="text-sm text-gray-500 dark:text-gray-400">{data.project.description}</p>
{/if}
{/if} {/if}
</div> </div>
{#if canAddExpense} {#if canAddExpense && !editing}
<a <a
href="/companies/{data.company.id}/projects/{data.project.id}/expenses/new" href="/companies/{data.company.id}/projects/{data.project.id}/expenses/new"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
@@ -64,8 +122,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.expenses as expense} {#each data.expenses as expense (expense.id)}
<tr class="border-t border-gray-100 dark:border-gray-700"> <tr class="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/40"
onclick={() => (window.location.href = `/companies/${data.company.id}/expenses/${expense.id}`)}>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">{expense.title}</td> <td class="px-4 py-3 font-medium text-gray-900 dark:text-white">{expense.title}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{expense.categoryName ?? '—'}</td> <td class="px-4 py-3 text-gray-500 dark:text-gray-400">{expense.categoryName ?? '—'}</td>
<td class="px-4 py-3 dark:text-white">{formatCurrency(expense.amount, expense.currency)}</td> <td class="px-4 py-3 dark:text-white">{formatCurrency(expense.amount, expense.currency)}</td>
@@ -1,8 +1,15 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { expenses, categories, tags, expenseTags, projects } from '$lib/server/db/schema.js'; import {
import { eq, and } from 'drizzle-orm'; expenses,
categories,
tags,
expenseTags,
projects,
companyAccounts
} from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
@@ -22,6 +29,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(tags.companyId, params.companyId)) .where(eq(tags.companyId, params.companyId))
.orderBy(tags.name); .orderBy(tags.name);
const accountList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
// Get project info for the currency // Get project info for the currency
const [project] = await db const [project] = await db
.select({ name: projects.name }) .select({ name: projects.name })
@@ -29,7 +53,12 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(projects.id, params.projectId)) .where(eq(projects.id, params.projectId))
.limit(1); .limit(1);
return { categories: categoryList, tags: tagList, projectName: project?.name }; return {
categories: categoryList,
tags: tagList,
accounts: accountList,
projectName: project?.name
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -42,6 +71,7 @@ export const actions: Actions = {
const amount = formData.get('amount')?.toString().trim(); const amount = formData.get('amount')?.toString().trim();
const expenseDate = formData.get('expenseDate')?.toString(); const expenseDate = formData.get('expenseDate')?.toString();
const categoryId = formData.get('categoryId')?.toString() || null; const categoryId = formData.get('categoryId')?.toString() || null;
const accountId = formData.get('accountId')?.toString() || null;
const tagIds = formData.getAll('tagIds').map((t) => t.toString()); const tagIds = formData.getAll('tagIds').map((t) => t.toString());
if (!title || !amount || !expenseDate) { if (!title || !amount || !expenseDate) {
@@ -69,6 +99,7 @@ export const actions: Actions = {
.values({ .values({
projectId: params.projectId, projectId: params.projectId,
categoryId: categoryId || null, categoryId: categoryId || null,
accountId: accountId || null,
submittedBy: user.id, submittedBy: user.id,
title, title,
description, description,
@@ -83,6 +83,25 @@
</select> </select>
</div> </div>
{#if data.accounts.length > 0}
<div class="mb-4">
<label for="accountId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Funding Account
<span class="ml-1 text-xs text-gray-400">(posts on approval)</span>
</label>
<select
id="accountId"
name="accountId"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">None — assign later</option>
{#each data.accounts as acct}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</div>
{/if}
{#if data.tags.length > 0} {#if data.tags.length > 0}
<div class="mb-4"> <div class="mb-4">
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span>
@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { expenses, projects, categories } from '$lib/server/db/schema.js'; import { expenses, projects, categories, companyAccounts } from '$lib/server/db/schema.js';
import { eq, and, sql, gte, lte } from 'drizzle-orm'; import { eq, and, sql, gte, lte } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent, params, url }) => { export const load: PageServerLoad = async ({ parent, params, url }) => {
@@ -9,16 +9,20 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
const from = url.searchParams.get('from') || new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]; const from = url.searchParams.get('from') || new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0];
const to = url.searchParams.get('to') || new Date().toISOString().split('T')[0]; const to = url.searchParams.get('to') || new Date().toISOString().split('T')[0];
// All amounts converted to company base currency via account FX rate.
const convertedAmount = sql<string>`${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1)`;
// Spending by category // Spending by category
const byCategory = await db const byCategory = await db
.select({ .select({
categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`, categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`,
categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`, categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`,
total: sql<string>`sum(${expenses.amount})` total: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id)) .leftJoin(categories, eq(expenses.categoryId, categories.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),
@@ -34,10 +38,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
.select({ .select({
projectName: projects.name, projectName: projects.name,
allocated: projects.allocatedBudget, allocated: projects.allocatedBudget,
spent: sql<string>`sum(${expenses.amount})` spent: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),
@@ -52,10 +57,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
const byMonth = await db const byMonth = await db
.select({ .select({
month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`, month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`,
total: sql<string>`sum(${expenses.amount})` total: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),
@@ -0,0 +1,110 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { sales, saleLineItems, parties, projects, users } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const status = url.searchParams.get('status') ?? 'all';
const whereClauses = [eq(sales.companyId, params.companyId), isNull(sales.deletedAt)];
if (status !== 'all' && ['draft', 'confirmed', 'voided'].includes(status)) {
whereClauses.push(eq(sales.status, status as 'draft' | 'confirmed' | 'voided'));
}
const salesList = await db
.select({
id: sales.id,
title: sales.title,
saleDate: sales.saleDate,
status: sales.status,
currency: sales.currency,
projectId: sales.projectId,
projectName: projects.name,
partyId: sales.partyId,
partyName: parties.name,
withholdingTaxRate: sales.withholdingTaxRate,
createdByName: users.displayName,
createdAt: sales.createdAt,
grossTotal: sql<string>`coalesce((
select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate}))::text
from sale_line_items
where sale_id = ${sales.id}
), '0')`
})
.from(sales)
.leftJoin(projects, eq(sales.projectId, projects.id))
.leftJoin(parties, eq(sales.partyId, parties.id))
.leftJoin(users, eq(sales.createdBy, users.id))
.where(and(...whereClauses))
.orderBy(desc(sales.saleDate));
const partyList = await db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
.orderBy(asc(parties.name));
const projectList = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
.orderBy(asc(projects.name));
return {
sales: salesList,
statusFilter: status,
parties: partyList,
projects: projectList
};
};
export const actions: Actions = {
createSale: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
const saleDate = trimOrNull(fd.get('saleDate'));
const projectId = trimOrNull(fd.get('projectId'));
const partyId = trimOrNull(fd.get('partyId'));
const withholdingRaw = fd.get('withholdingTaxRate')?.toString().trim();
const withholdingTaxRate = withholdingRaw ? Number(withholdingRaw) / 100 : 0;
if (!title) return fail(400, { action: 'createSale', error: 'Title is required' });
if (!saleDate) return fail(400, { action: 'createSale', error: 'Sale date is required' });
if (withholdingTaxRate < 0 || withholdingTaxRate > 1) {
return fail(400, { action: 'createSale', error: 'Withholding rate must be 0100%' });
}
const [inserted] = await db
.insert(sales)
.values({
companyId: params.companyId,
title,
saleDate,
projectId,
partyId,
withholdingTaxRate: withholdingTaxRate.toFixed(4),
createdBy: user.id,
status: 'draft'
})
.returning({ id: sales.id });
await logCompanyEvent(params.companyId, user.id, 'sale_created',
`Sale "${title}" created`, { saleId: inserted.id });
redirect(303, `/companies/${params.companyId}/sales/${inserted.id}`);
}
};
@@ -0,0 +1,142 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showAddForm = $state(false);
const todayIso = new Date().toISOString().slice(0, 10);
const STATUS_BADGE: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
confirmed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300'
};
const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>Sales - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<header class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Sales</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Revenue events. Confirmed sales contribute to project budget.
</p>
</div>
<button type="button" onclick={() => (showAddForm = !showAddForm)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddForm ? 'Cancel' : '+ New Sale'}
</button>
</header>
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
{#if showAddForm}
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<form method="POST" action="?/createSale" use:enhance class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="md:col-span-2">
<label for="sale-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
<input id="sale-title" name="title" type="text" required class={inputCls} placeholder="e.g. 15 widgets to ABC Corp" />
</div>
<div>
<label for="sale-date" class={labelCls}>Sale Date <span class="text-red-500">*</span></label>
<input id="sale-date" name="saleDate" type="date" required value={todayIso} class={inputCls} />
</div>
<div>
<label for="sale-wht" class={labelCls}>Withholding Tax %</label>
<input id="sale-wht" name="withholdingTaxRate" type="number" step="0.01" min="0" max="100" value="0" class={inputCls} />
</div>
<div>
<label for="sale-project" class={labelCls}>Project</label>
<select id="sale-project" name="projectId" class={inputCls}>
<option value=""></option>
{#each data.projects as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="sale-party" class={labelCls}>Customer</label>
<select id="sale-party" name="partyId" class={inputCls}>
<option value=""></option>
{#each data.parties as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Create Draft
</button>
</div>
</form>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
After creating the sale, add line items on the detail page.
</p>
</section>
{/if}
<!-- Status filter -->
<div class="flex gap-2">
{#each ['all', 'draft', 'confirmed', 'voided'] as s (s)}
<a href="?status={s}"
class="rounded-full px-3 py-1 text-xs font-medium {data.statusFilter === s
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'}">
{s.charAt(0).toUpperCase() + s.slice(1)}
</a>
{/each}
</div>
{#if data.sales.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-sm text-gray-500 dark:text-gray-400">No sales yet.</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Title</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Date</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Customer</th>
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Gross</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.sales as sale (sale.id)}
<tr class="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40"
onclick={() => (window.location.href = `/companies/${data.company.id}/sales/${sale.id}`)}>
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white">{sale.title}</div>
{#if sale.projectName}
<div class="text-xs text-gray-500 dark:text-gray-400">Project: {sale.projectName}</div>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{sale.saleDate}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{sale.partyName ?? '—'}</td>
<td class="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">
{formatCurrency(sale.grossTotal, sale.currency)}
</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[sale.status]}">
{sale.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,297 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
sales,
saleLineItems,
salePackages,
parties,
projects,
packages,
invoices
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const [sale] = await db
.select()
.from(sales)
.where(
and(
eq(sales.id, params.saleId),
eq(sales.companyId, params.companyId),
isNull(sales.deletedAt)
)
)
.limit(1);
if (!sale) error(404, 'Sale not found');
const lineItems = await db
.select()
.from(saleLineItems)
.where(eq(saleLineItems.saleId, params.saleId))
.orderBy(asc(saleLineItems.sortOrder), asc(saleLineItems.createdAt));
const linkedPkgRows = await db
.select({
packageId: salePackages.packageId,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
status: packages.status,
direction: packages.direction
})
.from(salePackages)
.innerJoin(packages, eq(salePackages.packageId, packages.id))
.where(eq(salePackages.saleId, params.saleId));
const availablePackages = await db
.select({
id: packages.id,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
direction: packages.direction
})
.from(packages)
.where(eq(packages.companyId, params.companyId))
.orderBy(sql`${packages.createdAt} desc`);
const [party] = sale.partyId
? await db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(eq(parties.id, sale.partyId))
.limit(1)
: [null];
const [project] = sale.projectId
? await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.id, sale.projectId))
.limit(1)
: [null];
const partyList = await db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
.orderBy(asc(parties.name));
const projectList = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
.orderBy(asc(projects.name));
const invoiceList = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction
})
.from(invoices)
.where(
and(
eq(invoices.companyId, params.companyId),
eq(invoices.direction, 'outgoing'),
ne(invoices.status, 'voided')
)
)
.orderBy(asc(invoices.invoiceNumber));
return {
sale,
lineItems,
linkedPackages: linkedPkgRows,
availablePackages,
party,
project,
parties: partyList,
projects: projectList,
invoices: invoiceList
};
};
export const actions: Actions = {
updateSale: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
const saleDate = trimOrNull(fd.get('saleDate'));
const projectId = trimOrNull(fd.get('projectId'));
const partyId = trimOrNull(fd.get('partyId'));
const invoiceId = trimOrNull(fd.get('invoiceId'));
const notes = trimOrNull(fd.get('notes'));
const withholdingRaw = fd.get('withholdingTaxRate')?.toString().trim();
const withholdingTaxRate = withholdingRaw ? Number(withholdingRaw) / 100 : 0;
if (!title) return fail(400, { action: 'updateSale', error: 'Title is required' });
if (!saleDate) return fail(400, { action: 'updateSale', error: 'Date is required' });
await db
.update(sales)
.set({
title,
saleDate,
projectId,
partyId,
invoiceId,
notes,
withholdingTaxRate: withholdingTaxRate.toFixed(4),
updatedAt: new Date()
})
.where(
and(eq(sales.id, params.saleId), eq(sales.companyId, params.companyId))
);
return { success: true, action: 'updateSale' };
},
addLineItem: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const productName = trimOrNull(fd.get('productName'));
const description = trimOrNull(fd.get('description'));
const quantity = fd.get('quantity')?.toString().trim();
const unitPrice = fd.get('unitPrice')?.toString().trim();
const taxPct = fd.get('taxRate')?.toString().trim();
if (!productName) return fail(400, { action: 'addLineItem', error: 'Product name required' });
if (!quantity || Number(quantity) <= 0) return fail(400, { action: 'addLineItem', error: 'Valid quantity required' });
if (!unitPrice || Number(unitPrice) < 0) return fail(400, { action: 'addLineItem', error: 'Valid unit price required' });
const taxRate = taxPct ? Number(taxPct) / 100 : 0;
const [maxRow] = await db
.select({ max: sql<number>`coalesce(max(${saleLineItems.sortOrder}), -1)::int` })
.from(saleLineItems)
.where(eq(saleLineItems.saleId, params.saleId));
await db.insert(saleLineItems).values({
saleId: params.saleId,
productName,
description,
quantity: Number(quantity).toFixed(4),
unitPrice: Number(unitPrice).toFixed(2),
taxRate: taxRate.toFixed(4),
sortOrder: (maxRow?.max ?? -1) + 1
});
return { success: true, action: 'addLineItem' };
},
removeLineItem: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const itemId = trimOrNull(fd.get('itemId'));
if (!itemId) return fail(400, { action: 'removeLineItem', error: 'Item id required' });
await db
.delete(saleLineItems)
.where(and(eq(saleLineItems.id, itemId), eq(saleLineItems.saleId, params.saleId)));
return { success: true, action: 'removeLineItem' };
},
confirmSale: async ({ locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const [lineCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(saleLineItems)
.where(eq(saleLineItems.saleId, params.saleId));
if (!lineCount || lineCount.count === 0) {
return fail(400, { action: 'confirmSale', error: 'Add at least one line item before confirming' });
}
const [updated] = await db
.update(sales)
.set({ status: 'confirmed', updatedAt: new Date() })
.where(
and(
eq(sales.id, params.saleId),
eq(sales.companyId, params.companyId),
eq(sales.status, 'draft')
)
)
.returning({ title: sales.title });
if (!updated) return fail(400, { action: 'confirmSale', error: 'Sale not found or not in draft' });
await logCompanyEvent(params.companyId, user.id, 'sale_confirmed',
`Sale "${updated.title}" confirmed`, { saleId: params.saleId });
return { success: true, action: 'confirmSale' };
},
voidSale: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const fd = await request.formData();
const reason = trimOrNull(fd.get('reason'));
if (!reason) return fail(400, { action: 'voidSale', error: 'Void reason is required' });
const [updated] = await db
.update(sales)
.set({
status: 'voided',
voidedAt: new Date(),
voidReason: reason,
updatedAt: new Date()
})
.where(
and(eq(sales.id, params.saleId), eq(sales.companyId, params.companyId))
)
.returning({ title: sales.title });
if (!updated) return fail(404, { action: 'voidSale', error: 'Sale not found' });
await logCompanyEvent(params.companyId, user.id, 'sale_voided',
`Sale "${updated.title}" voided: ${reason}`,
{ saleId: params.saleId, reason });
return { success: true, action: 'voidSale' };
},
linkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const packageId = trimOrNull(fd.get('packageId'));
if (!packageId) return fail(400, { error: 'Package id required' });
await db
.insert(salePackages)
.values({ saleId: params.saleId, packageId })
.onConflictDoNothing();
return { success: true, action: 'linkPackage' };
},
unlinkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const packageId = trimOrNull(fd.get('packageId'));
if (!packageId) return fail(400, { error: 'Package id required' });
await db
.delete(salePackages)
.where(and(eq(salePackages.saleId, params.saleId), eq(salePackages.packageId, packageId)));
return { success: true, action: 'unlinkPackage' };
}
};
@@ -0,0 +1,327 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showAddItem = $state(false);
let editingMeta = $state(false);
let showVoidForm = $state(false);
const STATUS_BADGE: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
confirmed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
voided: 'bg-red-200 text-red-800 dark:bg-red-900/50 dark:text-red-300'
};
const isLive = $derived(data.sale.status === 'draft');
const totals = $derived.by(() => {
let subtotal = 0;
let tax = 0;
for (const li of data.lineItems) {
const lineNet = Number(li.quantity) * Number(li.unitPrice);
subtotal += lineNet;
tax += lineNet * Number(li.taxRate);
}
const gross = subtotal + tax;
const withholding = gross * Number(data.sale.withholdingTaxRate);
const net = gross - withholding;
return { subtotal, tax, gross, withholding, net };
});
const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>{data.sale.title} - Sales</title>
</svelte:head>
<div class="space-y-6">
<header>
<a href={`/companies/${data.company.id}/sales`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Sales</a>
<div class="mt-1 flex items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.sale.title}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.sale.status]}">
{data.sale.status}
</span>
<span>{data.sale.saleDate}</span>
{#if data.party}<span>Customer: {data.party.name}</span>{/if}
{#if data.project}<span>Project: {data.project.name}</span>{/if}
</div>
</div>
<button type="button" onclick={() => (editingMeta = !editingMeta)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200">
{editingMeta ? 'Cancel' : 'Edit'}
</button>
</div>
{#if data.sale.status === 'voided' && data.sale.voidReason}
<div class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-700 dark:bg-red-900/20">
<span class="font-medium text-red-700 dark:text-red-300">Voided:</span>
<span class="text-red-600 dark:text-red-400">{data.sale.voidReason}</span>
</div>
{/if}
</header>
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
{#if editingMeta}
<form method="POST" action="?/updateSale"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingMeta = false;
}}
class="grid grid-cols-1 gap-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
<div class="md:col-span-2">
<label for="s-title" class={labelCls}>Title</label>
<input id="s-title" name="title" type="text" required value={data.sale.title} class={inputCls} />
</div>
<div>
<label for="s-date" class={labelCls}>Date</label>
<input id="s-date" name="saleDate" type="date" required value={data.sale.saleDate} class={inputCls} />
</div>
<div>
<label for="s-wht" class={labelCls}>Withholding %</label>
<input id="s-wht" name="withholdingTaxRate" type="number" step="0.01" min="0" max="100"
value={(Number(data.sale.withholdingTaxRate) * 100).toString()} class={inputCls} />
</div>
<div>
<label for="s-project" class={labelCls}>Project</label>
<select id="s-project" name="projectId" value={data.sale.projectId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.projects as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="s-party" class={labelCls}>Customer</label>
<select id="s-party" name="partyId" value={data.sale.partyId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.parties as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="s-invoice" class={labelCls}>Outgoing Invoice</label>
<select id="s-invoice" name="invoiceId" value={data.sale.invoiceId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.invoices as inv (inv.id)}
<option value={inv.id}>{inv.invoiceNumber}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label for="s-notes" class={labelCls}>Notes</label>
<textarea id="s-notes" name="notes" rows="2" class={inputCls}>{data.sale.notes ?? ''}</textarea>
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
{/if}
<!-- Line items -->
<section class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between border-b border-gray-100 p-4 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-white">Line Items</h2>
{#if isLive}
<button type="button" onclick={() => (showAddItem = !showAddItem)}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showAddItem ? 'Cancel' : '+ Add item'}
</button>
{/if}
</div>
{#if showAddItem && isLive}
<form method="POST" action="?/addLineItem"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') { showAddItem = false; formElement.reset(); }
}}
class="grid grid-cols-1 gap-3 border-b border-gray-100 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-700/30 md:grid-cols-4">
<div class="md:col-span-2">
<label class={labelCls} for="li-product">Product</label>
<input id="li-product" name="productName" type="text" required class={inputCls} />
</div>
<div>
<label class={labelCls} for="li-qty">Quantity</label>
<input id="li-qty" name="quantity" type="number" step="0.0001" min="0.0001" required value="1" class={inputCls} />
</div>
<div>
<label class={labelCls} for="li-price">Unit Price</label>
<input id="li-price" name="unitPrice" type="number" step="0.01" min="0" required class={inputCls} />
</div>
<div>
<label class={labelCls} for="li-tax">Tax %</label>
<input id="li-tax" name="taxRate" type="number" step="0.01" min="0" max="100" value="7" class={inputCls} />
</div>
<div class="md:col-span-3">
<label class={labelCls} for="li-desc">Description</label>
<input id="li-desc" name="description" type="text" class={inputCls} />
</div>
<div class="md:col-span-4 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Add
</button>
</div>
</form>
{/if}
{#if data.lineItems.length === 0}
<p class="p-4 text-sm text-gray-500 dark:text-gray-400">No line items yet.</p>
{:else}
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Product</th>
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Qty</th>
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Unit</th>
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Tax %</th>
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Line Total</th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.lineItems as li (li.id)}
{@const lineNet = Number(li.quantity) * Number(li.unitPrice)}
{@const lineGross = lineNet * (1 + Number(li.taxRate))}
<tr>
<td class="px-4 py-2">
<div class="font-medium text-gray-900 dark:text-white">{li.productName}</div>
{#if li.description}<div class="text-xs text-gray-500">{li.description}</div>{/if}
</td>
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{Number(li.quantity)}</td>
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{formatCurrency(li.unitPrice, data.sale.currency)}</td>
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{(Number(li.taxRate) * 100).toFixed(1)}%</td>
<td class="px-4 py-2 text-right font-medium text-gray-900 dark:text-white">{formatCurrency(lineGross.toFixed(2), data.sale.currency)}</td>
<td class="px-4 py-2 text-right">
{#if isLive}
<form method="POST" action="?/removeLineItem" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="itemId" value={li.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-700 dark:text-red-400">Remove</button>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
<tfoot class="bg-gray-50 dark:bg-gray-700/50">
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Subtotal</td>
<td class="px-4 py-2 text-right text-gray-900 dark:text-white">{formatCurrency(totals.subtotal.toFixed(2), data.sale.currency)}</td>
<td></td>
</tr>
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Tax</td>
<td class="px-4 py-2 text-right text-gray-900 dark:text-white">{formatCurrency(totals.tax.toFixed(2), data.sale.currency)}</td>
<td></td>
</tr>
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Gross</td>
<td class="px-4 py-2 text-right font-semibold text-gray-900 dark:text-white">{formatCurrency(totals.gross.toFixed(2), data.sale.currency)}</td>
<td></td>
</tr>
{#if Number(data.sale.withholdingTaxRate) > 0}
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-medium text-red-600 dark:text-red-400">
Withholding ({(Number(data.sale.withholdingTaxRate) * 100).toFixed(2)}%)
</td>
<td class="px-4 py-2 text-right text-red-600 dark:text-red-400">
-{formatCurrency(totals.withholding.toFixed(2), data.sale.currency)}
</td>
<td></td>
</tr>
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-bold text-gray-900 dark:text-white">Net Receivable</td>
<td class="px-4 py-2 text-right font-bold text-green-600 dark:text-green-400">
{formatCurrency(totals.net.toFixed(2), data.sale.currency)}
</td>
<td></td>
</tr>
{/if}
</tfoot>
</table>
{/if}
</section>
<!-- Packages -->
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
{#if data.linkedPackages.length > 0}
<div class="mb-3 flex flex-wrap gap-2">
{#each data.linkedPackages as pkg (pkg.packageId)}
<span class="inline-flex items-center gap-2 rounded-full bg-cyan-100 px-3 py-1 text-xs font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
📦 {pkg.trackingNumber} ({pkg.carrier})
{#if isLive}
<form method="POST" action="?/unlinkPackage" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="packageId" value={pkg.packageId} />
<button type="submit" class="text-cyan-800 hover:text-red-600 dark:text-cyan-200">×</button>
</form>
{/if}
</span>
{/each}
</div>
{/if}
{#if isLive && data.availablePackages.length > 0}
<form method="POST" action="?/linkPackage" use:enhance class="flex items-center gap-2 text-sm">
<select name="packageId" required class={inputCls + ' flex-1'}>
<option value="" disabled selected>Select package to link</option>
{#each data.availablePackages as pkg (pkg.id)}
{#if !data.linkedPackages.find((l) => l.packageId === pkg.id)}
<option value={pkg.id}>{pkg.trackingNumber} {pkg.carrier} ({pkg.direction})</option>
{/if}
{/each}
</select>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Link
</button>
</form>
{/if}
</section>
<!-- Actions -->
{#if isLive}
<div class="flex gap-3">
<form method="POST" action="?/confirmSale" use:enhance>
<button type="submit" class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
Confirm Sale
</button>
</form>
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Void Sale
</button>
</div>
{/if}
{#if data.sale.status === 'confirmed'}
<div class="flex">
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Void Sale
</button>
</div>
{/if}
{#if showVoidForm}
<form method="POST" action="?/voidSale"
use:enhance={() => async ({ update }) => { await update({ reset: false }); showVoidForm = false; }}
class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
<label for="void-reason" class={labelCls}>Void Reason <span class="text-red-500">*</span></label>
<textarea id="void-reason" name="reason" rows="2" required class={inputCls}></textarea>
<div class="mt-2 flex justify-end gap-2">
<button type="button" onclick={() => (showVoidForm = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Confirm Void</button>
</div>
</form>
{/if}
</div>
@@ -0,0 +1,245 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companyServiceAccounts } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, eq, isNull } from 'drizzle-orm';
const SERVICE_TYPES = [
'electricity',
'water',
'gas',
'internet',
'phone',
'shipping',
'insurance',
'tax_registration',
'social_security',
'customs',
'other'
] as const;
type ServiceType = (typeof SERVICE_TYPES)[number];
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseType(v: FormDataEntryValue | null): ServiceType | null {
const s = v?.toString();
if (!s) return null;
return (SERVICE_TYPES as readonly string[]).includes(s) ? (s as ServiceType) : null;
}
function validateUrl(v: string | null): string | null {
if (!v) return null;
if (!v.startsWith('http://') && !v.startsWith('https://')) return null;
return v;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const accounts = await db
.select()
.from(companyServiceAccounts)
.where(
and(
eq(companyServiceAccounts.companyId, params.companyId),
isNull(companyServiceAccounts.deletedAt)
)
)
.orderBy(asc(companyServiceAccounts.type), asc(companyServiceAccounts.providerName));
return { serviceAccounts: accounts };
};
export const actions: Actions = {
create: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const type = parseType(fd.get('type'));
const providerName = trimOrNull(fd.get('providerName'));
const accountNumber = trimOrNull(fd.get('accountNumber'));
const customLabel = trimOrNull(fd.get('customLabel'));
const contactPhone = trimOrNull(fd.get('contactPhone'));
const websiteUrlRaw = trimOrNull(fd.get('websiteUrl'));
const notes = trimOrNull(fd.get('notes'));
if (!type) return fail(400, { action: 'create', error: 'Service type is required' });
if (!providerName) return fail(400, { action: 'create', error: 'Provider name is required' });
if (providerName.length > 200)
return fail(400, { action: 'create', error: 'Provider name too long (max 200)' });
if (!accountNumber)
return fail(400, { action: 'create', error: 'Account number is required' });
if (accountNumber.length > 200)
return fail(400, { action: 'create', error: 'Account number too long (max 200)' });
if (websiteUrlRaw && !validateUrl(websiteUrlRaw))
return fail(400, { action: 'create', error: 'Website URL must start with http:// or https://' });
const [inserted] = await db
.insert(companyServiceAccounts)
.values({
companyId: params.companyId,
type,
providerName,
accountNumber,
customLabel,
contactPhone,
websiteUrl: websiteUrlRaw,
notes,
createdBy: user.id
})
.returning({ id: companyServiceAccounts.id });
await logCompanyEvent(
params.companyId,
user.id,
'service_account_created',
`Service account "${providerName}" (${type}) created`,
{ accountId: inserted.id, type, providerName }
);
return { success: true, action: 'create' };
},
update: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'update', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyServiceAccounts.id })
.from(companyServiceAccounts)
.where(
and(
eq(companyServiceAccounts.id, id),
eq(companyServiceAccounts.companyId, params.companyId),
isNull(companyServiceAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Service account not found');
const type = parseType(fd.get('type'));
const providerName = trimOrNull(fd.get('providerName'));
const accountNumber = trimOrNull(fd.get('accountNumber'));
const customLabel = trimOrNull(fd.get('customLabel'));
const contactPhone = trimOrNull(fd.get('contactPhone'));
const websiteUrlRaw = trimOrNull(fd.get('websiteUrl'));
const notes = trimOrNull(fd.get('notes'));
if (!type) return fail(400, { action: 'update', error: 'Service type is required' });
if (!providerName) return fail(400, { action: 'update', error: 'Provider name is required' });
if (providerName.length > 200)
return fail(400, { action: 'update', error: 'Provider name too long (max 200)' });
if (!accountNumber)
return fail(400, { action: 'update', error: 'Account number is required' });
if (accountNumber.length > 200)
return fail(400, { action: 'update', error: 'Account number too long (max 200)' });
if (websiteUrlRaw && !validateUrl(websiteUrlRaw))
return fail(400, { action: 'update', error: 'Website URL must start with http:// or https://' });
await db
.update(companyServiceAccounts)
.set({
type,
providerName,
accountNumber,
customLabel,
contactPhone,
websiteUrl: websiteUrlRaw,
notes,
updatedAt: new Date()
})
.where(eq(companyServiceAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'service_account_updated',
`Service account "${providerName}" updated`,
{ accountId: id }
);
return { success: true, action: 'update' };
},
delete: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'delete', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyServiceAccounts.id, providerName: companyServiceAccounts.providerName })
.from(companyServiceAccounts)
.where(
and(
eq(companyServiceAccounts.id, id),
eq(companyServiceAccounts.companyId, params.companyId),
isNull(companyServiceAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Service account not found');
await db
.update(companyServiceAccounts)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(companyServiceAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'service_account_deleted',
`Service account "${existing.providerName}" deleted`,
{ accountId: id }
);
return { success: true, action: 'delete' };
},
toggleActive: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'toggleActive', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyServiceAccounts.id, isActive: companyServiceAccounts.isActive })
.from(companyServiceAccounts)
.where(
and(
eq(companyServiceAccounts.id, id),
eq(companyServiceAccounts.companyId, params.companyId),
isNull(companyServiceAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Service account not found');
await db
.update(companyServiceAccounts)
.set({ isActive: !existing.isActive, updatedAt: new Date() })
.where(eq(companyServiceAccounts.id, id));
return { success: true, action: 'toggleActive' };
}
};
@@ -0,0 +1,383 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let typeFilter = $state<string>('all');
let showAddForm = $state(false);
let editingId = $state<string | null>(null);
let deletingId = $state<string | null>(null);
const TYPE_LABELS: Record<string, string> = {
electricity: 'Electricity',
water: 'Water',
gas: 'Gas',
internet: 'Internet',
phone: 'Phone',
shipping: 'Shipping',
insurance: 'Insurance',
tax_registration: 'Tax Registration',
social_security: 'Social Security',
customs: 'Customs',
other: 'Other'
};
const TYPE_BADGE: Record<string, string> = {
electricity: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
water: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
gas: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
internet: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
phone: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
shipping: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
insurance: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
tax_registration: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
social_security: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
customs: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
};
const ALL_TYPES = Object.keys(TYPE_LABELS);
const filteredAccounts = $derived(
typeFilter === 'all'
? data.serviceAccounts
: data.serviceAccounts.filter((a) => a.type === typeFilter)
);
const typesPresent = $derived([...new Set(data.serviceAccounts.map((a) => a.type))]);
function openAdd() {
showAddForm = !showAddForm;
editingId = null;
deletingId = null;
}
function startEdit(id: string) {
editingId = id;
showAddForm = false;
deletingId = null;
}
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>Service Accounts - {data.company.name}</title>
</svelte:head>
{#snippet accountForm(
action: string,
values: {
type?: string;
providerName?: string;
accountNumber?: string;
customLabel?: string;
contactPhone?: string;
websiteUrl?: string;
notes?: string;
} = {},
accountId?: string
)}
<form
method="POST"
{action}
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
if (!accountId) {
showAddForm = false;
formElement.reset();
} else {
editingId = null;
}
}
}}
class="mt-3 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-4 dark:bg-gray-700/50 md:grid-cols-2"
>
{#if accountId}
<input type="hidden" name="id" value={accountId} />
{/if}
<div>
<label class={labelCls}>Type <span class="text-red-500">*</span></label>
<select name="type" required value={values.type ?? ''} class={inputCls}>
<option value="" disabled>Select type</option>
{#each ALL_TYPES as t (t)}
<option value={t}>{TYPE_LABELS[t]}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls}>Provider Name <span class="text-red-500">*</span></label>
<input
name="providerName"
type="text"
required
value={values.providerName ?? ''}
placeholder="e.g. PEA, TRUE, UPS"
class={inputCls}
/>
</div>
<div>
<label class={labelCls}>Account Number <span class="text-red-500">*</span></label>
<input
name="accountNumber"
type="text"
required
value={values.accountNumber ?? ''}
placeholder="e.g. 12-3456-7890"
class={inputCls}
/>
</div>
<div>
<label class={labelCls}>Custom Label</label>
<input
name="customLabel"
type="text"
value={values.customLabel ?? ''}
placeholder="e.g. Main office"
class={inputCls}
/>
</div>
<div>
<label class={labelCls}>Contact Phone</label>
<input
name="contactPhone"
type="tel"
value={values.contactPhone ?? ''}
class={inputCls}
/>
</div>
<div>
<label class={labelCls}>Website</label>
<input
name="websiteUrl"
type="url"
value={values.websiteUrl ?? ''}
placeholder="https://..."
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label class={labelCls}>Notes</label>
<textarea name="notes" rows="2" class={inputCls}>{values.notes ?? ''}</textarea>
</div>
<div class="md:col-span-2 flex justify-end gap-2">
<button
type="button"
onclick={() => {
if (accountId) editingId = null;
else showAddForm = false;
}}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{accountId ? 'Save' : 'Add Account'}
</button>
</div>
</form>
{/snippet}
<div class="space-y-6">
<header>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Service Accounts</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Utility accounts, shipping carrier numbers, insurance policies, and government registrations.
</p>
</header>
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Add Service Account</h2>
<button
type="button"
onclick={openAdd}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddForm ? 'Cancel' : '+ New Account'}
</button>
</div>
{#if showAddForm}
{@render accountForm('?/create')}
{/if}
</section>
{#if data.serviceAccounts.length > 0}
<div class="flex flex-wrap gap-2">
<button
type="button"
onclick={() => (typeFilter = 'all')}
class="rounded-full px-3 py-1 text-xs font-medium {typeFilter === 'all'
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
All
</button>
{#each typesPresent as t (t)}
<button
type="button"
onclick={() => (typeFilter = t)}
class="rounded-full px-3 py-1 text-xs font-medium {typeFilter === t
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{TYPE_LABELS[t] ?? t}
</button>
{/each}
</div>
{/if}
{#if filteredAccounts.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
No {typeFilter === 'all' ? '' : TYPE_LABELS[typeFilter]?.toLowerCase() + ' '}accounts yet.
</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Type</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Provider</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Account #</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Contact</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each filteredAccounts as acct (acct.id)}
<tr class="align-top {acct.isActive ? '' : 'opacity-60'}">
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {TYPE_BADGE[acct.type] ?? TYPE_BADGE.other}">
{TYPE_LABELS[acct.type] ?? acct.type}
</span>
</td>
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white">{acct.providerName}</div>
{#if acct.customLabel}
<div class="text-xs text-gray-500 dark:text-gray-400">{acct.customLabel}</div>
{/if}
</td>
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">{acct.accountNumber}</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{#if acct.contactPhone}
<div>{acct.contactPhone}</div>
{/if}
{#if acct.websiteUrl}
<a
href={acct.websiteUrl}
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Website
</a>
{/if}
</td>
<td class="px-4 py-3">
<form method="POST" action="?/toggleActive" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="id" value={acct.id} />
<button
type="submit"
class="rounded-full px-2 py-0.5 text-xs font-medium {acct.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}"
>
{acct.isActive ? 'Active' : 'Inactive'}
</button>
</form>
</td>
<td class="px-4 py-3 text-right text-xs">
<div class="flex flex-wrap justify-end gap-2">
<button
type="button"
onclick={() => startEdit(acct.id)}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
type="button"
onclick={() => (deletingId = deletingId === acct.id ? null : acct.id)}
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
</td>
</tr>
{#if deletingId === acct.id}
<tr>
<td colspan="6" class="bg-red-50 px-4 py-3 dark:bg-red-900/20">
<form
method="POST"
action="?/delete"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
deletingId = null;
}}
class="flex items-center justify-between gap-3 text-xs"
>
<input type="hidden" name="id" value={acct.id} />
<p class="text-red-700 dark:text-red-300">Delete "{acct.providerName}"?</p>
<div class="flex gap-2">
<button
type="button"
onclick={() => (deletingId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
</td>
</tr>
{/if}
{#if editingId === acct.id}
<tr>
<td colspan="6" class="bg-gray-50 px-4 py-3 dark:bg-gray-700/30">
{@render accountForm(
'?/update',
{
type: acct.type,
providerName: acct.providerName,
accountNumber: acct.accountNumber,
customLabel: acct.customLabel ?? '',
contactPhone: acct.contactPhone ?? '',
websiteUrl: acct.websiteUrl ?? '',
notes: acct.notes ?? ''
},
acct.id
)}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>
+4 -1
View File
@@ -7,7 +7,8 @@ import {
isOIDCEnabled isOIDCEnabled
} from '$lib/server/auth/oidc.js'; } from '$lib/server/auth/oidc.js';
export const GET: RequestHandler = async ({ cookies }) => { export const GET: RequestHandler = async ({ cookies, url: reqUrl }) => {
const isSecure = reqUrl.protocol === 'https:';
if (!isOIDCEnabled()) { if (!isOIDCEnabled()) {
redirect(302, '/login'); redirect(302, '/login');
} }
@@ -17,6 +18,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
cookies.set('oidc_state', state, { cookies.set('oidc_state', state, {
httpOnly: true, httpOnly: true,
secure: isSecure,
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
maxAge: 600 // 10 minutes maxAge: 600 // 10 minutes
@@ -24,6 +26,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
cookies.set('oidc_code_verifier', codeVerifier, { cookies.set('oidc_code_verifier', codeVerifier, {
httpOnly: true, httpOnly: true,
secure: isSecure,
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
maxAge: 600 maxAge: 600
+3 -3
View File
@@ -45,8 +45,8 @@ export const GET: RequestHandler = async (event) => {
.then((r) => r[0] ?? null); .then((r) => r[0] ?? null);
if (!user) { if (!user) {
// Check if a user with this email exists (link accounts) // Check if a user with this email exists — only auto-link if provider verified the email
if (userInfo.email) { if (userInfo.email && userInfo.email_verified) {
user = await db user = await db
.select() .select()
.from(users) .from(users)
@@ -55,7 +55,7 @@ export const GET: RequestHandler = async (event) => {
.then((r) => r[0] ?? null); .then((r) => r[0] ?? null);
if (user) { if (user) {
// Link OIDC identity to existing user // Link OIDC identity to existing user (email verified by provider)
await db await db
.update(users) .update(users)
.set({ .set({
+28
View File
@@ -1,7 +1,35 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { onMount } from 'svelte';
let { children } = $props(); let { children } = $props();
// Constrain date inputs so browsers don't render yyyyyy-mm-dd.
// Uses a MutationObserver so dynamically-rendered inputs get constrained too.
function constrainEl(el: Element) {
if (!(el instanceof HTMLInputElement)) return;
if (el.type !== 'date') return;
if (!el.hasAttribute('min')) el.setAttribute('min', '1900-01-01');
if (!el.hasAttribute('max')) el.setAttribute('max', '2100-12-31');
}
function constrainRoot(root: ParentNode) {
root.querySelectorAll<HTMLInputElement>('input[type="date"]').forEach(constrainEl);
}
onMount(() => {
constrainRoot(document);
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
constrainEl(node as Element);
if ((node as Element).querySelectorAll) constrainRoot(node as Element);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
});
</script> </script>
{@render children()} {@render children()}