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
+1 -1
View File
@@ -4,7 +4,7 @@
{ {
"name": "beenvoice-web-dev", "name": "beenvoice-web-dev",
"runtimeExecutable": "bash", "runtimeExecutable": "bash",
"runtimeArgs": ["-c", "cd beenvoice-web && PORT=3010 bun run dev"], "runtimeArgs": ["-c", "cd apps/web && PORT=3010 bun run dev"],
"port": 3010 "port": 3010
} }
] ]
+32
View File
@@ -0,0 +1,32 @@
node_modules
**/node_modules
.next
**/.next
.turbo
**/.turbo
.expo
**/.expo
.git
.gitignore
Dockerfile*
docker-compose*
README.md
docs
AGENTS.md
*.log
.DS_Store
.env*
!.env.example
.vscode
.idea
coverage
*.tsbuildinfo
dist
**/dist
build
apps/mobile/*
!apps/mobile/package.json
apps/web/store-assets
apps/web/tsconfig.tsbuildinfo
.claude
beenvoice.icon
+9
View File
@@ -0,0 +1,9 @@
# Beenvoice repository guidance
- Use Bun exclusively for dependency installation and workspace scripts.
- Run cross-workspace commands from the repository root; use `--filter` for app-specific tasks.
- Put platform-neutral rules, serialized types, constants, and parsing in `packages/domain`.
- Keep browser-only UI in `apps/web` and React Native/native code in `apps/mobile`.
- Do not import server implementations into mobile runtime bundles. The `AppRouter` import is type-only.
- Keep Drizzle migrations and their journal together under `apps/web/drizzle`.
- Never commit `.env`, signing credentials, native build output, receipts, or customer data.
+57
View File
@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1.3.14 AS base
WORKDIR /app
FROM base AS install
COPY package.json bun.lock ./
COPY apps/web/package.json apps/web/package.json
COPY apps/mobile/package.json apps/mobile/package.json
COPY packages/domain/package.json packages/domain/package.json
RUN bun install --frozen-lockfile --filter @beenvoice/web
# Next's production build runs under Node because Bun can fail during the
# page-data worker phase on Linux arm64. Dependencies still come from Bun.
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY --from=install /app ./
COPY . .
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
ARG BETTER_AUTH_URL=http://localhost:3000
ENV DOCKER_BUILD=1 \
DISABLE_REACT_COMPILER=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
WORKDIR /app/apps/web
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=install /app/node_modules ./node_modules
COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/apps/web/package.json ./apps/web/package.json
COPY --from=build /app/packages/domain ./packages/domain
COPY --from=build /app/apps/web/.next ./apps/web/.next
COPY --from=build /app/apps/web/public ./apps/web/public
COPY --from=build /app/apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts
COPY --from=build /app/apps/web/drizzle ./apps/web/drizzle
COPY --from=build /app/apps/web/src/server/db/migrate.ts ./apps/web/src/server/db/migrate.ts
RUN chmod -R a+rX apps/web/drizzle apps/web/public apps/web/src/server/db/migrate.ts
USER bun
EXPOSE 3000
WORKDIR /app/apps/web
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
View File
+57 -71
View File
@@ -1,95 +1,81 @@
# beenvoice workspace # beenvoice
Local development layout for the **beenvoice** product: a freelancer/small-business invoicing platform with a Next.js web app and an Expo mobile companion. Beenvoice is a freelancer and small-business invoicing platform with a Next.js web application/API and an Expo mobile companion. The repository is a Bun workspace orchestrated by Turborepo.
``` ## Workspace map
beenvoice-meta/
├── beenvoice-web/ # Web API + dashboard (Next.js 16, tRPC, PostgreSQL) ```text
├── beenvoice-app/ # Mobile app (Expo, React Native, dev client) beenvoice/
── beenvoice.icon/ # iOS app icon asset (icon composer) ── apps/
│ ├── web/ # Next.js dashboard, tRPC API, PostgreSQL/Drizzle
│ └── mobile/ # Expo Router mobile app and iOS widgets
├── packages/
│ └── domain/ # Platform-neutral shared rules and parsing
├── Dockerfile
├── docker-compose*.yml
├── package.json
└── turbo.json
``` ```
Each subdirectory is its own git repository. This folder is a convenience layout for working on both clients against one API. The mobile app consumes the same tRPC API and Better Auth sessions as the web app. It imports the server router type at compile time, while runtime-safe shared behavior lives in `@beenvoice/domain`.
| Repo | Remote | ## Quick start
|------|--------|
| Web | [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web) |
| Mobile | [git.soconnor.dev/soconnor/beenvoice-app](https://git.soconnor.dev/soconnor/beenvoice-app) |
## Quick start (full stack)
```bash ```bash
# 1. API + database
cd beenvoice-web
cp .env.example .env.local # edit DATABASE_URL, AUTH_SECRET, etc.
docker compose -f docker-compose.dev.yml up -d
bun install && bun run db:push
bun run dev # http://localhost:3000
# 2. Mobile (simulator)
cd ../beenvoice-app
cp .env.example .env # EXPO_PUBLIC_API_URL=http://localhost:3000
bun install bun install
bun run ios # Metro on :8082, native dev build
cp apps/web/.env.example apps/web/.env.local
cp apps/mobile/.env.example apps/mobile/.env
bun run --filter @beenvoice/web docker:up
bun run --filter @beenvoice/web db:push
bun run dev
``` ```
Physical iPhone: set `EXPO_PUBLIC_API_URL` to your Mac's LAN IP; ensure `beenvoice-web` has `BETTER_AUTH_URL` matching a host the device can reach (trusted origins are derived from `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL`). `bun run dev` starts Next.js on port 3000 and Expo Metro on port 8082. For a physical iPhone, set `EXPO_PUBLIC_API_URL` in `apps/mobile/.env` to a host the device can reach and configure the web app's canonical/auth URLs consistently.
## Production deploy (Docker) Useful workspace commands:
From `beenvoice-web` with `.env` configured (see [beenvoice-web/README.md](./beenvoice-web/README.md#docker-deployment-app--database)):
```bash ```bash
cd beenvoice-web bun run typecheck
git pull bun run lint
./scripts/docker-deploy.sh # rebuild app image + restart (not docker-compose.dev.yml) bun run test
bun run build
bun run check
``` ```
Plain `docker compose up -d` without `--build` reuses the existing local image and will not pick up code from `git pull`. Run an app-specific command with a workspace filter:
## How the pieces connect ```bash
bun run --filter @beenvoice/web db:migrate
bun run --filter @beenvoice/mobile ios
bun run --filter @beenvoice/mobile ios:release:upload
```
| Layer | Repo | Transport | Auth | ## Production Docker deployment
|-------|------|-----------|------|
| Web UI | `beenvoice-web` | tRPC `/api/trpc` (cookies) | better-auth session |
| Mobile UI | `beenvoice-app` | tRPC `/api/trpc` (cookie header) | better-auth + `@better-auth/expo` → SecureStore |
| Automation | `beenvoice-web` MCP `/api/mcp` | JSON-RPC | API key (`bv_…`) only |
| Public invoices | `beenvoice-web` `/i/[token]` | HTTP | unauthenticated token |
Mobile imports **tRPC router types** from `beenvoice-web/src/server/api/root` via `tsconfig` path mapping — keep API changes type-checked in both repos. Keep production web configuration in `apps/web/.env`, then run from the repository root:
## Documentation map ```bash
git pull
./scripts/docker-deploy.sh
```
| Doc | Contents | The root Dockerfile installs the frozen Bun workspace lockfile, builds the Next.js app under Node, and runs migrations plus the web server under Bun.
|-----|----------|
| [beenvoice-web/README.md](./beenvoice-web/README.md) | Web setup, Docker deploy, scripts |
| [beenvoice-web/docs/ARCHITECTURE.md](./beenvoice-web/docs/ARCHITECTURE.md) | Server architecture (dense) |
| [beenvoice-app/README.md](./beenvoice-app/README.md) | Mobile setup, troubleshooting |
| [beenvoice-app/docs/ARCHITECTURE.md](./beenvoice-app/docs/ARCHITECTURE.md) | Mobile architecture (dense) |
| [beenvoice-web/docs/](./beenvoice-web/docs/) | UI guides (forms, tables, breadcrumbs, email) |
## Shared domain concepts ## Documentation
- **Clients** — billable contacts with optional default hourly rate - [Web setup](./apps/web/README.md)
- **Businesses** — sender profiles (logo, address, Resend email config) - [Web architecture](./apps/web/docs/ARCHITECTURE.md)
- **Invoices** — draft → sent → paid; line items; PDF + email; public share token - [Mobile setup](./apps/mobile/README.md)
- **Time entries** — clock in/out; one running timer per user; auto-attach to open invoice - [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md)
- **Recurring invoices**, **expenses**, **payments**, **templates** — full web support; mobile covers a core CRUD subset - [Shared domain package](./packages/domain/README.md)
## Environment cheat sheet ## Product concepts
| Variable | Where | Purpose | - Clients with optional default hourly rates
|----------|-------|---------| - Businesses and sender branding
| `DATABASE_URL` | beenvoice-web | PostgreSQL | - Draft, sent, paid, and overdue invoices
| `AUTH_SECRET`, `BETTER_AUTH_URL` | beenvoice-web | better-auth | - Time tracking and invoice attachment
| `EXPO_PUBLIC_API_URL` | beenvoice-app | API base for mobile | - Recurring invoices, expenses, payments, and templates
| `DISABLE_SIGNUPS` | beenvoice-web | `true` blocks new registrations | - Public invoice links and API-key-authenticated automation
| `RESEND_*` | beenvoice-web | Invoice / reset email |
## Ports
| Service | Default port |
|---------|----------------|
| Next.js (beenvoice-web) | 3000 |
| Postgres (dev compose) | 5432 |
| Metro (beenvoice-app) | **8082** (intentionally not 8081) |
+5 -4
View File
@@ -1,4 +1,4 @@
# beenvoice-app — agent notes # Beenvoice mobile — agent notes
Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) before changing native config. Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) before changing native config.
@@ -10,7 +10,8 @@ Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) b
## Conventions ## Conventions
- **Package manager**: Bun only - **Package manager**: Bun only
- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../beenvoice-web/src/*`) - **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../web/src/*`)
- **Shared domain**: import platform-neutral behavior from `@beenvoice/domain`
- **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts` - **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts`
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`) - **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`) - **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`)
@@ -28,6 +29,6 @@ Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) b
| App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` | | App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` |
| Time clock | `components/time-clock/TimeClockPanel.tsx` | | Time clock | `components/time-clock/TimeClockPanel.tsx` |
## Server repo ## Server workspace
Sibling `../beenvoice-web` — run `bun run dev` on :3000 before mobile dev. Sibling `../web` — run `bun run dev` at the repository root to start both apps.
+10 -10
View File
@@ -1,20 +1,20 @@
# beenvoice Mobile # beenvoice Mobile
Expo companion for [beenvoice-web](../beenvoice-web) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API** and **better-auth** sessions as the web app. Expo companion for the [Beenvoice web app](../web/README.md) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API**, Better Auth sessions, and platform-neutral domain package.
**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) **Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
## Prerequisites ## Prerequisites
- [Bun](https://bun.sh) 1.3+ - [Bun](https://bun.sh) 1.3+
- beenvoice API running ([setup](../beenvoice-web/README.md)) - Beenvoice API running ([setup](../web/README.md))
- Xcode + iOS Simulator (or device) for native dev build - Xcode + iOS Simulator (or device) for native dev build
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client` - **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
## Setup ## Setup
```bash ```bash
cd beenvoice-app cd apps/mobile
bun install bun install
cp .env.example .env cp .env.example .env
``` ```
@@ -36,11 +36,11 @@ Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `been
## Run ## Run
```bash ```bash
# Terminal 1 — API # Terminal 1, from the repository root — API + Metro
cd ../beenvoice-web && bun run dev bun run dev
# Terminal 2 — mobile (builds native app if needed) # Terminal 2, from the repository root — native app
cd beenvoice-app && bun run ios bun run --filter @beenvoice/mobile ios
``` ```
Metro uses port **8082** (avoids other Expo projects on 8081). Metro uses port **8082** (avoids other Expo projects on 8081).
@@ -145,6 +145,6 @@ widgets/ # iOS Live Activity (TimeClockActivity)
## Related ## Related
- [beenvoice-web README](../beenvoice-web/README.md) - [Web README](../web/README.md)
- [beenvoice-web ARCHITECTURE](../beenvoice-web/docs/ARCHITECTURE.md) - [Web architecture](../web/docs/ARCHITECTURE.md)
- [Workspace root README](../README.md) - [Workspace root README](../../README.md)
-1516
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -247,7 +247,7 @@ Optional: iPad 12.9" if `supportsTablet: true` — use iPad simulator or “Run
See **[IOS_LOCAL_RELEASE.md](./IOS_LOCAL_RELEASE.md)** for the full guide. See **[IOS_LOCAL_RELEASE.md](./IOS_LOCAL_RELEASE.md)** for the full guide.
```bash ```bash
cd beenvoice-app cd apps/mobile
cp .ios-release.env.example .ios-release.env # once — add Team ID + API key cp .ios-release.env.example .ios-release.env # once — add Team ID + API key
bun run ios:release:upload # archive + upload to TestFlight bun run ios:release:upload # archive + upload to TestFlight
``` ```
@@ -257,7 +257,7 @@ Requires Xcode on macOS, Apple Developer membership, and an App Store Connect AP
### Option B — EAS (Expo cloud build) ### Option B — EAS (Expo cloud build)
```bash ```bash
cd beenvoice-app cd apps/mobile
# Production iOS build (auto-increments build number) # Production iOS build (auto-increments build number)
eas build --platform ios --profile production eas build --platform ios --profile production
+5 -4
View File
@@ -1,4 +1,4 @@
# beenvoice-app architecture # Beenvoice mobile architecture
Dense reference for the Expo 57 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics. Dense reference for the Expo 57 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics.
@@ -10,7 +10,8 @@ Dense reference for the Expo 57 mobile companion. Talks to **beenvoice** over tR
| UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) | | UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) |
| API | tRPC 11 + TanStack Query, SuperJSON | | API | tRPC 11 + TanStack Query, SuperJSON |
| Auth | better-auth + `@better-auth/expo``expo-secure-store` | | Auth | better-auth + `@better-auth/expo``expo-secure-store` |
| Types | `AppRouter` imported from `../beenvoice-web/src/server/api/root` | | Types | `AppRouter` imported type-only from sibling `apps/web` |
| Shared domain | `@beenvoice/domain` workspace package |
## Boot sequence ## Boot sequence
@@ -271,6 +272,6 @@ Requires beenvoice with:
- `@better-auth/expo` in `src/lib/auth.ts` - `@better-auth/expo` in `src/lib/auth.ts`
- `trustedOrigins` including `beenvoice://` and `exp://` - `trustedOrigins` including `beenvoice://` and `exp://`
- Postgres running (`docker compose -f docker-compose.dev.yml up -d db`) - Postgres running (`bun run --filter @beenvoice/web docker:up` from the repository root)
See [beenvoice-web/docs/ARCHITECTURE.md](../../beenvoice-web/docs/ARCHITECTURE.md). See the [web architecture](../../web/docs/ARCHITECTURE.md).
+1 -1
View File
@@ -13,7 +13,7 @@ Archive and upload **beenvoice** to App Store Connect using Xcode on your Mac
## One-time setup ## One-time setup
```bash ```bash
cd beenvoice-app cd apps/mobile
cp .ios-release.env.example .ios-release.env cp .ios-release.env.example .ios-release.env
``` ```
+3 -3
View File
@@ -1,4 +1,4 @@
# beenvoice-app documentation # Beenvoice mobile documentation
| Document | Description | | Document | Description |
|----------|-------------| |----------|-------------|
@@ -8,5 +8,5 @@
## Related ## Related
- [beenvoice-web docs](../../beenvoice-web/docs/README.md) — server API and web app - [Web docs](../../web/docs/README.md) — server API and web app
- [Workspace README](../../README.md) — full-stack layout - [Workspace README](../../../README.md) — full-stack layout
+4 -11
View File
@@ -1,11 +1,4 @@
export const EXPENSE_CATEGORIES = [ export {
"Travel", EXPENSE_CATEGORIES,
"Meals & Entertainment", type ExpenseCategory,
"Software & Subscriptions", } from "@beenvoice/domain/expense-categories";
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
+8 -11
View File
@@ -1,19 +1,16 @@
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue"; import { getEffectiveInvoiceStatus } from "@beenvoice/domain/invoice-status";
import type { EffectiveInvoiceStatus } from "@beenvoice/domain/invoice-status";
export type InvoiceStatus = EffectiveInvoiceStatus;
export function getInvoiceStatus(invoice: { export function getInvoiceStatus(invoice: {
status: string; status: string;
dueDate: Date | string; dueDate: Date | string;
}): InvoiceStatus { }): InvoiceStatus {
if (invoice.status === "paid") return "paid"; if (invoice.status === "paid" || invoice.status === "draft") {
if (invoice.status === "draft") return "draft"; return invoice.status;
}
const today = new Date(); return getEffectiveInvoiceStatus("sent", invoice.dueDate);
const due = new Date(invoice.dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
if (due < today) return "overdue";
return "sent";
} }
export const statusLabels: Record<InvoiceStatus, string> = { export const statusLabels: Record<InvoiceStatus, string> = {
+5 -128
View File
@@ -1,128 +1,5 @@
export type ReceiptParseResult = { export {
amount: number | null; parseReceiptText,
date: Date | null; type ReceiptLineItem,
subtotal: number | null; type ReceiptParseResult,
tax: number | null; } from "@beenvoice/domain/receipt-parse";
vendor: string | null;
items: ReceiptLineItem[];
rawLines: string[];
};
export type ReceiptLineItem = {
id: string;
name: string;
amount: number;
rawLine: 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})/,
];
const SUBTOTAL_PATTERNS = [
/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
];
const TAX_PATTERNS = [
/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
];
const NON_ITEM_LINE =
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
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 parseFirstMatchingAmount(
text: string,
patterns: RegExp[],
): number | null {
for (const pattern of 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;
}
return 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;
}
function parseLineItems(lines: string[]): ReceiptLineItem[] {
const items: ReceiptLineItem[] = [];
for (const [index, rawLine] of lines.entries()) {
const line = rawLine.replace(/\s+/g, " ").trim();
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
if (!match?.[1] || !match[2]) continue;
const amount = Number(match[2].replace(/,/g, ""));
const name = match[1]
.replace(/^\d+\s*[xX]\s+/, "")
.replace(/\s+\d+\s*[xX]\s*$/, "")
.trim();
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
items.push({
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
name: name.slice(0, 80),
amount,
rawLine,
});
}
return items.slice(0, 30);
}
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),
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
vendor: parseVendor(rawLines),
items: parseLineItems(rawLines),
rawLines,
};
}
+12 -23
View File
@@ -1,13 +1,16 @@
export type ClockOutOutcome = import {
| "linked_to_invoice" DEFAULT_CLOCK_DESCRIPTION,
| "saved_no_invoice" LEGACY_DEFAULT_CLOCK_DESCRIPTION,
| "saved_no_client" } from "@beenvoice/domain/time-clock";
| "zero_hours"; import type { ClockOutOutcome } from "@beenvoice/domain/time-clock";
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In"; export {
DEFAULT_CLOCK_DESCRIPTION,
/** Stored on entries clocked in before empty descriptions were allowed. */ formatElapsedHoursMinutes,
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services"; formatElapsedSeconds,
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
type ClockOutOutcome,
} from "@beenvoice/domain/time-clock";
export function resolveClockDescription(description: string | null | undefined): string { export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim(); const trimmed = description?.trim();
@@ -26,20 +29,6 @@ export function formatRunningTimerLabel(description?: string | null): string {
return trimmed; return trimmed;
} }
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(":");
}
/** Hours and minutes only — for Live Activity / compact displays. */
export function formatElapsedHoursMinutes(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}
export function resolveEffectiveHourlyRate( export function resolveEffectiveHourlyRate(
rateText: string, rateText: string,
clientDefaultRate?: number | null, clientDefaultRate?: number | null,
+52 -42
View File
@@ -1,67 +1,77 @@
{ {
"name": "beenvoice-app", "name": "@beenvoice/mobile",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@better-auth/expo": "^1.6.19", "@beenvoice/domain": "workspace:*",
"@expo-google-fonts/inter": "^0.4.2", "@better-auth/expo": "1.6.19",
"@expo-google-fonts/playfair-display": "^0.4.2", "@expo-google-fonts/inter": "0.4.2",
"@expo/ui": "~57.0.11", "@expo-google-fonts/playfair-display": "0.4.2",
"@expo/vector-icons": "^15.1.1", "@expo/ui": "57.0.11",
"@expo/vector-icons": "15.1.1",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "9.1.0", "@react-native-community/datetimepicker": "9.1.0",
"@react-native-picker/picker": "^2.11.4", "@react-native-picker/picker": "2.11.4",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "5.101.0",
"@trpc/client": "^11.17.0", "@trpc/client": "11.17.0",
"@trpc/react-query": "^11.17.0", "@trpc/react-query": "11.17.0",
"better-auth": "^1.6.19", "@trpc/server": "11.17.0",
"expo": "^57.0.9", "better-auth": "1.6.19",
"expo-blur": "~57.0.2", "expo": "57.0.13",
"expo-build-properties": "~57.0.11", "expo-blur": "57.0.2",
"expo-constants": "~57.0.11", "expo-build-properties": "57.0.11",
"expo-dev-client": "~57.0.12", "expo-constants": "57.0.11",
"expo-file-system": "~57.0.4", "expo-dev-client": "57.0.12",
"expo-font": "~57.0.1", "expo-file-system": "57.0.4",
"expo-image": "~57.0.3", "expo-font": "57.0.1",
"expo-image-picker": "~57.0.10", "expo-image": "57.0.3",
"expo-linear-gradient": "~57.0.1", "expo-image-picker": "57.0.10",
"expo-linking": "~57.0.6", "expo-linear-gradient": "57.0.1",
"expo-local-authentication": "~57.0.2", "expo-linking": "57.0.6",
"expo-mlkit-ocr": "^0.2.7", "expo-local-authentication": "57.0.2",
"expo-network": "~57.0.1", "expo-mlkit-ocr": "0.2.7",
"expo-notifications": "~57.0.11", "expo-modules-core": "57.0.11",
"expo-router": "~57.0.13", "expo-network": "57.0.1",
"expo-secure-store": "~57.0.1", "expo-notifications": "57.0.11",
"expo-sharing": "~57.0.12", "expo-router": "57.0.13",
"expo-splash-screen": "~57.0.6", "expo-secure-store": "57.0.1",
"expo-status-bar": "~57.0.1", "expo-sharing": "57.0.12",
"expo-symbols": "~57.0.2", "expo-splash-screen": "57.0.6",
"expo-web-browser": "~57.0.2", "expo-status-bar": "57.0.1",
"expo-widgets": "~57.0.10", "expo-symbols": "57.0.2",
"expo-web-browser": "57.0.2",
"expo-widgets": "57.0.10",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-gesture-handler": "3.2.1",
"react-native-reanimated": "4.5.1", "react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "5.7.0",
"react-native-screens": "~4.26.0", "react-native-screens": "4.26.2",
"react-native-svg": "15.15.4", "react-native-svg": "15.15.4",
"react-native-web": "~0.21.0", "react-native-web": "0.21.2",
"react-native-webview": "13.16.1", "react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1", "react-native-worklets": "0.10.1",
"superjson": "^2.2.6" "superjson": "2.2.6"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@expo/config-plugins": "57.0.8",
"@types/react": "~19.2.2", "@types/bun": "1.3.14",
"react-native-svg-transformer": "^1.5.3", "@types/react": "19.2.17",
"typescript": "~6.0.3" "react-native-svg-transformer": "1.5.3",
"typescript": "6.0.3",
"xcode": "3.0.1"
}, },
"scripts": { "scripts": {
"dev": "expo start --dev-client --port 8082",
"start": "expo start --dev-client --port 8082", "start": "expo start --dev-client --port 8082",
"android": "expo run:android --port 8082", "android": "expo run:android --port 8082",
"ios": "expo run:ios --port 8082", "ios": "expo run:ios --port 8082",
"ios:release": "bash scripts/ios-release.sh", "ios:release": "bash scripts/ios-release.sh",
"ios:release:upload": "bash scripts/ios-release.sh --upload", "ios:release:upload": "bash scripts/ios-release.sh --upload",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "bun test", "test": "bun test",
"web": "expo start --web" "web": "expo start --web"
}, },
@@ -1,18 +1,20 @@
/// <reference types="bun" /> /// <reference types="bun" />
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import {
EXPENSE_CATEGORIES as domainExpenseCategories,
formatElapsedSeconds as formatDomainElapsedSeconds,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
import { fetchAuthCapabilities } from "../lib/auth-capabilities"; import { fetchAuthCapabilities } from "../lib/auth-capabilities";
import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories"; import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
import { getInvoiceStatus } from "../lib/invoice-status"; import { getInvoiceStatus } from "../lib/invoice-status";
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock"; import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
import { EXPENSE_CATEGORIES as webExpenseCategories } from "../../beenvoice-web/src/lib/expense-categories"; import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
import { getEffectiveInvoiceStatus } from "../../beenvoice-web/src/lib/invoice-status";
import { safeCallbackPath } from "../../beenvoice-web/src/lib/safe-callback-url";
import { import {
formatElapsedSeconds as formatWebElapsedSeconds,
normalizeOptionalId, normalizeOptionalId,
} from "../../beenvoice-web/src/lib/time-clock"; } from "../../web/src/lib/time-clock";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -85,7 +87,7 @@ describe("timer parity", () => {
test("elapsed time formatting is identical", () => { test("elapsed time formatting is identical", () => {
for (const seconds of [0, 59, 60, 3_661, 86_399]) { for (const seconds of [0, 59, 60, 3_661, 86_399]) {
expect(formatAppElapsedSeconds(seconds)).toBe( expect(formatAppElapsedSeconds(seconds)).toBe(
formatWebElapsedSeconds(seconds), formatDomainElapsedSeconds(seconds),
); );
} }
}); });
@@ -99,6 +101,6 @@ describe("timer parity", () => {
describe("expense parity", () => { describe("expense parity", () => {
test("web and mobile expose the same category vocabulary", () => { test("web and mobile expose the same category vocabulary", () => {
expect([...appExpenseCategories]).toEqual([...webExpenseCategories]); expect([...appExpenseCategories]).toEqual([...domainExpenseCategories]);
}); });
}); });
+3 -3
View File
@@ -5,9 +5,9 @@
"skipLibCheck": true, "skipLibCheck": true,
"paths": { "paths": {
"@/*": ["./*"], "@/*": ["./*"],
"~/*": ["../beenvoice-web/src/*"], "~/*": ["../web/src/*"],
"src/*": ["../beenvoice-web/src/*"], "src/*": ["../web/src/*"],
"beenvoice/*": ["../beenvoice-web/src/*"] "beenvoice/*": ["../web/src/*"]
} }
}, },
"include": [ "include": [
-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): # Quick start (local dev):
+2 -4
View File
@@ -1,11 +1,9 @@
# beenvoice - AI Assistant Rules # 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 ## 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. `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.
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
## Core Development Principles ## 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 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. 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) **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 ## Stack
@@ -39,12 +38,12 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
## Local development ## Local development
### 1. Clone and install ### 1. Install the workspace
```bash ```bash
git clone https://git.soconnor.dev/soconnor/beenvoice-web.git cd beenvoice
cd beenvoice-web
bun install bun install
cd apps/web
``` ```
### 2. Environment ### 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): Start Postgres (dev compose exposes port 5432):
```bash ```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. 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. 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 ### 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`: `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 ```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. `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) ### 2. First start (or after code changes)
```bash ```bash
./scripts/docker-deploy.sh ../../scripts/docker-deploy.sh
# or: bun run docker:deploy # 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. `--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 ```bash
git pull 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 # or: docker compose up -d --build
``` ```
@@ -168,7 +167,7 @@ git pull
|---------|-----------|-----------------| |---------|-----------|-----------------|
| `git pull` only | No | No | | `git pull` only | No | No |
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) | | `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) | | `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). 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 ### 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 ### 4. Sign-ups
@@ -201,21 +200,18 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on
## Project structure ## Project structure
``` ```
beenvoice-web/ apps/web/
├── src/app/ # Routes (dashboard, auth, /api/*) ├── src/app/ # Routes (dashboard, auth, /api/*)
├── src/server/api/ # tRPC routers ├── src/server/api/ # tRPC routers
├── src/server/db/ # Drizzle schema, pool, migrate.ts ├── src/server/db/ # Drizzle schema, pool, migrate.ts
├── src/components/ # UI (ui/, forms/, layout/, branding/) ├── src/components/ # UI (ui/, forms/, layout/, branding/)
├── src/lib/ # auth, PDF, email, branding helpers ├── src/lib/ # auth, PDF, email, branding helpers
├── drizzle/ # SQL migrations ├── 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 └── 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. See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for routers, schema, auth flows, and MCP.
## Scripts ## 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 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 ## API surface
@@ -277,4 +273,4 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
## License ## License
MIT — see [LICENSE](LICENSE). MIT — see the root [LICENSE](../../LICENSE).
-1938
View File
File diff suppressed because it is too large Load Diff
+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. 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 ## Stack
@@ -144,7 +144,7 @@ Migrations: `bun run db:generate` → `drizzle/`; apply with `db:push` (dev) or
## Mobile API contract ## 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()`. 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}`). 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 | | File | Use |
|------|-----| |------|-----|
| `docker-compose.yml` | Deploy: `app` + `db` (Postgres internal); copy `.env.example``.env` | | Root `docker-compose.yml` | Deploy: `app` + `db` + Garage; use `apps/web/.env` |
| `docker-compose.dev.yml` | Local dev: Postgres only, port `${POSTGRES_PORT:-5432}` | | 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. 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 ## Scripts
@@ -214,4 +214,4 @@ bun run check # eslint + tsc
- [forms-guide.md](./forms-guide.md), [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md) - [forms-guide.md](./forms-guide.md), [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md)
- [data-table-responsive-guide.md](./data-table-responsive-guide.md) - [data-table-responsive-guide.md](./data-table-responsive-guide.md)
- [email-features.md](./email-features.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 ## 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** 1. Coolify → **New Resource****Docker Compose**
2. Point at this repo; compose file: **`docker-compose.coolify.yml`** 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. 5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
6. Redeploy. 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) ### 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 | | File | Purpose |
|------|---------| |------|---------|
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource | | [`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.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-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. 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 # Beenvoice web documentation
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
## Core ## Core
@@ -26,11 +24,11 @@
| Document | Description | | Document | Description |
|----------|-------------| |----------|-------------|
| [../../beenvoice-app/docs/ARCHITECTURE.md](../../beenvoice-app/docs/ARCHITECTURE.md) | Expo app architecture | | [../../mobile/docs/ARCHITECTURE.md](../../mobile/docs/ARCHITECTURE.md) | Expo app architecture |
| [../../beenvoice-app/README.md](../../beenvoice-app/README.md) | Mobile setup | | [../../mobile/README.md](../../mobile/README.md) | Mobile setup |
## Workspace ## Workspace
| Document | Description | | 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/**"], 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"], files: ["**/*.ts", "**/*.tsx"],
plugins: { plugins: {
+1
View File
@@ -9,6 +9,7 @@ const disableReactCompiler = process.env.DISABLE_REACT_COMPILER === "1";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
transpilePackages: ["@beenvoice/domain"],
// React Compiler is helpful in dev/prod but adds compile-time memory pressure in Docker builds. // React Compiler is helpful in dev/prod but adds compile-time memory pressure in Docker builds.
reactCompiler: !disableReactCompiler, reactCompiler: !disableReactCompiler,
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
+90 -89
View File
@@ -1,5 +1,5 @@
{ {
"name": "beenvoice", "name": "@beenvoice/web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -13,10 +13,10 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:clone": "./scripts/clone-local.sh", "db:clone": "./scripts/clone-local.sh",
"demo:provision": "bun scripts/provision-demo-account.ts", "demo:provision": "bun scripts/provision-demo-account.ts",
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "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 down && colima stop", "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 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", "docker:deploy": "../../scripts/docker-deploy.sh",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -28,94 +28,95 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1075.0", "@beenvoice/domain": "workspace:*",
"@better-auth/expo": "^1.6.19", "@aws-sdk/client-s3": "3.1075.0",
"@dnd-kit/core": "^6.3.1", "@better-auth/expo": "1.6.19",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/sortable": "10.0.0",
"@fontsource-variable/playfair-display": "^5.2.8", "@dnd-kit/utilities": "3.2.2",
"@radix-ui/react-alert-dialog": "^1.1.16", "@fontsource-variable/playfair-display": "5.2.8",
"@radix-ui/react-avatar": "^1.1.12", "@radix-ui/react-alert-dialog": "1.1.16",
"@radix-ui/react-checkbox": "^1.3.4", "@radix-ui/react-avatar": "1.1.12",
"@radix-ui/react-collapsible": "^1.1.13", "@radix-ui/react-checkbox": "1.3.4",
"@radix-ui/react-dialog": "^1.1.16", "@radix-ui/react-collapsible": "1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.17", "@radix-ui/react-dialog": "1.1.16",
"@radix-ui/react-label": "^2.1.9", "@radix-ui/react-dropdown-menu": "2.1.17",
"@radix-ui/react-navigation-menu": "^1.2.15", "@radix-ui/react-label": "2.1.9",
"@radix-ui/react-popover": "^1.1.16", "@radix-ui/react-navigation-menu": "1.2.15",
"@radix-ui/react-progress": "^1.1.9", "@radix-ui/react-popover": "1.1.16",
"@radix-ui/react-select": "^2.3.0", "@radix-ui/react-progress": "1.1.9",
"@radix-ui/react-separator": "^1.1.9", "@radix-ui/react-select": "2.3.0",
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-separator": "1.1.9",
"@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-slot": "1.2.5",
"@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-switch": "1.3.0",
"@radix-ui/react-tooltip": "^1.2.9", "@radix-ui/react-tabs": "1.1.14",
"@react-pdf/renderer": "^4.5.1", "@radix-ui/react-tooltip": "1.2.9",
"@t3-oss/env-nextjs": "^0.12.0", "@react-pdf/renderer": "4.5.1",
"@tanstack/react-query": "^5.101.0", "@t3-oss/env-nextjs": "0.12.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-query": "5.101.0",
"@tiptap/extension-color": "^3.13.0", "@tanstack/react-table": "8.21.3",
"@tiptap/extension-list-item": "^3.13.0", "@tiptap/extension-color": "3.22.4",
"@tiptap/extension-text-align": "^3.13.0", "@tiptap/extension-list-item": "3.22.4",
"@tiptap/extension-text-style": "^3.13.0", "@tiptap/extension-text-align": "3.22.4",
"@tiptap/react": "^3.13.0", "@tiptap/extension-text-style": "3.22.4",
"@tiptap/starter-kit": "^3.13.0", "@tiptap/react": "3.22.4",
"@trpc/client": "^11.17.0", "@tiptap/starter-kit": "3.22.4",
"@trpc/react-query": "^11.17.0", "@trpc/client": "11.17.0",
"@trpc/server": "^11.17.0", "@trpc/react-query": "11.17.0",
"bcryptjs": "^3.0.3", "@trpc/server": "11.17.0",
"better-auth": "^1.6.16", "bcryptjs": "3.0.3",
"chrono-node": "^2.9.1", "better-auth": "1.6.16",
"class-variance-authority": "^0.7.1", "chrono-node": "2.9.1",
"clsx": "^2.1.1", "class-variance-authority": "0.7.1",
"date-fns": "^4.4.0", "clsx": "2.1.1",
"dotenv": "^17.4.2", "date-fns": "4.4.0",
"drizzle-orm": "^0.45.2", "dotenv": "17.4.2",
"file-saver": "^2.0.5", "drizzle-orm": "0.45.2",
"framer-motion": "^12.40.0", "file-saver": "2.0.5",
"fuse.js": "^7.4.2", "framer-motion": "12.40.0",
"lucide-react": "^0.525.0", "fuse.js": "7.4.2",
"next": "^16.2.12", "lucide-react": "0.525.0",
"next": "16.2.12",
"pg": "8.21.0", "pg": "8.21.0",
"react": "^19.2.8", "react": "19.2.8",
"react-colorful": "^5.7.0", "react-colorful": "5.7.0",
"react-day-picker": "^9.12.0", "react-day-picker": "9.14.0",
"react-dom": "^19.2.8", "react-dom": "19.2.8",
"react-dropzone": "^14.3.8", "react-dropzone": "14.4.1",
"recharts": "^3.8.1", "recharts": "3.8.1",
"resend": "^4.8.0", "resend": "4.8.0",
"server-only": "^0.0.1", "server-only": "0.0.1",
"sharp": "^0.35.3", "sharp": "0.35.3",
"sonner": "^2.0.7", "sonner": "2.0.7",
"superjson": "^2.2.6", "superjson": "2.2.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "3.6.0",
"trpc": "^0.11.3", "trpc": "0.11.3",
"zod": "^3.25.76" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "4.3.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "2.4.6",
"@types/file-saver": "^2.0.7", "@types/file-saver": "2.0.7",
"@types/node": "^20.19.26", "@types/node": "20.19.39",
"@types/pg": "^8.20.0", "@types/pg": "8.20.0",
"@types/raf": "^3.4.3", "@types/raf": "3.4.3",
"@types/react": "^19.2.17", "@types/react": "19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.10.34", "baseline-browser-mapping": "2.10.34",
"drizzle-kit": "^0.31.10", "drizzle-kit": "0.31.10",
"eslint": "^9.39.1", "eslint": "9.39.4",
"eslint-config-next": "^16.2.7", "eslint-config-next": "16.2.7",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "0.2.3",
"postcss": "^8.5.15", "postcss": "8.5.15",
"prettier": "3.8.3", "prettier": "3.8.3",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "^4.3.0", "tailwindcss": "4.3.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "1.0.7",
"tw-animate-css": "^1.4.0", "tw-animate-css": "1.4.0",
"typescript": "^5.9.3", "typescript": "5.9.3",
"typescript-eslint": "^8.60.1" "typescript-eslint": "8.60.1"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"
+4 -11
View File
@@ -1,11 +1,4 @@
export const EXPENSE_CATEGORIES = [ export {
"Travel", EXPENSE_CATEGORIES,
"Meals & Entertainment", type ExpenseCategory,
"Software & Subscriptions", } from "@beenvoice/domain/expense-categories";
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
+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 { import type {
StoredInvoiceStatus,
EffectiveInvoiceStatus, EffectiveInvoiceStatus,
StoredInvoiceStatus,
} from "~/types/invoice"; } from "~/types/invoice";
// Types are now imported from ~/types/invoice
/**
* Calculate the effective status of an invoice including overdue computation
*/
export function getEffectiveInvoiceStatus( export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
): EffectiveInvoiceStatus { ): EffectiveInvoiceStatus {
// If already paid, status is always paid regardless of due date return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
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;
} }
/**
* Check if an invoice is overdue
*/
export function isInvoiceOverdue( export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
): boolean { ): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue"; return isSharedInvoiceOverdue(storedStatus, dueDate);
} }
/**
* Get days past due (returns 0 if not overdue)
*/
export function getDaysPastDue( export function getDaysPastDue(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
): number { ): number {
if (!isInvoiceOverdue(storedStatus, dueDate)) { return getSharedDaysPastDue(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);
} }
/**
* Status configuration for UI display
*/
export const statusConfig = { export const statusConfig = {
draft: { draft: {
label: "Draft", label: "Draft",
@@ -96,42 +54,22 @@ export const statusConfig = {
}, },
} as const; } as const;
/**
* Get status configuration for display
*/
export function getStatusConfig( export function getStatusConfig(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
) { ) {
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate); return statusConfig[getEffectiveInvoiceStatus(storedStatus, dueDate)];
return statusConfig[effectiveStatus];
} }
/**
* Get valid status transitions from current stored status
*/
export function getValidStatusTransitions( export function getValidStatusTransitions(
currentStatus: StoredInvoiceStatus, currentStatus: StoredInvoiceStatus,
): StoredInvoiceStatus[] { ): StoredInvoiceStatus[] {
switch (currentStatus) { return getSharedValidStatusTransitions(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 [];
}
} }
/**
* Check if a status transition is valid
*/
export function isValidStatusTransition( export function isValidStatusTransition(
from: StoredInvoiceStatus, from: StoredInvoiceStatus,
to: StoredInvoiceStatus, to: StoredInvoiceStatus,
): boolean { ): boolean {
const validTransitions = getValidStatusTransitions(from); return isSharedValidStatusTransition(from, to);
return validTransitions.includes(to);
} }
+4 -63
View File
@@ -1,63 +1,4 @@
export type ReceiptParseResult = { export {
amount: number | null; parseReceiptText,
date: Date | null; type ReceiptParseResult,
vendor: string | null; } from "@beenvoice/domain/receipt-parse";
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,
};
}
+8 -15
View File
@@ -1,5 +1,11 @@
/** Stored on entries clocked in before empty descriptions were allowed. */ import { LEGACY_DEFAULT_CLOCK_DESCRIPTION } from "@beenvoice/domain/time-clock";
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services"; 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 { export function normalizeOptionalId(value?: string | null): string | null {
const trimmed = value?.trim(); const trimmed = value?.trim();
@@ -44,24 +50,11 @@ export function resolveBillingDescription(description?: string | null): string {
return trimmed; return trimmed;
} }
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"
| "saved_no_client"
| "zero_hours";
export function computeTrackedHours(startedAt: Date, endedAt: Date): number { export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000); const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25); 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: { export function describeClockOutOutcome(input: {
outcome: ClockOutOutcome; outcome: ClockOutOutcome;
hours: number; hours: number;
+3329
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
{
"name": "beenvoice",
"private": true,
"packageManager": "bun@1.3.14",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"typecheck": "turbo typecheck",
"lint": "turbo lint",
"test": "turbo test",
"check": "turbo typecheck lint test"
},
"devDependencies": {
"turbo": "2.10.10",
"typescript": "5.9.3"
},
"overrides": {
"@tiptap/core": "3.22.4",
"@tiptap/extension-blockquote": "3.22.4",
"@tiptap/extension-bold": "3.22.4",
"@tiptap/extension-bubble-menu": "3.22.4",
"@tiptap/extension-bullet-list": "3.22.4",
"@tiptap/extension-code": "3.22.4",
"@tiptap/extension-code-block": "3.22.4",
"@tiptap/extension-color": "3.22.4",
"@tiptap/extension-document": "3.22.4",
"@tiptap/extension-dropcursor": "3.22.4",
"@tiptap/extension-floating-menu": "3.22.4",
"@tiptap/extension-gapcursor": "3.22.4",
"@tiptap/extension-hard-break": "3.22.4",
"@tiptap/extension-heading": "3.22.4",
"@tiptap/extension-horizontal-rule": "3.22.4",
"@tiptap/extension-italic": "3.22.4",
"@tiptap/extension-link": "3.22.4",
"@tiptap/extension-list": "3.22.4",
"@tiptap/extension-list-item": "3.22.4",
"@tiptap/extension-list-keymap": "3.22.4",
"@tiptap/extension-ordered-list": "3.22.4",
"@tiptap/extension-paragraph": "3.22.4",
"@tiptap/extension-strike": "3.22.4",
"@tiptap/extension-text": "3.22.4",
"@tiptap/extension-text-align": "3.22.4",
"@tiptap/extension-text-style": "3.22.4",
"@tiptap/extension-underline": "3.22.4",
"@tiptap/extensions": "3.22.4",
"@tiptap/pm": "3.22.4",
"@tiptap/react": "3.22.4",
"@tiptap/starter-kit": "3.22.4",
"@typescript-eslint/eslint-plugin": "8.60.1",
"@typescript-eslint/parser": "8.60.1",
"@typescript-eslint/project-service": "8.60.1",
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/tsconfig-utils": "8.60.1",
"@typescript-eslint/type-utils": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/utils": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"typescript-eslint": "8.60.1"
}
}
+12
View File
@@ -0,0 +1,12 @@
# @beenvoice/domain
Platform-neutral Beenvoice rules shared by the web and mobile applications.
This package may use TypeScript and Web-standard APIs available in all target runtimes. It must not import Next.js, React DOM, React Native, database code, environment configuration, filesystem APIs, or native storage.
Current responsibilities:
- Expense category vocabulary
- Invoice status calculation and transitions
- Receipt-text parsing
- Time-clock display primitives
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@beenvoice/domain",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./expense-categories": "./src/expense-categories.ts",
"./invoice-status": "./src/invoice-status.ts",
"./receipt-parse": "./src/receipt-parse.ts",
"./time-clock": "./src/time-clock.ts"
},
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "1.3.14",
"typescript": "5.9.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];
+4
View File
@@ -0,0 +1,4 @@
export * from "./expense-categories";
export * from "./invoice-status";
export * from "./receipt-parse";
export * from "./time-clock";
+54
View File
@@ -0,0 +1,54 @@
export type StoredInvoiceStatus = "draft" | "sent" | "paid";
export type EffectiveInvoiceStatus = StoredInvoiceStatus | "overdue";
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): EffectiveInvoiceStatus {
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
const today = new Date();
const due = new Date(dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return due < today ? "overdue" : "sent";
}
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "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);
return Math.max(0, Math.ceil((today.getTime() - due.getTime()) / 86_400_000));
}
export function getValidStatusTransitions(
currentStatus: StoredInvoiceStatus,
): StoredInvoiceStatus[] {
switch (currentStatus) {
case "draft":
return ["sent", "paid"];
case "sent":
return ["paid", "draft"];
case "paid":
return ["sent"];
}
}
export function isValidStatusTransition(
from: StoredInvoiceStatus,
to: StoredInvoiceStatus,
): boolean {
return getValidStatusTransitions(from).includes(to);
}
+93
View File
@@ -0,0 +1,93 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
subtotal: number | null;
tax: number | null;
vendor: string | null;
items: ReceiptLineItem[];
rawLines: string[];
};
export type ReceiptLineItem = {
id: string;
name: string;
amount: number;
rawLine: string;
};
const AMOUNT_PATTERNS = [
/^\s*(?:grand total|total|amount due|balance due)[:\s]*\$?\s*([\d,]+\.\d{2})/im,
/\$\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})/,
];
const SUBTOTAL_PATTERNS = [/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i];
const TAX_PATTERNS = [/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i];
const NON_ITEM_LINE =
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
function parseFirstMatchingAmount(text: string, patterns: RegExp[]): number | null {
for (const pattern of 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;
}
return null;
}
function parseAmount(text: string): number | null {
const labeledAmount = parseFirstMatchingAmount(text, AMOUNT_PATTERNS);
if (labeledAmount != null && labeledAmount > 0) return labeledAmount;
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((match) => Number(match[1]!.replace(/,/g, "")))
.filter((amount) => Number.isFinite(amount) && amount > 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 parseLineItems(lines: string[]): ReceiptLineItem[] {
const items: ReceiptLineItem[] = [];
for (const [index, rawLine] of lines.entries()) {
const line = rawLine.replace(/\s+/g, " ").trim();
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
if (!match?.[1] || !match[2]) continue;
const amount = Number(match[2].replace(/,/g, ""));
const name = match[1].replace(/^\d+\s*[xX]\s+/, "").replace(/\s+\d+\s*[xX]\s*$/, "").trim();
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
items.push({
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
name: name.slice(0, 80),
amount,
rawLine,
});
}
return items.slice(0, 30);
}
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),
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
vendor: rawLines.find((line) => line.length >= 3)?.slice(0, 120) ?? null,
items: parseLineItems(rawLines),
rawLines,
};
}
+21
View File
@@ -0,0 +1,21 @@
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"
| "saved_no_client"
| "zero_hours";
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
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((value) => String(value).padStart(2, "0")).join(":");
}
export function formatElapsedHoursMinutes(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}
+35
View File
@@ -0,0 +1,35 @@
/// <reference types="bun" />
import { describe, expect, test } from "bun:test";
import {
EXPENSE_CATEGORIES,
formatElapsedSeconds,
getEffectiveInvoiceStatus,
parseReceiptText,
} from "../src";
describe("shared domain behavior", () => {
test("exposes the expense category vocabulary", () => {
expect(EXPENSE_CATEGORIES).toContain("Professional Services");
});
test("calculates overdue status at day precision", () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
expect(getEffectiveInvoiceStatus("sent", yesterday)).toBe("overdue");
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid");
});
test("formats elapsed time", () => {
expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
});
test("extracts receipt totals and line items", () => {
const receipt = parseReceiptText(
"Corner Store\nCoffee 3.50\nSubtotal 3.50\nTax 0.31\nTotal 3.81",
);
expect(receipt.amount).toBe(3.81);
expect(receipt.tax).toBe(0.31);
expect(receipt.items[0]?.name).toBe("Coffee");
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
@@ -11,10 +11,10 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
if [[ -f .env ]]; then if [[ -f apps/web/.env ]]; then
set -a set -a
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source .env source apps/web/.env
set +a set +a
fi fi
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"@beenvoice/domain#build": {
"outputs": []
},
"@beenvoice/mobile#build": {
"outputs": []
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": []
},
"lint": {
"dependsOn": ["^lint"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": []
}
}
}