Convert Beenvoice to a Turborepo monorepo
This commit is contained in:
+1
-1
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
# =============================================================================
|
||||
# beenvoice-web — environment template
|
||||
# Beenvoice web workspace — environment template
|
||||
# =============================================================================
|
||||
#
|
||||
# Quick start (local dev):
|
||||
|
||||
+2
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -1,12 +1,11 @@
|
||||

|
||||
|
||||
# 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
File diff suppressed because it is too large
Load Diff
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,78 +1,36 @@
|
||||
import {
|
||||
getDaysPastDue as getSharedDaysPastDue,
|
||||
getEffectiveInvoiceStatus as getSharedEffectiveInvoiceStatus,
|
||||
getValidStatusTransitions as getSharedValidStatusTransitions,
|
||||
isInvoiceOverdue as isSharedInvoiceOverdue,
|
||||
isValidStatusTransition as isSharedValidStatusTransition,
|
||||
} from "@beenvoice/domain/invoice-status";
|
||||
import type {
|
||||
StoredInvoiceStatus,
|
||||
EffectiveInvoiceStatus,
|
||||
StoredInvoiceStatus,
|
||||
} from "~/types/invoice";
|
||||
|
||||
// Types are now imported from ~/types/invoice
|
||||
|
||||
/**
|
||||
* Calculate the effective status of an invoice including overdue computation
|
||||
*/
|
||||
export function getEffectiveInvoiceStatus(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): EffectiveInvoiceStatus {
|
||||
// If already paid, status is always paid regardless of due date
|
||||
if (storedStatus === "paid") {
|
||||
return "paid";
|
||||
}
|
||||
|
||||
// If draft, status is always draft
|
||||
if (storedStatus === "draft") {
|
||||
return "draft";
|
||||
}
|
||||
|
||||
// For sent invoices, check if overdue
|
||||
if (storedStatus === "sent") {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
// Set both dates to start of day for accurate comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
return due < today ? "overdue" : "sent";
|
||||
}
|
||||
|
||||
return storedStatus;
|
||||
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an invoice is overdue
|
||||
*/
|
||||
export function isInvoiceOverdue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): boolean {
|
||||
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
|
||||
return isSharedInvoiceOverdue(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days past due (returns 0 if not overdue)
|
||||
*/
|
||||
export function getDaysPastDue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): number {
|
||||
if (!isInvoiceOverdue(storedStatus, dueDate)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = today.getTime() - due.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return Math.max(0, diffDays);
|
||||
return getSharedDaysPastDue(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status configuration for UI display
|
||||
*/
|
||||
export const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
@@ -96,42 +54,22 @@ export const statusConfig = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get status configuration for display
|
||||
*/
|
||||
export function getStatusConfig(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
) {
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
return statusConfig[effectiveStatus];
|
||||
return statusConfig[getEffectiveInvoiceStatus(storedStatus, dueDate)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid status transitions from current stored status
|
||||
*/
|
||||
export function getValidStatusTransitions(
|
||||
currentStatus: StoredInvoiceStatus,
|
||||
): StoredInvoiceStatus[] {
|
||||
switch (currentStatus) {
|
||||
case "draft":
|
||||
return ["sent", "paid"]; // Can send or mark paid directly
|
||||
case "sent":
|
||||
return ["paid", "draft"]; // Can mark paid or revert to draft
|
||||
case "paid":
|
||||
return ["sent"]; // Can revert to sent if needed (rare cases)
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
return getSharedValidStatusTransitions(currentStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid
|
||||
*/
|
||||
export function isValidStatusTransition(
|
||||
from: StoredInvoiceStatus,
|
||||
to: StoredInvoiceStatus,
|
||||
): boolean {
|
||||
const validTransitions = getValidStatusTransitions(from);
|
||||
return validTransitions.includes(to);
|
||||
return isSharedValidStatusTransition(from, to);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./expense-categories";
|
||||
export * from "./invoice-status";
|
||||
export * from "./receipt-parse";
|
||||
export * from "./time-clock";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
+31
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user