Convert Beenvoice to a Turborepo monorepo

This commit is contained in:
2026-08-16 22:13:42 -04:00
parent d057ba208d
commit 6c74436092
54 changed files with 4140 additions and 4147 deletions
-19
View File
@@ -1,19 +0,0 @@
node_modules
.next
.git
.gitignore
Dockerfile*
docker-compose*
README.md
docs
AGENTS.md
*.log
.DS_Store
.env*
!.env.example
.vscode
.idea
coverage
*.tsbuildinfo
dist
build
+1 -1
View File
@@ -1,5 +1,5 @@
# =============================================================================
# beenvoice-web — environment template
# Beenvoice web workspace — environment template
# =============================================================================
#
# Quick start (local dev):
+2 -4
View File
@@ -1,11 +1,9 @@
# beenvoice - AI Assistant Rules
> **Canonical architecture reference:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (stack, routers, schema, auth). This file may lag behind; prefer ARCHITECTURE.md for facts.
> **Canonical architecture reference:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (stack, routers, schema, auth). Root workspace rules are in [../../AGENTS.md](../../AGENTS.md).
## Project Overview
beenvoice-web is the web app and API for beenvoice — Next.js 16, tRPC 11, Drizzle/PostgreSQL, better-auth, and shadcn/ui. Reliability, security, and professional UX are paramount.
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
`apps/web` is the web app and API for Beenvoice — Next.js 16, tRPC 11, Drizzle/PostgreSQL, Better Auth, and shadcn/ui. Reliability, security, and professional UX are paramount.
## Core Development Principles
-49
View File
@@ -1,49 +0,0 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS base
WORKDIR /usr/src/app
FROM base AS install
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Next.js build must run on Node — Bun 1.3.x can SIGSEGV on Linux arm64 during
# the "Collecting page data" worker phase (oven-sh/bun#...). Runtime stays on Bun.
FROM node:22-bookworm-slim AS build
WORKDIR /usr/src/app
COPY --from=install /usr/src/app/node_modules node_modules
COPY . .
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
ARG BETTER_AUTH_URL=http://localhost:3000
# Low-memory Docker build profile:
# - skip tsc inside `next build` (run `bun run check` in CI instead)
ENV DOCKER_BUILD=1 \
NODE_ENV=production \
SKIP_ENV_VALIDATION=1 \
NEXT_TELEMETRY_DISABLED=1 \
BETTER_AUTH_URL=${BETTER_AUTH_URL} \
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
RUN node ./node_modules/next/dist/bin/next build
FROM base AS release
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1
COPY --from=build /usr/src/app/.next ./.next
COPY --from=build /usr/src/app/public ./public
COPY --from=install /usr/src/app/node_modules node_modules
COPY --from=build /usr/src/app/package.json ./package.json
COPY --from=build /usr/src/app/drizzle.config.ts ./drizzle.config.ts
COPY --from=build /usr/src/app/drizzle ./drizzle
COPY --from=build /usr/src/app/src/server/db/migrate.ts ./migrate.ts
RUN chmod -R a+rX drizzle public migrate.ts
USER bun
EXPOSE 3000
CMD ["sh", "-c", "bun migrate.ts && bun run start"]
+19 -23
View File
@@ -1,12 +1,11 @@
![beenvoice Logo](public/beenvoice-logo.png)
# beenvoice-web
# Beenvoice web
Web application and API for **beenvoice** — invoicing for freelancers and small businesses. Includes the Next.js dashboard, tRPC API, better-auth, PostgreSQL persistence, PDF/email delivery, time tracking, and an MCP automation endpoint.
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
**Architecture:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
**Mobile companion:** [beenvoice-app](https://git.soconnor.dev/soconnor/beenvoice-app) (separate repo; often checked out beside this one in a workspace)
**Mobile companion:** [apps/mobile](../mobile/README.md)
## Stack
@@ -39,12 +38,12 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
## Local development
### 1. Clone and install
### 1. Install the workspace
```bash
git clone https://git.soconnor.dev/soconnor/beenvoice-web.git
cd beenvoice-web
cd beenvoice
bun install
cd apps/web
```
### 2. Environment
@@ -70,7 +69,7 @@ Email and SSO are optional for local work — leave `RESEND_*` and `AUTHENTIK_*`
Start Postgres (dev compose exposes port 5432):
```bash
docker compose -f docker-compose.dev.yml up -d
bun run docker:up
```
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
@@ -104,9 +103,9 @@ Open [http://localhost:3000](http://localhost:3000), register at `/auth/register
The production compose file runs the Next.js app and PostgreSQL.
**Container startup** runs `bun migrate.ts && bun run start` (see `Dockerfile`). Drizzle only applies **pending** migrations — safe to run on every restart; already-applied migrations are skipped.
**Container startup** runs the web migration script followed by `bun run start` (see the root [`Dockerfile`](../../Dockerfile)). Drizzle only applies **pending** migrations — safe to run on every restart; already-applied migrations are skipped.
The Docker **build** runs `next build` on **Node 22** (Bun can crash on Linux arm64 during the page-data worker phase). The **runtime** image still uses Bun for migrations and `next start`. `docker-compose.yml` does not set container memory or CPU limits — containers can use whatever the Docker host provides.
The Docker **build** runs `next build` on **Node 22** (Bun can crash on Linux arm64 during the page-data worker phase). The **runtime** image still uses Bun for migrations and `next start`. The root `docker-compose.yml` does not set container memory or CPU limits.
### 1. Configure
@@ -127,7 +126,7 @@ NEXT_PUBLIC_APP_URL=https://your-public-hostname
`NEXT_PUBLIC_*` values are embedded at **image build** time. Rebuild after changing `NEXT_PUBLIC_APP_URL`, white-label defaults, or `NEXT_PUBLIC_AUTHENTIK_ENABLED`:
```bash
docker compose build --no-cache app
docker compose -f ../../docker-compose.yml build --no-cache app
```
`BETTER_AUTH_URL` and `AUTH_SECRET` are read at **container runtime** from `.env` — you can change them without rebuilding, then restart the app container.
@@ -135,9 +134,9 @@ docker compose build --no-cache app
### 2. First start (or after code changes)
```bash
./scripts/docker-deploy.sh
../../scripts/docker-deploy.sh
# or: bun run docker:deploy
# or: docker compose up -d --build
# or, from the repository root: docker compose up -d --build
```
`--build` is required after code changes. A plain `docker compose up -d` reuses the existing `beenvoice:local` image and **does not** pick up new code from `git pull`. The deploy script tags the image with the current git SHA (`beenvoice:<sha>`) so each deploy gets a distinct image.
@@ -160,7 +159,7 @@ when something calls `POST /api/cron/generate-recurring` with
```bash
git pull
./scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
../../scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
# or: docker compose up -d --build
```
@@ -168,7 +167,7 @@ git pull
|---------|-----------|-----------------|
| `git pull` only | No | No |
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
| `./scripts/docker-deploy.sh` or `docker compose up -d --build` | Yes | Yes — on app container start |
| `../../scripts/docker-deploy.sh` or root `docker compose up -d --build` | Yes | Yes — on app container start |
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags).
@@ -177,7 +176,7 @@ To verify migration files match the journal before deploy: `bun run db:verify-jo
### Coolify
For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource.
For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy the root [`docker-compose.coolify.yml`](../../docker-compose.coolify.yml) as a single Compose resource.
### 4. Sign-ups
@@ -201,21 +200,18 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on
## Project structure
```
beenvoice-web/
apps/web/
├── src/app/ # Routes (dashboard, auth, /api/*)
├── src/server/api/ # tRPC routers
├── src/server/db/ # Drizzle schema, pool, migrate.ts
├── src/components/ # UI (ui/, forms/, layout/, branding/)
├── src/lib/ # auth, PDF, email, branding helpers
├── drizzle/ # SQL migrations
├── Dockerfile # Production image (migrate + next start)
├── docker-compose.yml # App + Postgres + Garage (deploy)
├── docker-compose.coolify.yml # Coolify Compose (app + db + garage)
├── docker-compose.coolify-garage.yml # Garage-only for Coolify Application pairing
├── docker-compose.dev.yml # Postgres only (local dev)
└── docs/ # Architecture and UI guides
```
Workspace configuration, Dockerfiles, Compose files, and the shared `packages/domain` package live at the repository root.
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for routers, schema, auth flows, and MCP.
## Scripts
@@ -245,7 +241,7 @@ bun run docker:down # stop dev Postgres + colima
bun run docker:deploy # production: rebuild app image + docker-compose.yml up -d
```
Full-stack deploy uses `bun run docker:deploy` or `./scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
Full-stack deploy uses `bun run docker:deploy` or `../../scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
## API surface
@@ -277,4 +273,4 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
## License
MIT — see [LICENSE](LICENSE).
MIT — see the root [LICENSE](../../LICENSE).
-1938
View File
File diff suppressed because it is too large Load Diff
@@ -1,74 +0,0 @@
# Garage-only stack for Coolify when beenvoice runs as a separate Application resource.
#
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-garage.yml
#
# ── Pair with a beenvoice Application (pick ONE) ───────────────────────────────
#
# A) Public Garage URL (most reliable — no shared Docker network required)
# 1. Redeploy this stack (includes SERVICE_FQDN_GARAGE_3900 below).
# 2. Garage resource → assign a domain for port 3900 (e.g. s3.example.com).
# 3. Copy SERVICE_URL_GARAGE_3900 from this resource's Environment tab.
# 4. beenvoice Application → S3_ENDPOINT=<that URL> → redeploy beenvoice.
#
# B) Internal Docker DNS (same Coolify destination network)
# 1. Garage resource → Advanced → enable "Connect to Predefined Network" → redeploy.
# 2. beenvoice Application → same destination → enable "Connect to Predefined Network".
# 3. beenvoice → S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
#
# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+garage).
# See docs/COOLIFY.md.
services:
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
-124
View File
@@ -1,124 +0,0 @@
# beenvoice on Coolify — single Docker Compose resource (recommended).
#
# Deploy: Coolify → New Resource → Docker Compose → compose file: docker-compose.coolify.yml
#
# 1. Assign a domain to the `app` service in Coolify (SERVICE_FQDN_APP wires Traefik).
# 2. Set AUTH_SECRET, POSTGRES_PASSWORD, S3_ACCESS_KEY, S3_SECRET_KEY in the resource env (see .env.example).
# 3. Do NOT override S3_ENDPOINT — this stack sets http://garage:3900 on the shared network.
# 4. Rebuild after changing NEXT_PUBLIC_* (image build args use SERVICE_URL_APP).
#
# Migrating from Application + separate Postgres + Garage compose:
# - Export Postgres data, point DATABASE_URL at this stack's `db` service, redeploy once here.
# - Or keep external Postgres and remove the `db` service + volume from this file.
services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
image: ${BEENVOICE_IMAGE:-beenvoice:coolify}
environment:
SERVICE_FQDN_APP:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-garage}
expose:
- "3000"
depends_on:
db:
condition: service_healthy
garage:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_pg_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
-74
View File
@@ -1,74 +0,0 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
# S3-compatible receipt storage for host dev (`bun dev`). API :3900.
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_dev_garage_meta:/var/lib/garage/meta
- beenvoice_dev_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
ports:
- "${GARAGE_API_PORT:-3900}:3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_dev_pg_data:
beenvoice_dev_garage_meta:
beenvoice_dev_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
-123
View File
@@ -1,123 +0,0 @@
# Production stack (app + Postgres + Garage). Local dev Postgres/Garage: docker-compose.dev.yml
#
# Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred),
# or this file. S3_ENDPOINT=http://garage:3900 works only inside a single stack.
# Application + separate Garage → docs/COOLIFY.md.
#
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
# the existing local image and will NOT include new code. Use:
# ./scripts/docker-deploy.sh
# docker compose up -d --build
services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
# Fixed default tag (beenvoice:local) is reused until you --build. docker-deploy.sh
# sets BEENVOICE_IMAGE=beenvoice:<git-sha> so each deploy gets a fresh tag.
image: ${BEENVOICE_IMAGE:-beenvoice:local}
environment:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
S3_REGION: ${S3_REGION:-garage}
ports:
- "${WEB_PORT:-${PORT:-3000}}:3000"
depends_on:
db:
condition: service_healthy
garage:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
# S3-compatible receipt storage (~50100 MB RAM vs MinIO). API :3900.
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
ports:
- "${GARAGE_API_PORT:-3900}:3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_pg_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+8 -8
View File
@@ -1,8 +1,8 @@
# beenvoice-web architecture
# Beenvoice web architecture
Dense reference for the Next.js web application and API. Package manager: **Bun**. Database: **PostgreSQL** via Drizzle ORM.
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
This application is the server and browser workspace in the Beenvoice monorepo.
## Stack
@@ -144,7 +144,7 @@ Migrations: `bun run db:generate` → `drizzle/`; apply with `db:push` (dev) or
## Mobile API contract
The Expo app (`beenvoice-app`) does **not** use API keys. It:
The Expo app (`apps/mobile`) does **not** use API keys. It:
1. Calls the same tRPC endpoints with `Authorization` cookie header from `authClient.getCookie()`.
2. Stores session per account in SecureStore via `@better-auth/expo` (`storagePrefix`: `beenvoice:guest` or `beenvoice:auth:{accountId}`).
@@ -183,14 +183,14 @@ Validated in `src/env.js`. See `.env.example`.
| File | Use |
|------|-----|
| `docker-compose.yml` | Deploy: `app` + `db` (Postgres internal); copy `.env.example``.env` |
| `docker-compose.dev.yml` | Local dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
| Root `docker-compose.yml` | Deploy: `app` + `db` + Garage; use `apps/web/.env` |
| Root `docker-compose.dev.yml` | Local dev: Postgres + Garage |
App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run start` (migrations then `next start` on port 3000). Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun. Docker builds disable React Compiler and use `experimental.webpackMemoryOptimizations` to reduce peak RAM.
The app image is built from the root `Dockerfile`. Container startup runs the web migration script and then `next start` on port 3000. Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun.
Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars.
**Deploy / update:** `git pull && ./scripts/docker-deploy.sh` (or `docker compose up -d --build`). Plain `docker compose up -d` reuses the local `beenvoice:local` image and does not include pulled code. The deploy script tags images as `beenvoice:<git-sha>`.
**Deploy / update from the repository root:** `git pull && ./scripts/docker-deploy.sh` (or `docker compose up -d --build`). Plain `docker compose up -d` reuses the local `beenvoice:local` image and does not include pulled code.
## Scripts
@@ -214,4 +214,4 @@ bun run check # eslint + tsc
- [forms-guide.md](./forms-guide.md), [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md)
- [data-table-responsive-guide.md](./data-table-responsive-guide.md)
- [email-features.md](./email-features.md)
- Mobile companion: `../beenvoice-app/docs/ARCHITECTURE.md`
- Mobile companion: `../../mobile/docs/ARCHITECTURE.md`
+5 -5
View File
@@ -75,7 +75,7 @@ S3_REGION=garage
## Recommended long-term — one Compose stack
Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + Garage). This is the lowest-friction production layout on Coolify.
Deploy the root **[`docker-compose.coolify.yml`](../../../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + Garage). This is the lowest-friction production layout on Coolify.
1. Coolify → **New Resource****Docker Compose**
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
@@ -84,7 +84,7 @@ Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **on
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
6. Redeploy.
Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
Alternative: [`docker-compose.yml`](../../../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
### Migrating from Application + external Postgres + Garage (or legacy MinIO)
@@ -103,9 +103,9 @@ Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `
| File | Purpose |
|------|---------|
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
| [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
| [`docker-compose.coolify-garage.yml`](../docker-compose.coolify-garage.yml) | Garage only; pair with beenvoice Application (Path A or B above) |
| [`docker-compose.coolify.yml`](../../../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
| [`docker-compose.yml`](../../../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
| [`docker-compose.coolify-garage.yml`](../../../docker-compose.coolify-garage.yml) | Garage only; pair with beenvoice Application (Path A or B above) |
Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your server. Coolify v4 uses **destinations**; network names are often UUID-based. Prefer the UI **Connect to Predefined Network** toggle over hard-coding `coolify` in compose.
+4 -6
View File
@@ -1,6 +1,4 @@
# beenvoice-web documentation
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
# Beenvoice web documentation
## Core
@@ -26,11 +24,11 @@
| Document | Description |
|----------|-------------|
| [../../beenvoice-app/docs/ARCHITECTURE.md](../../beenvoice-app/docs/ARCHITECTURE.md) | Expo app architecture |
| [../../beenvoice-app/README.md](../../beenvoice-app/README.md) | Mobile setup |
| [../../mobile/docs/ARCHITECTURE.md](../../mobile/docs/ARCHITECTURE.md) | Expo app architecture |
| [../../mobile/README.md](../../mobile/README.md) | Mobile setup |
## Workspace
| Document | Description |
|----------|-------------|
| [../../README.md](../../README.md) | Meta repo layout, full-stack quick start |
| [../../../README.md](../../../README.md) | Monorepo layout and full-stack quick start |
+3 -1
View File
@@ -7,7 +7,9 @@ export default tseslint.config(
{
ignores: [".next", "scripts/**"],
},
...nextCoreWebVitals,
// The project supplies the type-aware typescript-eslint configs below.
// Avoid registering the same plugin again through Next's basic TS preset.
...nextCoreWebVitals.filter((config) => config.name !== "next/typescript"),
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
+1
View File
@@ -9,6 +9,7 @@ const disableReactCompiler = process.env.DISABLE_REACT_COMPILER === "1";
/** @type {import("next").NextConfig} */
const config = {
transpilePackages: ["@beenvoice/domain"],
// React Compiler is helpful in dev/prod but adds compile-time memory pressure in Docker builds.
reactCompiler: !disableReactCompiler,
productionBrowserSourceMaps: false,
+90 -89
View File
@@ -1,5 +1,5 @@
{
"name": "beenvoice",
"name": "@beenvoice/web",
"version": "0.1.0",
"private": true,
"type": "module",
@@ -13,10 +13,10 @@
"db:studio": "drizzle-kit studio",
"db:clone": "./scripts/clone-local.sh",
"demo:provision": "bun scripts/provision-demo-account.ts",
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:deploy": "./scripts/docker-deploy.sh",
"docker:up": "colima start && docker compose -f ../../docker-compose.dev.yml --env-file .env up -d",
"docker:down": "docker compose -f ../../docker-compose.dev.yml --env-file .env down && colima stop",
"docker:dev:down": "docker compose -f ../../docker-compose.dev.yml --env-file .env down && colima stop",
"docker:deploy": "../../scripts/docker-deploy.sh",
"deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -28,94 +28,95 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@better-auth/expo": "^1.6.19",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/playfair-display": "^5.2.8",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-collapsible": "^1.1.13",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-navigation-menu": "^1.2.15",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-progress": "^1.1.9",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@react-pdf/renderer": "^4.5.1",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-color": "^3.13.0",
"@tiptap/extension-list-item": "^3.13.0",
"@tiptap/extension-text-align": "^3.13.0",
"@tiptap/extension-text-style": "^3.13.0",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@trpc/client": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0",
"bcryptjs": "^3.0.3",
"better-auth": "^1.6.16",
"chrono-node": "^2.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"file-saver": "^2.0.5",
"framer-motion": "^12.40.0",
"fuse.js": "^7.4.2",
"lucide-react": "^0.525.0",
"next": "^16.2.12",
"@beenvoice/domain": "workspace:*",
"@aws-sdk/client-s3": "3.1075.0",
"@better-auth/expo": "1.6.19",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@fontsource-variable/playfair-display": "5.2.8",
"@radix-ui/react-alert-dialog": "1.1.16",
"@radix-ui/react-avatar": "1.1.12",
"@radix-ui/react-checkbox": "1.3.4",
"@radix-ui/react-collapsible": "1.1.13",
"@radix-ui/react-dialog": "1.1.16",
"@radix-ui/react-dropdown-menu": "2.1.17",
"@radix-ui/react-label": "2.1.9",
"@radix-ui/react-navigation-menu": "1.2.15",
"@radix-ui/react-popover": "1.1.16",
"@radix-ui/react-progress": "1.1.9",
"@radix-ui/react-select": "2.3.0",
"@radix-ui/react-separator": "1.1.9",
"@radix-ui/react-slot": "1.2.5",
"@radix-ui/react-switch": "1.3.0",
"@radix-ui/react-tabs": "1.1.14",
"@radix-ui/react-tooltip": "1.2.9",
"@react-pdf/renderer": "4.5.1",
"@t3-oss/env-nextjs": "0.12.0",
"@tanstack/react-query": "5.101.0",
"@tanstack/react-table": "8.21.3",
"@tiptap/extension-color": "3.22.4",
"@tiptap/extension-list-item": "3.22.4",
"@tiptap/extension-text-align": "3.22.4",
"@tiptap/extension-text-style": "3.22.4",
"@tiptap/react": "3.22.4",
"@tiptap/starter-kit": "3.22.4",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"bcryptjs": "3.0.3",
"better-auth": "1.6.16",
"chrono-node": "2.9.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"date-fns": "4.4.0",
"dotenv": "17.4.2",
"drizzle-orm": "0.45.2",
"file-saver": "2.0.5",
"framer-motion": "12.40.0",
"fuse.js": "7.4.2",
"lucide-react": "0.525.0",
"next": "16.2.12",
"pg": "8.21.0",
"react": "^19.2.8",
"react-colorful": "^5.7.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.8",
"react-dropzone": "^14.3.8",
"recharts": "^3.8.1",
"resend": "^4.8.0",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"sonner": "^2.0.7",
"superjson": "^2.2.6",
"tailwind-merge": "^3.6.0",
"trpc": "^0.11.3",
"zod": "^3.25.76"
"react": "19.2.8",
"react-colorful": "5.7.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.8",
"react-dropzone": "14.4.1",
"recharts": "3.8.1",
"resend": "4.8.0",
"server-only": "0.0.1",
"sharp": "0.35.3",
"sonner": "2.0.7",
"superjson": "2.2.6",
"tailwind-merge": "3.6.0",
"trpc": "0.11.3",
"zod": "3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@types/bcryptjs": "^2.4.6",
"@types/file-saver": "^2.0.7",
"@types/node": "^20.19.26",
"@types/pg": "^8.20.0",
"@types/raf": "^3.4.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"baseline-browser-mapping": "^2.10.34",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.1",
"eslint-config-next": "^16.2.7",
"eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.15",
"@tailwindcss/postcss": "4.3.0",
"@types/bcryptjs": "2.4.6",
"@types/file-saver": "2.0.7",
"@types/node": "20.19.39",
"@types/pg": "8.20.0",
"@types/raf": "3.4.3",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "2.10.34",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-config-next": "16.2.7",
"eslint-plugin-drizzle": "0.2.3",
"postcss": "8.5.15",
"prettier": "3.8.3",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.3.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.60.1"
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "4.3.0",
"tailwindcss-animate": "1.0.7",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"typescript-eslint": "8.60.1"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
# Rebuilds the app image from the current working tree, then starts/restarts services
# (app, db, garage). Receipt storage uses in-stack Garage unless S3_* are
# overridden in .env. Garage S3 API: localhost:${GARAGE_API_PORT:-3900}.
#
# Plain `docker compose up -d` reuses the local image tag and does NOT pick up
# changes from `git pull`. Always pass --build or use this script after pulling.
cd "$(dirname "$0")/.."
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
if [[ -z "${BEENVOICE_IMAGE:-}" ]] && command -v git >/dev/null 2>&1; then
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
BEENVOICE_IMAGE="beenvoice:$(git rev-parse --short HEAD)"
export BEENVOICE_IMAGE
fi
fi
BEENVOICE_IMAGE="${BEENVOICE_IMAGE:-beenvoice:local}"
export BEENVOICE_IMAGE
echo "Deploying ${BEENVOICE_IMAGE} (docker compose up -d --build)..."
exec docker compose up -d --build "$@"
+4 -11
View File
@@ -1,11 +1,4 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
export {
EXPENSE_CATEGORIES,
type ExpenseCategory,
} from "@beenvoice/domain/expense-categories";
+14 -76
View File
@@ -1,78 +1,36 @@
import {
getDaysPastDue as getSharedDaysPastDue,
getEffectiveInvoiceStatus as getSharedEffectiveInvoiceStatus,
getValidStatusTransitions as getSharedValidStatusTransitions,
isInvoiceOverdue as isSharedInvoiceOverdue,
isValidStatusTransition as isSharedValidStatusTransition,
} from "@beenvoice/domain/invoice-status";
import type {
StoredInvoiceStatus,
EffectiveInvoiceStatus,
StoredInvoiceStatus,
} from "~/types/invoice";
// Types are now imported from ~/types/invoice
/**
* Calculate the effective status of an invoice including overdue computation
*/
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): EffectiveInvoiceStatus {
// If already paid, status is always paid regardless of due date
if (storedStatus === "paid") {
return "paid";
}
// If draft, status is always draft
if (storedStatus === "draft") {
return "draft";
}
// For sent invoices, check if overdue
if (storedStatus === "sent") {
const today = new Date();
const due = new Date(dueDate);
// Set both dates to start of day for accurate comparison
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return due < today ? "overdue" : "sent";
}
return storedStatus;
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
}
/**
* Check if an invoice is overdue
*/
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
return isSharedInvoiceOverdue(storedStatus, dueDate);
}
/**
* Get days past due (returns 0 if not overdue)
*/
export function getDaysPastDue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): number {
if (!isInvoiceOverdue(storedStatus, dueDate)) {
return 0;
}
const today = new Date();
const due = new Date(dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
const diffTime = today.getTime() - due.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, diffDays);
return getSharedDaysPastDue(storedStatus, dueDate);
}
/**
* Status configuration for UI display
*/
export const statusConfig = {
draft: {
label: "Draft",
@@ -96,42 +54,22 @@ export const statusConfig = {
},
} as const;
/**
* Get status configuration for display
*/
export function getStatusConfig(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
) {
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
return statusConfig[effectiveStatus];
return statusConfig[getEffectiveInvoiceStatus(storedStatus, dueDate)];
}
/**
* Get valid status transitions from current stored status
*/
export function getValidStatusTransitions(
currentStatus: StoredInvoiceStatus,
): StoredInvoiceStatus[] {
switch (currentStatus) {
case "draft":
return ["sent", "paid"]; // Can send or mark paid directly
case "sent":
return ["paid", "draft"]; // Can mark paid or revert to draft
case "paid":
return ["sent"]; // Can revert to sent if needed (rare cases)
default:
return [];
}
return getSharedValidStatusTransitions(currentStatus);
}
/**
* Check if a status transition is valid
*/
export function isValidStatusTransition(
from: StoredInvoiceStatus,
to: StoredInvoiceStatus,
): boolean {
const validTransitions = getValidStatusTransitions(from);
return validTransitions.includes(to);
return isSharedValidStatusTransition(from, to);
}
+4 -63
View File
@@ -1,63 +1,4 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
vendor: string | null;
rawLines: string[];
};
const AMOUNT_PATTERNS = [
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
];
const DATE_PATTERNS = [
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
];
function parseAmount(text: string): number | null {
for (const pattern of AMOUNT_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value > 0) return value;
}
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((m) => Number(m[1]!.replace(/,/g, "")))
.filter((n) => Number.isFinite(n) && n > 0);
return amounts.length > 0 ? Math.max(...amounts) : null;
}
function parseDate(text: string): Date | null {
for (const pattern of DATE_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const parsed = new Date(match[1]);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function parseVendor(lines: string[]): string | null {
const candidate = lines.find((line) => line.trim().length >= 3);
return candidate?.trim().slice(0, 120) ?? null;
}
/** Heuristic receipt field extraction from OCR or pasted text. */
export function parseReceiptText(text: string): ReceiptParseResult {
const normalized = text.replace(/\r/g, "\n").trim();
const rawLines = normalized
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
amount: parseAmount(normalized),
date: parseDate(normalized),
vendor: parseVendor(rawLines),
rawLines,
};
}
export {
parseReceiptText,
type ReceiptParseResult,
} from "@beenvoice/domain/receipt-parse";
+8 -15
View File
@@ -1,5 +1,11 @@
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
import { LEGACY_DEFAULT_CLOCK_DESCRIPTION } from "@beenvoice/domain/time-clock";
import type { ClockOutOutcome } from "@beenvoice/domain/time-clock";
export {
formatElapsedSeconds,
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
type ClockOutOutcome,
} from "@beenvoice/domain/time-clock";
export function normalizeOptionalId(value?: string | null): string | null {
const trimmed = value?.trim();
@@ -44,24 +50,11 @@ export function resolveBillingDescription(description?: string | null): string {
return trimmed;
}
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"
| "saved_no_client"
| "zero_hours";
export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
}
export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
}
export function describeClockOutOutcome(input: {
outcome: ClockOutOutcome;
hours: number;