diff --git a/.claude/launch.json b/.claude/launch.json index 80eeee4..397d2fa 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -3,8 +3,8 @@ "configurations": [ { "name": "dev", - "runtimeExecutable": "pnpm", - "runtimeArgs": ["dev"], + "runtimeExecutable": "bun", + "runtimeArgs": ["run", "dev"], "port": 3000 } ] diff --git a/.cta.json b/.cta.json index f31929b..d8e3b4b 100644 --- a/.cta.json +++ b/.cta.json @@ -3,7 +3,7 @@ "mode": "file-router", "typescript": true, "tailwind": true, - "packageManager": "pnpm", + "packageManager": "bun", "git": false, "install": true, "intent": false, diff --git a/.dockerignore b/.dockerignore index 06c92a8..d9ea975 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ data design .env .git -packages +packages/*/* +!packages/*/package.json diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c3a05af..1ecb4f1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: true contact_links: - name: Security issue - url: https://github.com/lobbystack/openprofit/blob/main/CONTRIBUTING.md#report-a-security-issue + url: https://github.com/morencyr/openprofit/blob/main/CONTRIBUTING.md#report-a-security-issue about: Email hello@openprofit.dev. Don't report security problems in a public issue. - name: Setup questions url: https://openprofit.dev/docs diff --git a/.github/ISSUE_TEMPLATE/connector.yml b/.github/ISSUE_TEMPLATE/connector.yml index c012826..2db813c 100644 --- a/.github/ISSUE_TEMPLATE/connector.yml +++ b/.github/ISSUE_TEMPLATE/connector.yml @@ -4,7 +4,7 @@ title: "Connector: " body: - type: markdown attributes: - value: A connector needs an API that returns billing or usage data with a key. Links to the provider's own docs help most. To build it yourself, see [CONTRIBUTING.md](https://github.com/lobbystack/openprofit/blob/main/CONTRIBUTING.md#build-a-connector). + value: A connector needs an API that returns billing or usage data with a key. Links to the provider's own docs help most. To build it yourself, see [CONTRIBUTING.md](https://github.com/morencyr/openprofit/blob/main/CONTRIBUTING.md#build-a-connector). - type: input id: provider attributes: diff --git a/.github/social-preview.png b/.github/social-preview.png new file mode 100644 index 0000000..1b2752e Binary files /dev/null and b/.github/social-preview.png differ diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 59ae4b8..98f848e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,22 +1,20 @@ name: Check +# Pushes to main run this through docker.yml, which deploys only when it passes. on: - push: - branches: [main] pull_request: + workflow_call: jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: oven-sh/setup-bun@v2 with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm check - - run: npx tsc --noEmit - - run: pnpm build - - run: pnpm --dir packages/cli build && pnpm --dir packages/cli test + bun-version-file: package.json + - run: bun install --frozen-lockfile + - run: bun run check + - run: bunx --bun tsc --noEmit + - run: bun run build + - run: cd packages/cli && bun run build && bun run test diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7ae35c0..a957fee 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,7 +6,11 @@ on: tags: ["v*"] jobs: + check: + uses: ./.github/workflows/check.yml + build: + needs: check runs-on: ubuntu-latest permissions: contents: read @@ -35,3 +39,15 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + # openprofit.dev runs this image on Dokploy. Its deploy webhook pulls :latest. + deploy: + needs: build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - env: + DOKPLOY_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }} + run: | + if [ -z "$DOKPLOY_DEPLOY_WEBHOOK" ]; then echo "DOKPLOY_DEPLOY_WEBHOOK is not set; skipping"; exit 0; fi + curl -fsS -X POST "$DOKPLOY_DEPLOY_WEBHOOK" diff --git a/CLAUDE.md b/CLAUDE.md index 232aaf3..edb9526 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,38 +2,46 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -OpenProfit: open-source finance for developers. Pulls revenue (Stripe, Polar, Paddle, Lemon Squeezy, RevenueCat) and costs (OpenAI, Anthropic, Vercel, Cloudflare, Railway and more) from provider APIs, assigns them to products, shows profit per product, and turns the same lines into books and tax-form numbers. One TanStack Start app serves the landing page, docs, blog, marketing content and the app. Hosted at openprofit.dev (Railway), self-hostable as one Docker image. Repo: github.com/lobbystack/openprofit. Operator: Lobbystack Inc. +OpenProfit: open-source finance for developers. Pulls revenue (Stripe, Polar, Paddle, Lemon Squeezy, RevenueCat) and costs (OpenAI, Anthropic, Vercel, Cloudflare, Railway and more) from provider APIs, assigns them to products, shows profit per product, and turns the same lines into books and tax-form numbers. One TanStack Start app serves the landing page, docs, blog, marketing content and the app. Hosted at openprofit.dev (Dokploy on an OVH server, behind Cloudflare), self-hostable as one Docker image. Repo: github.com/morencyr/openprofit. Operator: Lobbystack Inc. ## Commands ```bash -pnpm dev # http://localhost:3000 -pnpm check # Biome lint + format check (CI runs this) -npx biome check --write src # apply formatting; Biome rewrites files to tabs/double quotes -npx tsc --noEmit # typecheck (CI runs this) -pnpm build # production build into .output (CI runs this) -pnpm db:generate # write a migration after editing src/db/schema.ts -pnpm db:seed # demo workspace; `pnpm db:seed --owner=you@example.com` attaches it to a user +bun run dev # http://localhost:3000 +bun run check # Biome lint + format check (CI runs this) +bunx biome check --write src # apply formatting; Biome rewrites files to tabs/double quotes +bunx --bun tsc --noEmit # typecheck (CI runs this) +bun run build # production build into .output (CI runs this) +bun run db:generate # write a migration after editing src/db/schema.ts +bun run db:seed # demo workspace; `bun run db:seed --owner=you@example.com` attaches it to a user ``` -There is no test suite. Verify changes in the browser and with a production build when touching bundling, the database or `src/start.ts`. Books has assert scripts: `DATABASE_URL=/tmp/op-check SYNC_SCHEDULER=off npx tsx scripts/check-books-tax.ts` (also `check-books`, `check-books-close`, `check-books-export`). +Bun is the package manager and the runtime everywhere: -`biome.json` excludes every path under `.claude`, so `pnpm check` inside a worktree in `.claude/worktrees/` checks nothing. Run Biome on each changed file through stdin there: `npx biome check --write --stdin-file-path= < `. +- Vite runs under `bun --bun`, so dev and the prerender run in Bun. +- Scripts run as TypeScript with `bun scripts/seed.ts`. Bun loads `.env` itself. +- Nitro builds with its `bun` preset. Production runs `bun .output/server/index.mjs`. +- `bun run build` runs the app build. `bun build` is Bun's own bundler. +- `postinstall` points `node_modules/.bin/intent` at `@tanstack/intent`. `@tanstack/devtools-vite` ships a broken `intent` bin, and Bun links that one otherwise. + +There is no test suite. Verify changes in the browser and with a production build when touching bundling, the database or `src/start.ts`. Books has assert scripts: `DATABASE_URL=/tmp/op-check SYNC_SCHEDULER=off bun scripts/check-books-tax.ts` (also `check-books`, `check-books-close`, `check-books-export`). + +`biome.json` excludes every path under `.claude`, so `bun run check` inside a worktree in `.claude/worktrees/` checks nothing. Run Biome on each changed file through stdin there: `bunx biome check --write --stdin-file-path= < `. Production-build smoke test with a throwaway embedded DB: ```bash -PORT=3200 SYNC_SCHEDULER=off DATABASE_URL=/tmp/op-test node .output/server/index.mjs +PORT=3200 SYNC_SCHEDULER=off DATABASE_URL=/tmp/op-test bun .output/server/index.mjs ``` -Local sign-in without email: request a link on /login (or POST /api/auth/sign-in/magic-link) and read the URL from the dev server log. Same on Railway via `railway logs | grep magic-link`. +Local sign-in without email: request a link on /login (or POST /api/auth/sign-in/magic-link) and read the URL from the dev server log. In production, search the Dokploy app's logs for `magic-link`. ## Library docs before code Don't write framework code from memory. TanStack ships versioned agent skills inside its packages, and a project hook (`.claude/settings.json`, `.intent/hooks/`) lists them at session start and blocks edits until the guidance is checked. -- TanStack Start and Router: load the matching skill first (`pnpm exec intent list`, then `pnpm exec intent load #`). `package.json#intent.skills` lists the allowed skill sources; add a TanStack package there when the app starts using it. -- Anything the skills don't cover: read the docs for the installed version, e.g. `pnpm dlx @tanstack/cli search-docs "" --library start --framework react` and `pnpm dlx @tanstack/cli doc `. When docs and the installed source disagree, the source in `node_modules` wins. +- TanStack Start and Router: load the matching skill first (`bunx @tanstack/intent list`, then `bunx @tanstack/intent load #`). `package.json#intent.skills` lists the allowed skill sources; add a TanStack package there when the app starts using it. +- Anything the skills don't cover: read the docs for the installed version, e.g. `bunx @tanstack/cli search-docs "" --library start --framework react` and `bunx @tanstack/cli doc `. When docs and the installed source disagree, the source in `node_modules` wins. - Other libraries (Better Auth, Drizzle, PostHog, provider APIs): read their current docs before using an API, and cite the page in a short comment where the behaviour isn't obvious. @@ -41,8 +49,8 @@ Don't write framework code from memory. TanStack ships versioned agent skills in Use the repository’s installed Intent. If it is unavailable, report the missing dependency instead of downloading a replacement. Before editing files for a substantial task: -- Run `pnpm exec intent list` from the workspace root to see available local skills. -- If a listed skill matches the task, run `pnpm exec intent load #` before changing files. +- Run `bunx @tanstack/intent list` from the workspace root to see available local skills. +- If a listed skill matches the task, run `bunx @tanstack/intent load #` before changing files. - Use the loaded `SKILL.md` guidance while making the change. - Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed. - Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns. @@ -50,9 +58,9 @@ Before editing files for a substantial task: ## Database -- Postgres everywhere through Drizzle (`src/db/schema.ts`, `src/db/auth-schema.ts`). `DATABASE_URL` starting with `postgres://` uses `pg`; anything else is a directory for **PGlite** (embedded Postgres, default `./data/openprofit`). Hosted uses Railway Postgres. +- Postgres everywhere through Drizzle (`src/db/schema.ts`, `src/db/auth-schema.ts`). `DATABASE_URL` starting with `postgres://` uses `pg`; anything else is a directory for **PGlite** (embedded Postgres, default `./data/openprofit`). Hosted uses a Postgres 18 service on Dokploy. - Migrations in `./drizzle` run at import time of `src/db/index.ts` (skip with `SKIP_MIGRATIONS=1`). Don't use `drizzle-kit push`; PGlite rejects its multi-statement queries. -- **PGlite allows one process per data directory.** Running a script (`db:seed`, a tsx one-off) while `pnpm dev` is up hangs or fails. Killing a process mid-write (`kill -9`) can corrupt the directory. Stop dev cleanly first. One-off scripts must end with `process.exit(0)` or PGlite keeps them alive. SIGTERM and SIGINT close the database and exit (`src/db/index.ts`); without that, PGlite's timer keeps the process alive until Docker kills it. +- **PGlite allows one process per data directory.** Running a script (`db:seed`, a one-off script) while `bun run dev` is up hangs or fails. Killing a process mid-write (`kill -9`) can corrupt the directory. Stop dev cleanly first. One-off scripts must end with `process.exit(0)` or PGlite keeps them alive. SIGTERM and SIGINT close the database and exit (`src/db/index.ts`); without that, PGlite's timer keeps the process alive until Docker kills it. - PGlite is excluded from Vite `optimizeDeps` and externalized from the Nitro server bundle in `vite.config.ts`; bundling it drops its wasm/data files and the server crashes on start. Keep both. `@resvg/resvg-wasm` (social images, `src/server/images.server.ts`) is externalized for the same reason. - Money is integer cents. Each line stores the source amount and a `*_base_cents` amount in the workspace's base currency (Frankfurter/ECB daily rates, cached in `fx_rates`; conversions into CAD use the Bank of Canada's rates, as the CRA asks, in `src/server/fx.server.ts`). Changing the base currency reconverts every line (`settings.functions.ts`). - Every table carries `workspace_id`. @@ -67,10 +75,10 @@ Before editing files for a substantial task: - **Overview** (`src/server/overview.server.ts`): grouped monthly sums over 24 months; tiles compare the selected period with the equal period before, the chart's dotted line is the same months a year earlier. Period comes from the `?period=` search param. - **Books** (spec: `docs/BOOKS.md`; user docs: `src/docs/books.md`). `src/lib/books.ts` is the pure journal engine: accounts, who paid (personal card or company, before or after `incorporated_on`), yearly plans spread over their service period, and adjustments for closed months. `src/server/books.server.ts` loads a month's lines; a whole-workspace export stores it in `journal_exports` and closes the month, and later changes go out as adjustments in the next export. `src/lib/tax.ts` maps the year's totals to T2125, TP-80, GIFI, Schedule C and Form 1120 lines; every mapping cites its official source in a comment, and a line no source names is a judgment call marked `ours`. Changing a tax line means re-reading the source and updating `docs/BOOKS.md`, `src/docs/books.md` and `src/content/blog/tax-lines-for-software-costs.md`. QuickBooks, Xero and plain writers are in `books-export.server.ts`, downloaded from `/api/journal.csv`. - **Cloud mode** (`APP_MODE=cloud`): plans and limits in `src/lib/plans.ts`, Polar checkout/portal and webhook in `src/server/billing.server.ts` and `src/routes/api/polar/webhook.ts` (Standard Webhooks signature, `whsec_` secrets are base64). Inert in self-host. -- **Public demo** (`/demo`, `src/server/demo.server.ts`): the app's pages over one `demo = true` workspace with no members, rebuilt hourly by the seed generator (`src/server/seed.server.ts`, also behind `pnpm db:seed`). Read server functions take `{ demo: true }`; `currentWorkspace()` never returns the demo, so mutations can't reach it. On with `APP_MODE=cloud` or `DEMO=on`. +- **Public demo** (`/demo`, `src/server/demo.server.ts`): the app's pages over one `demo = true` workspace with no members, rebuilt hourly by the seed generator (`src/server/seed.server.ts`, also behind `bun run db:seed`). Read server functions take `{ demo: true }`; `currentWorkspace()` never returns the demo, so mutations can't reach it. On with `APP_MODE=cloud` or `DEMO=on`. - **Edge middleware** in `src/server/edge.server.ts` (a Nitro plugin that puts itself ahead of Nitro's static files): www to apex redirect, markdown responses for `Accept: text/markdown` (sources mapped in `src/server/markdown.server.ts`; add new public pages there), RFC 8288 `Link` headers, and serving the prerendered pages. It must stay free of database imports, so pages with live data (`/open`, `/p/...`) answer markdown from their own route `GET` handler and call `next()` for HTML. -- **Request middleware** in `src/start.ts` (`createMiddleware({ type: "request" })`): tracing, gzip of server-rendered HTML/XML/JSON, and `Cache-Control: no-store` on /app, /onboarding and server function responses. Redirect responses must pass through unchanged: Start serializes server function redirects after the middleware. Start's CSRF check for server functions is added there explicitly, because a custom `createStart` drops the default. Request middleware runs under `pnpm dev` too. -- **Prerendering.** `pnpm build` prerenders the pages that have markdown (landing, docs, integrations, comparisons, changelog, legal) with TanStack Start's `prerender` option; the `PRERENDER` pattern in `vite.config.ts` must match `markdownFor`. Nitro builds its static file list before those files exist, so the edge middleware serves them (brotli or gzip, compressed once per process). Prerendered HTML can't hold per-request data: the root route has no loader, and analytics settings are fetched after hydration. Server routes import the database inside their handlers so the prerender never opens it. +- **Request middleware** in `src/start.ts` (`createMiddleware({ type: "request" })`): tracing, gzip of server-rendered HTML/XML/JSON, and `Cache-Control: no-store` on /app, /onboarding and server function responses. Redirect responses must pass through unchanged: Start serializes server function redirects after the middleware. Start's CSRF check for server functions is added there explicitly, because a custom `createStart` drops the default. Request middleware runs under `bun run dev` too. +- **Prerendering.** `bun run build` prerenders the pages that have markdown (landing, docs, integrations, comparisons, changelog, legal) with TanStack Start's `prerender` option; the `PRERENDER` pattern in `vite.config.ts` must match `markdownFor`. Nitro builds its static file list before those files exist, so the edge middleware serves them (brotli or gzip, compressed once per process). Prerendered HTML can't hold per-request data: the root route has no loader, and analytics settings are fetched after hydration. Server routes import the database inside their handlers so the prerender never opens it. - **Auth:** Better Auth with magic link plus GitHub/Google when their env vars are set (`src/lib/auth.ts`). Emails go through Resend when `RESEND_API_KEY` is set, otherwise they print to the server log. ## Content and SEO @@ -84,7 +92,7 @@ Before editing files for a substantial task: - Tokens in `src/styles.css`: paper/ink with neutrals mixed from them, light and dark (`.dark` class, toggled in `src/lib/theme.ts`). Change the system tokens, not individual components. - One typeface: Geist. `--font-mono` points at Geist; numbers use the `num` utility (tabular figures). No serif or monospaced faces. - Interaction rules live in `src/styles.css`, not components: press feedback on every button, visible keyboard focus, `display` utility for type 36px and up (tighter tracking), `chrome` for translucent sticky bars, `pressable` for links styled as buttons, `rise` for the one-time landing entrance, and `--ease-out` / `--ease-in-out` curves (UI motion stays under 300 ms; frequent actions like switching metric tiles don't animate beyond the chart), and reduced-motion, reduced-transparency and high-contrast overrides. -- UI components are shadcn on Base UI (`components.json`: style `base-nova`), with their source in `src/components/ui/`. Each file is tuned to the tokens (no shadows, the app's heights and type sizes), so pages use variants such as `