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",
"runtimeExecutable": "bash",
"runtimeArgs": ["-c", "cd beenvoice-web && PORT=3010 bun run dev"],
"runtimeArgs": ["-c", "cd apps/web && PORT=3010 bun run dev"],
"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.
```
beenvoice-meta/
├── beenvoice-web/ # Web API + dashboard (Next.js 16, tRPC, PostgreSQL)
├── beenvoice-app/ # Mobile app (Expo, React Native, dev client)
── beenvoice.icon/ # iOS app icon asset (icon composer)
## Workspace map
```text
beenvoice/
── 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 |
|------|--------|
| 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)
## Quick start
```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 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)
From `beenvoice-web` with `.env` configured (see [beenvoice-web/README.md](./beenvoice-web/README.md#docker-deployment-app--database)):
Useful workspace commands:
```bash
cd beenvoice-web
git pull
./scripts/docker-deploy.sh # rebuild app image + restart (not docker-compose.dev.yml)
bun run typecheck
bun run lint
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 |
|-------|------|-----------|------|
| 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 |
## Production Docker deployment
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 |
|-----|----------|
| [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) |
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.
## Shared domain concepts
## Documentation
- **Clients** — billable contacts with optional default hourly rate
- **Businesses** — sender profiles (logo, address, Resend email config)
- **Invoices** — draft → sent → paid; line items; PDF + email; public share token
- **Time entries** — clock in/out; one running timer per user; auto-attach to open invoice
- **Recurring invoices**, **expenses**, **payments**, **templates** — full web support; mobile covers a core CRUD subset
- [Web setup](./apps/web/README.md)
- [Web architecture](./apps/web/docs/ARCHITECTURE.md)
- [Mobile setup](./apps/mobile/README.md)
- [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md)
- [Shared domain package](./packages/domain/README.md)
## Environment cheat sheet
## Product concepts
| Variable | Where | Purpose |
|----------|-------|---------|
| `DATABASE_URL` | beenvoice-web | PostgreSQL |
| `AUTH_SECRET`, `BETTER_AUTH_URL` | beenvoice-web | better-auth |
| `EXPO_PUBLIC_API_URL` | beenvoice-app | API base for mobile |
| `DISABLE_SIGNUPS` | beenvoice-web | `true` blocks new registrations |
| `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) |
- Clients with optional default hourly rates
- Businesses and sender branding
- Draft, sent, paid, and overdue invoices
- Time tracking and invoice attachment
- Recurring invoices, expenses, payments, and templates
- Public invoice links and API-key-authenticated automation
+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.
@@ -10,7 +10,8 @@ Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) b
## Conventions
- **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`
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
- **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` |
| 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
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)
## Prerequisites
- [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
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
## Setup
```bash
cd beenvoice-app
cd apps/mobile
bun install
cp .env.example .env
```
@@ -36,11 +36,11 @@ Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `been
## Run
```bash
# Terminal 1 — API
cd ../beenvoice-web && bun run dev
# Terminal 1, from the repository root — API + Metro
bun run dev
# Terminal 2 — mobile (builds native app if needed)
cd beenvoice-app && bun run ios
# Terminal 2, from the repository root — native app
bun run --filter @beenvoice/mobile ios
```
Metro uses port **8082** (avoids other Expo projects on 8081).
@@ -145,6 +145,6 @@ widgets/ # iOS Live Activity (TimeClockActivity)
## Related
- [beenvoice-web README](../beenvoice-web/README.md)
- [beenvoice-web ARCHITECTURE](../beenvoice-web/docs/ARCHITECTURE.md)
- [Workspace root README](../README.md)
- [Web README](../web/README.md)
- [Web architecture](../web/docs/ARCHITECTURE.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.
```bash
cd beenvoice-app
cd apps/mobile
cp .ios-release.env.example .ios-release.env # once — add Team ID + API key
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)
```bash
cd beenvoice-app
cd apps/mobile
# Production iOS build (auto-increments build number)
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.
@@ -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) |
| API | tRPC 11 + TanStack Query, SuperJSON |
| 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
@@ -271,6 +272,6 @@ Requires beenvoice with:
- `@better-auth/expo` in `src/lib/auth.ts`
- `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
```bash
cd beenvoice-app
cd apps/mobile
cp .ios-release.env.example .ios-release.env
```
+3 -3
View File
@@ -1,4 +1,4 @@
# beenvoice-app documentation
# Beenvoice mobile documentation
| Document | Description |
|----------|-------------|
@@ -8,5 +8,5 @@
## Related
- [beenvoice-web docs](../../beenvoice-web/docs/README.md) — server API and web app
- [Workspace README](../../README.md) — full-stack layout
- [Web docs](../../web/docs/README.md) — server API and web app
- [Workspace README](../../../README.md) — full-stack layout
+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";
+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: {
status: string;
dueDate: Date | string;
}): InvoiceStatus {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
const today = new Date();
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";
if (invoice.status === "paid" || invoice.status === "draft") {
return invoice.status;
}
return getEffectiveInvoiceStatus("sent", invoice.dueDate);
}
export const statusLabels: Record<InvoiceStatus, string> = {
+5 -128
View File
@@ -1,128 +1,5 @@
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 = [
/(?: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,
};
}
export {
parseReceiptText,
type ReceiptLineItem,
type ReceiptParseResult,
} from "@beenvoice/domain/receipt-parse";
+12 -23
View File
@@ -1,13 +1,16 @@
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"
| "saved_no_client"
| "zero_hours";
import {
DEFAULT_CLOCK_DESCRIPTION,
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
} from "@beenvoice/domain/time-clock";
import type { ClockOutOutcome } from "@beenvoice/domain/time-clock";
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export {
DEFAULT_CLOCK_DESCRIPTION,
formatElapsedHoursMinutes,
formatElapsedSeconds,
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
type ClockOutOutcome,
} from "@beenvoice/domain/time-clock";
export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim();
@@ -26,20 +29,6 @@ export function formatRunningTimerLabel(description?: string | null): string {
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(
rateText: string,
clientDefaultRate?: number | null,
+52 -42
View File
@@ -1,67 +1,77 @@
{
"name": "beenvoice-app",
"name": "@beenvoice/mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@better-auth/expo": "^1.6.19",
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/playfair-display": "^0.4.2",
"@expo/ui": "~57.0.11",
"@expo/vector-icons": "^15.1.1",
"@beenvoice/domain": "workspace:*",
"@better-auth/expo": "1.6.19",
"@expo-google-fonts/inter": "0.4.2",
"@expo-google-fonts/playfair-display": "0.4.2",
"@expo/ui": "57.0.11",
"@expo/vector-icons": "15.1.1",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "9.1.0",
"@react-native-picker/picker": "^2.11.4",
"@tanstack/react-query": "^5.101.0",
"@trpc/client": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"better-auth": "^1.6.19",
"expo": "^57.0.9",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.11",
"expo-constants": "~57.0.11",
"expo-dev-client": "~57.0.12",
"expo-file-system": "~57.0.4",
"expo-font": "~57.0.1",
"expo-image": "~57.0.3",
"expo-image-picker": "~57.0.10",
"expo-linear-gradient": "~57.0.1",
"expo-linking": "~57.0.6",
"expo-local-authentication": "~57.0.2",
"expo-mlkit-ocr": "^0.2.7",
"expo-network": "~57.0.1",
"expo-notifications": "~57.0.11",
"expo-router": "~57.0.13",
"expo-secure-store": "~57.0.1",
"expo-sharing": "~57.0.12",
"expo-splash-screen": "~57.0.6",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
"expo-web-browser": "~57.0.2",
"expo-widgets": "~57.0.10",
"@react-native-picker/picker": "2.11.4",
"@tanstack/react-query": "5.101.0",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"better-auth": "1.6.19",
"expo": "57.0.13",
"expo-blur": "57.0.2",
"expo-build-properties": "57.0.11",
"expo-constants": "57.0.11",
"expo-dev-client": "57.0.12",
"expo-file-system": "57.0.4",
"expo-font": "57.0.1",
"expo-image": "57.0.3",
"expo-image-picker": "57.0.10",
"expo-linear-gradient": "57.0.1",
"expo-linking": "57.0.6",
"expo-local-authentication": "57.0.2",
"expo-mlkit-ocr": "0.2.7",
"expo-modules-core": "57.0.11",
"expo-network": "57.0.1",
"expo-notifications": "57.0.11",
"expo-router": "57.0.13",
"expo-secure-store": "57.0.1",
"expo-sharing": "57.0.12",
"expo-splash-screen": "57.0.6",
"expo-status-bar": "57.0.1",
"expo-symbols": "57.0.2",
"expo-web-browser": "57.0.2",
"expo-widgets": "57.0.10",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-gesture-handler": "3.2.1",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.2",
"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-worklets": "0.10.1",
"superjson": "^2.2.6"
"superjson": "2.2.6"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/react": "~19.2.2",
"react-native-svg-transformer": "^1.5.3",
"typescript": "~6.0.3"
"@expo/config-plugins": "57.0.8",
"@types/bun": "1.3.14",
"@types/react": "19.2.17",
"react-native-svg-transformer": "1.5.3",
"typescript": "6.0.3",
"xcode": "3.0.1"
},
"scripts": {
"dev": "expo start --dev-client --port 8082",
"start": "expo start --dev-client --port 8082",
"android": "expo run:android --port 8082",
"ios": "expo run:ios --port 8082",
"ios:release": "bash scripts/ios-release.sh",
"ios:release:upload": "bash scripts/ios-release.sh --upload",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "bun test",
"web": "expo start --web"
},
@@ -1,18 +1,20 @@
/// <reference types="bun" />
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 { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
import { getInvoiceStatus } from "../lib/invoice-status";
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
import { EXPENSE_CATEGORIES as webExpenseCategories } from "../../beenvoice-web/src/lib/expense-categories";
import { getEffectiveInvoiceStatus } from "../../beenvoice-web/src/lib/invoice-status";
import { safeCallbackPath } from "../../beenvoice-web/src/lib/safe-callback-url";
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
import {
formatElapsedSeconds as formatWebElapsedSeconds,
normalizeOptionalId,
} from "../../beenvoice-web/src/lib/time-clock";
} from "../../web/src/lib/time-clock";
const originalFetch = globalThis.fetch;
@@ -85,7 +87,7 @@ describe("timer parity", () => {
test("elapsed time formatting is identical", () => {
for (const seconds of [0, 59, 60, 3_661, 86_399]) {
expect(formatAppElapsedSeconds(seconds)).toBe(
formatWebElapsedSeconds(seconds),
formatDomainElapsedSeconds(seconds),
);
}
});
@@ -99,6 +101,6 @@ describe("timer parity", () => {
describe("expense parity", () => {
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,
"paths": {
"@/*": ["./*"],
"~/*": ["../beenvoice-web/src/*"],
"src/*": ["../beenvoice-web/src/*"],
"beenvoice/*": ["../beenvoice-web/src/*"]
"~/*": ["../web/src/*"],
"src/*": ["../web/src/*"],
"beenvoice/*": ["../web/src/*"]
}
},
"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):
+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
+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"
+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";
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
}
// 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(
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;
return getSharedDaysPastDue(storedStatus, dueDate);
}
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 = {
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;
+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")/.."
if [[ -f .env ]]; then
if [[ -f apps/web/.env ]]; then
set -a
# shellcheck disable=SC1091
source .env
source apps/web/.env
set +a
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": []
}
}
}