diff --git a/.envrc.example b/.envrc.example index 5e5bf1a..41cf2d4 100644 --- a/.envrc.example +++ b/.envrc.example @@ -1 +1,2 @@ -export SUPABASE_PROJECT_REF=your-project-ref-here +export DATABASE_URL=postgres://splitcount:password@localhost:5432/splitcount +export PORT=3000 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8700f45..aea2e69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,44 +1,44 @@ -name: Deploy to Supabase +name: Build and Push Docker Image on: push: branches: - main +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: - deploy: + build-and-push: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 - - uses: supabase/setup-cli@v1 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 with: - version: latest - - - name: Link project - run: supabase link --project-ref "$SUPABASE_PROJECT_REF" - env: - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }} - - - name: Push database migrations - run: supabase db push --password "$SUPABASE_DB_PASSWORD" - env: - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }} - SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Reload PostgREST schema cache - run: | - supabase db execute --sql "SELECT pg_notify('pgrst', 'reload schema');" 2>/dev/null \ - || echo "Schema cache reload skipped — run manually if needed." - env: - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix= + type=raw,value=latest,enable={{is_default_branch}} - - name: Deploy mcp edge function - run: supabase functions deploy mcp --no-verify-jwt - env: - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }} + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..eb27b27 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run check + - run: bun run typecheck diff --git a/.gitignore b/.gitignore index 0191190..636f882 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ .env.* !.env.example .envrc -supabase/.temp/ node_modules/ +dist/ .DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de70125 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM oven/bun:1-alpine +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile --production +COPY src/ ./src +COPY migrations/ ./migrations +ENV PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "src/index.ts"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..002ea5e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dennis Falling + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 725a3ea..8db2f25 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,22 @@ -.PHONY: deploy +.PHONY: install dev start typecheck docker-build docker-up docker-down -# Deploy migrations and edge function. -# Requires SUPABASE_PROJECT_REF to be set (via .envrc + direnv, or exported manually). -deploy: - ./scripts/deploy.sh +install: + bun install + +dev: + bun run --watch src/index.ts + +start: + bun run src/index.ts + +typecheck: + bun run tsc --noEmit + +docker-build: + docker build -t splitcount . + +docker-up: + docker compose up --build -d + +docker-down: + docker compose down diff --git a/README.md b/README.md index 907e1bd..a75d9a8 100644 --- a/README.md +++ b/README.md @@ -4,44 +4,64 @@ Expense splitting via MCP + Claude. Log expenses (including receipt photos), tra ## Architecture -- **MCP server**: Supabase Edge Function (Deno) at `supabase/functions/mcp/` -- **Database**: Supabase PostgreSQL (groups, members, expenses, splits, settlements) +- **MCP server**: Bun HTTP server at `src/` +- **Database**: PostgreSQL (self-hosted or any provider) - **Client**: Any MCP-compatible client (Claude Desktop, Claude.ai, etc.) -## Setup +Migrations run automatically on startup. No migration tooling required. -### 1. Create a Supabase project +## Self-hosting with Docker -Go to [supabase.com](https://supabase.com), create a new project, and note your project ref (visible in the project URL or Settings → General). +The easiest way to run SplitCount is with Docker Compose. Add it to your existing stack: -### 2. Set your project ref - -Add to `.envrc` (used by [direnv](https://direnv.net/)): -```bash -export SUPABASE_PROJECT_REF= +```yaml +services: + splitcount: + image: ghcr.io/dfalling/splitcount:latest + environment: + DATABASE_URL: postgres://user:password@your-postgres-host:5432/splitcount + ports: + - "3000:3000" + restart: unless-stopped ``` -Or export it manually: +Or run the full stack including Postgres: + ```bash -export SUPABASE_PROJECT_REF= +docker compose up -d ``` -### 3. Deploy +The server will be available at `http://localhost:3000`. -```bash -make deploy -``` +### Connect to Claude -This links to your project, pushes database migrations, and deploys the MCP edge function. Your server URL will be printed at the end: +In Claude Desktop or Claude.ai settings, add a custom MCP connector: ``` -https://.supabase.co/functions/v1/mcp +http://your-server:3000 ``` -### 4. Add as a custom connector +## Local development + +**Prerequisites**: [mise](https://mise.jdx.dev/) for version management. + +```bash +mise install # installs bun +bun install # install dependencies +cp .envrc.example .envrc +# edit .envrc with your DATABASE_URL +bun run dev # start with hot reload +``` -In Claude Desktop or Claude.ai settings, add a custom MCP connector with your server URL: +Test the server: +```bash +curl -X POST http://localhost:3000 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` -https://.supabase.co/functions/v1/mcp + +Health check: +```bash +curl http://localhost:3000?health ``` ## Usage @@ -49,18 +69,20 @@ https://.supabase.co/functions/v1/mcp ### Create a group > "Create a new expense group called 'Barcelona Trip' with my name as Alex" -Claude will return a **6-character join code** (e.g. `XK7M2P`) and your **member_id**. Share the join code with friends. +Claude returns a **6-character join code** (e.g. `XK7M2P`) and your **member_id**. Share the join code with friends. ### Join a group -> "Join group XK7M2P, my name is Jordan" +> "Join group XK7M2P" + +Claude will show you the existing member list so you can claim your slot or join as someone new. ### Log an expense > "Log a $45 dinner expense, I paid, split equally among everyone" -**From a receipt photo**: Share the image with Claude, then ask it to log it: +**From a receipt photo**: share the image with Claude, then ask it to log it: > "Here's my receipt [image]. Add it as an expense split equally." -Claude will read the receipt, extract the amount and description, and call `add_expense` with the details. +Claude reads the receipt, extracts the amount and description, and calls `add_expense`. ### Check balances > "Who owes what in our group?" @@ -70,9 +92,9 @@ Claude will read the receipt, extract the amount and description, and call `add_ ## Identity model -There is no login. When you create or join a group, you receive a **member_id** (UUID). This is your permanent identity — save it. Claude stores it in conversation context and will remind you to note it down. +There is no login. When you create or join a group you receive a **member_id** (UUID). This is your permanent identity — save it. Claude stores it in conversation context and will remind you to note it down. -If you switch devices or start a new conversation, use `get_member` to confirm your identity: +If you start a new conversation, use `get_member` to confirm your identity: > "My member ID is abc123... — what group am I in?" ## Tools @@ -80,27 +102,21 @@ If you switch devices or start a new conversation, use `get_member` to confirm y | Tool | Description | |------|-------------| | `create_group` | Create a group, get a join code | -| `join_group` | Join with a join code + display name | +| `join_group` | Join with a join code; shows member list to claim or create | | `get_group` | Group info and member list | | `add_expense` | Log an expense (equal/exact/percent splits) | | `list_expenses` | View expenses with split details | +| `update_expense` | Edit an expense you logged | | `delete_expense` | Soft-delete an expense you logged | | `get_balances` | Net balances + minimum payments to settle | | `record_settlement` | Record a payment between members | | `get_settlement_history` | Past settlements | +| `add_member` | Pre-add a member before they join | +| `claim_member` | Identify as an existing member | +| `list_members` | List all members in a group | | `get_member` | Look up your member info | | `rename_member` | Change your display name | -## Local development - -```bash -supabase start # starts local Postgres + Edge Functions runtime -supabase functions serve mcp --no-verify-jwt -``` +## Database migrations -Test with curl: -```bash -curl -X POST http://localhost:54321/functions/v1/mcp \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' -``` +Migrations live in `migrations/` and are applied automatically at startup in order. To add a new migration, create a file named `NNN_description.sql` (e.g. `005_add_tags.sql`). diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..96dee98 --- /dev/null +++ b/biome.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..eae8368 --- /dev/null +++ b/bun.lock @@ -0,0 +1,47 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "splitcount", + "dependencies": { + "postgres": "^3.4.5", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.10", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7f76f4a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + splitcount: + build: . + environment: + DATABASE_URL: postgres://splitcount:password@db:5432/splitcount + ports: + - "3000:3000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:17-alpine + environment: + POSTGRES_DB: splitcount + POSTGRES_USER: splitcount + POSTGRES_PASSWORD: password + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U splitcount -d splitcount"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + pgdata: diff --git a/supabase/migrations/20260315000000_initial_schema.sql b/migrations/001_initial_schema.sql similarity index 100% rename from supabase/migrations/20260315000000_initial_schema.sql rename to migrations/001_initial_schema.sql diff --git a/supabase/migrations/20260315000001_original_currency.sql b/migrations/002_original_currency.sql similarity index 100% rename from supabase/migrations/20260315000001_original_currency.sql rename to migrations/002_original_currency.sql diff --git a/supabase/migrations/20260315000002_expense_date.sql b/migrations/003_expense_date.sql similarity index 100% rename from supabase/migrations/20260315000002_expense_date.sql rename to migrations/003_expense_date.sql diff --git a/supabase/migrations/20260315000003_update_expense_with_splits.sql b/migrations/004_update_expense_with_splits.sql similarity index 100% rename from supabase/migrations/20260315000003_update_expense_with_splits.sql rename to migrations/004_update_expense_with_splits.sql diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a94d1ed --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +bun = "latest" diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f4332a --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "splitcount", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "typecheck": "tsc --noEmit", + "lint": "biome lint src/", + "format": "biome format --write src/", + "check": "biome check src/" + }, + "dependencies": { + "postgres": "^3.4.5" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.10", + "@types/bun": "latest", + "typescript": "^5" + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index ecdaebb..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Deploy SplitCount to Supabase. -# Reads SUPABASE_PROJECT_REF from the environment (set it in .envrc). -# Usage: ./scripts/deploy.sh - -if ! command -v supabase &>/dev/null; then - echo "Error: supabase CLI not found." - echo "Install it: https://supabase.com/docs/guides/cli/getting-started" - exit 1 -fi - -if [[ -z "${SUPABASE_PROJECT_REF:-}" ]]; then - echo "Error: SUPABASE_PROJECT_REF is not set." - echo "Add it to .envrc: export SUPABASE_PROJECT_REF=" - exit 1 -fi - -echo "Linking to project: $SUPABASE_PROJECT_REF" -supabase link --project-ref "$SUPABASE_PROJECT_REF" - -echo "Pushing database migrations..." -supabase db push - -echo "Reloading PostgREST schema cache..." -supabase db execute --sql "SELECT pg_notify('pgrst', 'reload schema');" 2>/dev/null \ - || echo " (schema cache reload skipped — run manually in SQL Editor if queries fail: SELECT pg_notify('pgrst', 'reload schema');)" - -echo "Deploying mcp edge function..." -supabase functions deploy mcp --no-verify-jwt - -echo "" -echo "Deploy complete." -echo "Connector URL: https://${SUPABASE_PROJECT_REF}.supabase.co/functions/v1/mcp" diff --git a/supabase/functions/mcp/balance.ts b/src/balance.ts similarity index 98% rename from supabase/functions/mcp/balance.ts rename to src/balance.ts index 6485c83..760118f 100644 --- a/supabase/functions/mcp/balance.ts +++ b/src/balance.ts @@ -12,7 +12,6 @@ export interface SettlementSuggestion { amount: number; } -/** Compute per-member net balance from raw DB rows. */ export function computeNetBalances( members: Array<{ id: string; display_name: string }>, expenses: Array<{ diff --git a/src/handlers.ts b/src/handlers.ts new file mode 100644 index 0000000..ea6984c --- /dev/null +++ b/src/handlers.ts @@ -0,0 +1,991 @@ +import type postgres from "postgres"; +import { + computeNetBalances, + equalSplits, + minimizeTransactions, +} from "./balance"; + +type Db = postgres.Sql; +// biome-ignore lint/suspicious/noExplicitAny: postgres rows are untyped at runtime +type Row = Record; + +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; + +type SplitInput = { member_id: string; amount?: number; percent?: number }; +type SplitPayload = { member_id: string; amount: number }; + +function ok(data: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function err(message: string): ToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} + +/** Returns computed splits payload, or an error message string. */ +function computeSplits( + amount: number, + splitType: "equal" | "exact" | "percent", + participants: string[], + rawSplits: SplitInput[] | undefined, + memberIdSet: Set, +): SplitPayload[] | string { + if (splitType === "equal") { + const splitMap = equalSplits(amount, participants); + return [...splitMap.entries()].map(([mid, amt]) => ({ + member_id: mid, + amount: amt, + })); + } + + if (splitType === "exact") { + if (!rawSplits?.length) return "splits array required for exact split"; + const splitMap = new Map( + rawSplits.map((s) => [s.member_id, s.amount ?? 0]), + ); + const invalid = [...splitMap.keys()].find((id) => !memberIdSet.has(id)); + if (invalid) return `Member ${invalid} is not in this group`; + const total = [...splitMap.values()].reduce((a, b) => a + b, 0); + if (Math.abs(total - amount) > 0.01) { + return `Split amounts sum to ${total.toFixed(2)} but expense total is ${amount.toFixed(2)}`; + } + return [...splitMap.entries()].map(([mid, amt]) => ({ + member_id: mid, + amount: amt, + })); + } + + if (splitType === "percent") { + if (!rawSplits?.length) return "splits array required for percent split"; + const totalPct = rawSplits.reduce((a, s) => a + (s.percent ?? 0), 0); + if (Math.abs(totalPct - 100) > 0.01) { + return `Percentages sum to ${totalPct.toFixed(2)}%, must equal 100%`; + } + const invalid = rawSplits.find((s) => !memberIdSet.has(s.member_id)); + if (invalid) return `Member ${invalid.member_id} is not in this group`; + const amountCents = Math.round(amount * 100); + const amounts = rawSplits.map((s) => ({ + member_id: s.member_id, + cents: Math.round((amountCents * (s.percent ?? 0)) / 100), + })); + const sumCents = amounts.reduce((a, b) => a + b.cents, 0); + amounts[amounts.length - 1].cents += amountCents - sumCents; + return amounts.map((a) => ({ + member_id: a.member_id, + amount: a.cents / 100, + })); + } + + return `Unknown split_type: ${splitType}`; +} + +const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1 + +function randomCode(): string { + return Array.from( + { length: 6 }, + () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)], + ).join(""); +} + +async function uniqueJoinCode(db: Db): Promise { + for (let i = 0; i < 10; i++) { + const code = randomCode(); + const [existing] = await db< + Row[] + >`SELECT id FROM groups WHERE join_code = ${code}`; + if (!existing) return code; + } + throw new Error("Failed to generate unique join code after 10 attempts"); +} + +async function assertMemberInGroup( + db: Db, + memberId: string, + groupId: string, +): Promise { + const [member] = await db` + SELECT id, display_name FROM members + WHERE id = ${memberId}::uuid AND group_id = ${groupId}::uuid + `; + return member ?? null; +} + +export async function createGroup( + db: Db, + args: { name: string; display_name: string; currency?: string }, +): Promise { + const { name, display_name, currency = "USD" } = args; + + if (!name?.trim()) return err("Group name is required"); + if (!display_name?.trim()) return err("Display name is required"); + + const join_code = await uniqueJoinCode(db); + + const [group] = await db` + INSERT INTO groups (name, join_code, currency) + VALUES (${name.trim()}, ${join_code}, ${currency}) + RETURNING id, name, join_code, currency + `; + const [member] = await db` + INSERT INTO members (group_id, display_name) + VALUES (${group.id}::uuid, ${display_name.trim()}) + RETURNING id, display_name + `; + + return ok({ + group_id: group.id, + group_name: group.name, + join_code: group.join_code, + currency: group.currency, + member_id: member.id, + display_name: member.display_name, + message: `Group "${group.name}" created. Share join code ${group.join_code} with friends.`, + }); +} + +export async function joinGroup( + db: Db, + args: { join_code: string; display_name?: string }, +): Promise { + const { join_code, display_name } = args; + + if (!join_code?.trim()) return err("Join code is required"); + + const code = join_code.trim().toUpperCase(); + + const [group] = await db` + SELECT id, name, currency FROM groups WHERE join_code = ${code} + `; + if (!group) return err(`No group found with join code ${code}`); + + const members = await db` + SELECT id, display_name FROM members + WHERE group_id = ${group.id}::uuid + ORDER BY joined_at + `; + + if (!display_name?.trim()) { + return ok({ + group_id: group.id, + group_name: group.name, + currency: group.currency, + pending: true, + members, + message: + members.length > 0 + ? `Group "${group.name}" has these members: ${members.map((m: Row) => m.display_name).join(", ")}. ` + + `Are you one of them? Call claim_member with their member_id, or call join_group again with a display_name to join as someone new.` + : `Group "${group.name}" has no members yet. Call join_group with a display_name to be the first.`, + }); + } + + const existing = members.find( + (m: Row) => + m.display_name.toLowerCase() === display_name.trim().toLowerCase(), + ); + + if (existing) { + return ok({ + group_id: group.id, + group_name: group.name, + currency: group.currency, + member_id: existing.id, + display_name: existing.display_name, + members, + rejoined: true, + message: `Welcome back, ${existing.display_name}!`, + }); + } + + const [member] = await db` + INSERT INTO members (group_id, display_name) + VALUES (${group.id}::uuid, ${display_name.trim()}) + RETURNING id, display_name + `; + + return ok({ + group_id: group.id, + group_name: group.name, + currency: group.currency, + member_id: member.id, + display_name: member.display_name, + members: [...members, { id: member.id, display_name: member.display_name }], + message: `Joined "${group.name}" as ${member.display_name}.`, + }); +} + +export async function getGroup( + db: Db, + args: { group_id: string }, +): Promise { + const { group_id } = args; + + const [group] = await db` + SELECT id, name, join_code, currency, created_at FROM groups + WHERE id = ${group_id}::uuid + `; + if (!group) return err("Group not found"); + + const members = await db` + SELECT id, display_name, joined_at FROM members + WHERE group_id = ${group_id}::uuid + ORDER BY joined_at + `; + + const [{ count }] = await db<{ count: number }[]>` + SELECT COUNT(*)::int AS count FROM expenses + WHERE group_id = ${group_id}::uuid AND deleted_at IS NULL + `; + + return ok({ ...group, members, expense_count: count }); +} + +export async function addExpense( + db: Db, + args: { + group_id: string; + member_id: string; + paid_by: string; + description: string; + amount: number; + currency?: string; + split_type?: "equal" | "exact" | "percent"; + split_with?: string[]; + splits?: SplitInput[]; + date?: string; + receipt_note?: string; + original_amount?: number; + original_currency?: string; + }, +): Promise { + const { + group_id, + member_id, + paid_by, + description, + amount, + currency = "USD", + split_type = "equal", + split_with, + splits: rawSplits, + date, + receipt_note, + original_amount, + original_currency, + } = args; + + if (!description?.trim()) return err("Description is required"); + if (!amount || amount <= 0) return err("Amount must be greater than 0"); + + const allMembers = await db` + SELECT id, display_name FROM members WHERE group_id = ${group_id}::uuid + `; + const memberMap = new Map( + allMembers.map((m: Row) => [m.id as string, m as Row]), + ); + + const requester = memberMap.get(member_id); + if (!requester) return err("You are not a member of this group"); + const payer = memberMap.get(paid_by); + if (!payer) return err("The member who paid is not in this group"); + + const memberIdSet = new Set(memberMap.keys()); + + let participants: string[]; + if (split_with && split_with.length > 0) { + const invalid = split_with.find((id) => !memberIdSet.has(id)); + if (invalid) return err(`Member ${invalid} is not in this group`); + participants = split_with; + } else { + participants = [...memberIdSet]; + } + + if (participants.length === 0) return err("No participants for split"); + + const splitsPayload = computeSplits( + amount, + split_type, + participants, + rawSplits, + memberIdSet, + ); + if (typeof splitsPayload === "string") return err(splitsPayload); + + const [{ expense_id: expenseId }] = await db<{ expense_id: string }[]>` + SELECT create_expense_with_splits( + ${group_id}::uuid, + ${paid_by}::uuid, + ${member_id}::uuid, + ${description.trim()}, + ${amount}::numeric, + ${currency}, + ${receipt_note ?? null}, + ${JSON.stringify(splitsPayload)}::jsonb, + ${original_amount ?? null}::numeric, + ${original_currency?.toUpperCase() ?? null}, + ${date ?? null}::date + ) AS expense_id + `; + + const nameMap = new Map( + allMembers.map((m: Row) => [m.id as string, m.display_name as string]), + ); + const splitSummary = splitsPayload.map((s) => ({ + member: nameMap.get(s.member_id), + amount: s.amount, + })); + + return ok({ + expense_id: expenseId, + description: description.trim(), + amount, + currency, + paid_by: { id: paid_by, display_name: payer.display_name }, + date: date ?? new Date().toISOString().slice(0, 10), + splits: splitSummary, + receipt_note: receipt_note ?? null, + ...(original_amount && original_currency + ? { + original: { + amount: original_amount, + currency: original_currency.toUpperCase(), + }, + } + : {}), + message: + original_amount && original_currency + ? `Expense logged: "${description.trim()}" for ${original_currency.toUpperCase()} ${original_amount.toFixed(2)} → ${currency} ${amount.toFixed(2)}, paid by ${payer.display_name}` + : `Expense logged: "${description.trim()}" for ${currency} ${amount.toFixed(2)}, paid by ${payer.display_name}`, + }); +} + +export async function listExpenses( + db: Db, + args: { + group_id: string; + limit?: number; + offset?: number; + paid_by?: string; + }, +): Promise { + const { group_id, limit = 20, offset = 0, paid_by } = args; + const lim = Math.min(limit, 100); + + const paidByFilter = paid_by ? db`AND paid_by = ${paid_by}::uuid` : db``; + + const expenses = await db` + SELECT id, description, amount, currency, original_amount, original_currency, + date, receipt_note, created_at, paid_by, created_by + FROM expenses + WHERE group_id = ${group_id}::uuid AND deleted_at IS NULL + ${paidByFilter} + ORDER BY created_at DESC + LIMIT ${lim} OFFSET ${offset} + `; + + const [{ count }] = await db<{ count: number }[]>` + SELECT COUNT(*)::int AS count FROM expenses + WHERE group_id = ${group_id}::uuid AND deleted_at IS NULL + ${paidByFilter} + `; + + if (!expenses.length) return ok({ expenses: [], total: 0, has_more: false }); + + const expenseIds = expenses.map((e: Row) => e.id as string); + const splits = await db` + SELECT expense_id, member_id, amount FROM expense_splits + WHERE expense_id = ANY(${db.array(expenseIds)}::uuid[]) + `; + + const members = await db` + SELECT id, display_name FROM members WHERE group_id = ${group_id}::uuid + `; + + const nameMap = new Map( + members.map((m: Row) => [m.id as string, m.display_name as string]), + ); + const splitsByExpense = new Map< + string, + Array<{ member: string; amount: number }> + >(); + for (const s of splits) { + if (!splitsByExpense.has(s.expense_id)) + splitsByExpense.set(s.expense_id, []); + splitsByExpense.get(s.expense_id)?.push({ + member: nameMap.get(s.member_id) ?? s.member_id, + amount: parseFloat(s.amount), + }); + } + + const result = expenses.map((e: Row) => ({ + id: e.id, + description: e.description, + date: e.date, + amount: parseFloat(e.amount), + currency: e.currency, + ...(e.original_amount + ? { + original: { + amount: parseFloat(e.original_amount), + currency: e.original_currency, + }, + } + : {}), + paid_by: { + id: e.paid_by, + display_name: nameMap.get(e.paid_by) ?? e.paid_by, + }, + splits: splitsByExpense.get(e.id) ?? [], + receipt_note: e.receipt_note, + created_at: e.created_at, + logged_by: nameMap.get(e.created_by) ?? e.created_by, + })); + + return ok({ + expenses: result, + total: count, + has_more: offset + expenses.length < count, + }); +} + +export async function deleteExpense( + db: Db, + args: { expense_id: string; member_id: string }, +): Promise { + const { expense_id, member_id } = args; + + const [expense] = await db` + SELECT id, description, created_by FROM expenses + WHERE id = ${expense_id}::uuid AND deleted_at IS NULL + `; + if (!expense) return err("Expense not found"); + if (expense.created_by !== member_id) + return err("You can only delete expenses you logged"); + + await db`UPDATE expenses SET deleted_at = now() WHERE id = ${expense_id}::uuid`; + + return ok({ + deleted: true, + expense_id, + message: `Expense "${expense.description}" has been deleted`, + }); +} + +export async function getBalances( + db: Db, + args: { group_id: string }, +): Promise { + const { group_id } = args; + + const [group] = await db` + SELECT id, name, currency FROM groups WHERE id = ${group_id}::uuid + `; + if (!group) return err("Group not found"); + + const [members, allExpenses, settlementRows] = await Promise.all([ + db< + Row[] + >`SELECT id, display_name FROM members WHERE group_id = ${group_id}::uuid`, + db< + Row[] + >`SELECT id, paid_by, amount, currency FROM expenses WHERE group_id = ${group_id}::uuid AND deleted_at IS NULL`, + db< + Row[] + >`SELECT paid_by, paid_to, amount FROM settlements WHERE group_id = ${group_id}::uuid`, + ]); + + const expenseRows = allExpenses.filter( + (e: Row) => e.currency === group.currency, + ); + const foreignCount = allExpenses.length - expenseRows.length; + + const expenseIds = expenseRows.map((e: Row) => e.id as string); + const splitRows = + expenseIds.length > 0 + ? await db` + SELECT expense_id, member_id, amount FROM expense_splits + WHERE expense_id = ANY(${db.array(expenseIds)}::uuid[]) + ` + : []; + + const expenses = expenseRows.map((e: Row) => ({ + id: e.id as string, + paid_by: e.paid_by as string, + amount: parseFloat(e.amount), + splits: (splitRows as Row[]) + .filter((s) => s.expense_id === e.id) + .map((s) => ({ + member_id: s.member_id as string, + amount: parseFloat(s.amount), + })), + })); + + const settlements = settlementRows.map((s: Row) => ({ + paid_by: s.paid_by as string, + paid_to: s.paid_to as string, + amount: parseFloat(s.amount), + })); + + const membersList = members.map((m: Row) => ({ + id: m.id as string, + display_name: m.display_name as string, + })); + const netBalances = computeNetBalances(membersList, expenses, settlements); + const suggestions = minimizeTransactions(netBalances); + + return ok({ + group: { id: group.id, name: group.name, currency: group.currency }, + net_balances: netBalances, + settlements_needed: suggestions, + everyone_settled: suggestions.length === 0, + ...(foreignCount > 0 + ? { + warning: `${foreignCount} expense(s) in non-${group.currency} currency excluded from balances`, + } + : {}), + }); +} + +export async function recordSettlement( + db: Db, + args: { + group_id: string; + paid_by: string; + paid_to: string; + amount: number; + note?: string; + }, +): Promise { + const { group_id, paid_by, paid_to, amount, note } = args; + + if (!amount || amount <= 0) return err("Amount must be greater than 0"); + if (paid_by === paid_to) return err("paid_by and paid_to must be different"); + + const payer = await assertMemberInGroup(db, paid_by, group_id); + if (!payer) return err("Payer is not a member of this group"); + + const payee = await assertMemberInGroup(db, paid_to, group_id); + if (!payee) return err("Recipient is not a member of this group"); + + await db` + INSERT INTO settlements (group_id, paid_by, paid_to, amount, note) + VALUES (${group_id}::uuid, ${paid_by}::uuid, ${paid_to}::uuid, ${amount}::numeric, ${note ?? null}) + `; + + return ok({ + recorded: true, + message: `Recorded: ${payer.display_name} paid ${payee.display_name} ${amount.toFixed(2)}`, + paid_by: payer.display_name, + paid_to: payee.display_name, + amount, + }); +} + +export async function getSettlementHistory( + db: Db, + args: { group_id: string; limit?: number }, +): Promise { + const { group_id, limit = 20 } = args; + + const settlements = await db` + SELECT id, paid_by, paid_to, amount, note, created_at FROM settlements + WHERE group_id = ${group_id}::uuid + ORDER BY created_at DESC + LIMIT ${Math.min(limit, 100)} + `; + + if (!settlements.length) return ok({ settlements: [] }); + + const memberIds = [ + ...new Set( + settlements.flatMap((s: Row) => [ + s.paid_by as string, + s.paid_to as string, + ]), + ), + ]; + const members = await db` + SELECT id, display_name FROM members + WHERE id = ANY(${db.array(memberIds)}::uuid[]) + `; + + const nameMap = new Map( + members.map((m: Row) => [m.id as string, m.display_name as string]), + ); + + return ok({ + settlements: settlements.map((s: Row) => ({ + id: s.id, + paid_by: { id: s.paid_by, display_name: nameMap.get(s.paid_by) }, + paid_to: { id: s.paid_to, display_name: nameMap.get(s.paid_to) }, + amount: parseFloat(s.amount), + note: s.note, + created_at: s.created_at, + })), + }); +} + +export async function addMember( + db: Db, + args: { group_id: string; member_id: string; display_name: string }, +): Promise { + const { group_id, member_id, display_name } = args; + + if (!display_name?.trim()) return err("Display name is required"); + + const requester = await assertMemberInGroup(db, member_id, group_id); + if (!requester) return err("You are not a member of this group"); + + const [conflict] = await db` + SELECT id FROM members + WHERE group_id = ${group_id}::uuid AND display_name = ${display_name.trim()} + `; + if (conflict) + return err(`"${display_name}" is already a member of this group`); + + const [member] = await db` + INSERT INTO members (group_id, display_name) + VALUES (${group_id}::uuid, ${display_name.trim()}) + RETURNING id, display_name + `; + + return ok({ + member_id: member.id, + display_name: member.display_name, + message: `${display_name} has been added. They can join using the group's join code and pick their name from the list.`, + }); +} + +export async function claimMember( + db: Db, + args: { member_id: string }, +): Promise { + const { member_id } = args; + + const [member] = await db` + SELECT id, display_name, group_id FROM members WHERE id = ${member_id}::uuid + `; + if (!member) return err("Member not found"); + + const [group] = await db` + SELECT id, name, currency FROM groups WHERE id = ${member.group_id}::uuid + `; + + return ok({ + member_id: member.id, + display_name: member.display_name, + group_id: member.group_id, + group_name: group?.name, + currency: group?.currency, + message: `You are ${member.display_name} in ${group?.name}.`, + }); +} + +export async function listMembers( + db: Db, + args: { group_id: string }, +): Promise { + const members = await db` + SELECT id, display_name, joined_at FROM members + WHERE group_id = ${args.group_id}::uuid + ORDER BY joined_at + `; + return ok({ members }); +} + +export async function getMember( + db: Db, + args: { member_id: string }, +): Promise { + const { member_id } = args; + + const [member] = await db` + SELECT id, display_name, joined_at, group_id FROM members WHERE id = ${member_id}::uuid + `; + if (!member) return err("Member not found"); + + const [group] = await db` + SELECT id, name, join_code, currency FROM groups WHERE id = ${member.group_id}::uuid + `; + + return ok({ + member_id: member.id, + display_name: member.display_name, + joined_at: member.joined_at, + group_id: member.group_id, + group_name: group?.name, + join_code: group?.join_code, + currency: group?.currency, + }); +} + +export async function renameMember( + db: Db, + args: { member_id: string; display_name: string }, +): Promise { + const { member_id, display_name } = args; + + if (!display_name?.trim()) return err("Display name is required"); + + const [member] = await db` + SELECT id, group_id, display_name FROM members WHERE id = ${member_id}::uuid + `; + if (!member) return err("Member not found"); + + const [conflict] = await db` + SELECT id FROM members + WHERE group_id = ${member.group_id}::uuid + AND display_name = ${display_name.trim()} + AND id != ${member_id}::uuid + `; + if (conflict) + return err(`Display name "${display_name}" is already taken in this group`); + + await db`UPDATE members SET display_name = ${display_name.trim()} WHERE id = ${member_id}::uuid`; + + return ok({ + member_id, + old_display_name: member.display_name, + new_display_name: display_name.trim(), + message: `Name changed from "${member.display_name}" to "${display_name.trim()}"`, + }); +} + +export async function updateExpense( + db: Db, + args: { + expense_id: string; + member_id: string; + description?: string; + amount?: number; + currency?: string; + paid_by?: string; + date?: string; + receipt_note?: string; + original_amount?: number; + original_currency?: string; + split_type?: "equal" | "exact" | "percent"; + split_with?: string[]; + splits?: SplitInput[]; + }, +): Promise { + const { + expense_id, + member_id, + description, + amount, + currency, + paid_by, + date, + receipt_note, + original_amount, + original_currency, + split_type = "equal", + split_with, + splits: rawSplits, + } = args; + + if ((original_amount !== undefined) !== (original_currency !== undefined)) { + return err( + "original_amount and original_currency must be provided together", + ); + } + + const [expense] = await db` + SELECT id, description, amount, currency, paid_by, created_by, group_id, date, + receipt_note, original_amount, original_currency + FROM expenses + WHERE id = ${expense_id}::uuid AND deleted_at IS NULL + `; + if (!expense) return err("Expense not found"); + if (expense.created_by !== member_id) + return err("You can only update expenses you logged"); + + if (paid_by) { + const payer = await assertMemberInGroup(db, paid_by, expense.group_id); + if (!payer) return err("The member who paid is not in this group"); + } + + const effectiveAmount = amount ?? parseFloat(expense.amount); + const splitsExplicitlyProvided = + split_with !== undefined || + rawSplits !== undefined || + split_type !== "equal"; + const amountChanged = + amount !== undefined && amount !== parseFloat(expense.amount); + const needSplitRecompute = splitsExplicitlyProvided || amountChanged; + + let splitsPayload: SplitPayload[] | null = null; + + if (needSplitRecompute) { + if (splitsExplicitlyProvided) { + const allMembers = await db` + SELECT id, display_name FROM members WHERE group_id = ${expense.group_id}::uuid + `; + const memberIdSet = new Set(allMembers.map((m: Row) => m.id as string)); + + let participants: string[]; + if (split_with && split_with.length > 0) { + const invalid = split_with.find((id) => !memberIdSet.has(id)); + if (invalid) return err(`Member ${invalid} is not in this group`); + participants = split_with; + } else { + participants = [...memberIdSet]; + } + if (participants.length === 0) return err("No participants for split"); + + const result = computeSplits( + effectiveAmount, + split_type, + participants, + rawSplits, + memberIdSet, + ); + if (typeof result === "string") return err(result); + splitsPayload = result; + } else { + // Only amount changed — re-split equally among existing participants + const existingSplits = await db` + SELECT member_id FROM expense_splits WHERE expense_id = ${expense_id}::uuid + `; + const existingParticipants = existingSplits.map( + (s: Row) => s.member_id as string, + ); + if (!existingParticipants.length) + return err("No existing participants found"); + const splitMap = equalSplits(effectiveAmount, existingParticipants); + splitsPayload = [...splitMap.entries()].map(([mid, amt]) => ({ + member_id: mid, + amount: amt, + })); + } + } + + const [{ expense_id: updatedId }] = await db<{ expense_id: string }[]>` + SELECT update_expense_with_splits( + ${expense_id}::uuid, + ${description ?? null}, + ${amount ?? null}::numeric, + ${currency ?? null}, + ${paid_by ?? null}::uuid, + ${date ?? null}::date, + ${receipt_note ?? null}, + ${original_amount ?? null}::numeric, + ${original_currency?.toUpperCase() ?? null}, + ${false}::boolean, + ${splitsPayload !== null ? JSON.stringify(splitsPayload) : null}::jsonb + ) AS expense_id + `; + + const effectivePaidBy: string = paid_by ?? expense.paid_by; + + const [updatedRows, finalSplits] = await Promise.all([ + db` + SELECT id, description, amount, currency, paid_by, date, receipt_note, original_amount, original_currency + FROM expenses WHERE id = ${updatedId}::uuid + `, + db< + Row[] + >`SELECT member_id, amount FROM expense_splits WHERE expense_id = ${expense_id}::uuid`, + ]); + const [updated] = updatedRows; + + const splitMemberIds = finalSplits.map((s: Row) => s.member_id as string); + const [payerRows, splitMembers] = await Promise.all([ + db< + Row[] + >`SELECT id, display_name FROM members WHERE id = ${effectivePaidBy}::uuid`, + splitMemberIds.length > 0 + ? db< + Row[] + >`SELECT id, display_name FROM members WHERE id = ANY(${db.array(splitMemberIds)}::uuid[])` + : Promise.resolve([] as Row[]), + ]); + const [payerMember] = payerRows; + + const nameMap = new Map( + (splitMembers as Row[]).map((m) => [ + m.id as string, + m.display_name as string, + ]), + ); + const splitSummary = finalSplits.map((s: Row) => ({ + member: nameMap.get(s.member_id), + amount: parseFloat(s.amount), + })); + + const finalAmount = parseFloat(updated?.amount ?? expense.amount); + const finalCurrency = updated?.currency ?? expense.currency; + const finalOrigAmount = updated?.original_amount; + const finalOrigCurrency = updated?.original_currency; + + return ok({ + expense_id: updatedId, + description: updated?.description, + amount: finalAmount, + currency: finalCurrency, + paid_by: { id: updated?.paid_by, display_name: payerMember?.display_name }, + date: updated?.date, + splits: splitSummary, + receipt_note: updated?.receipt_note ?? null, + ...(finalOrigAmount && finalOrigCurrency + ? { + original: { + amount: parseFloat(finalOrigAmount), + currency: finalOrigCurrency, + }, + } + : {}), + message: `Expense updated: "${updated?.description}"`, + }); +} + +// biome-ignore lint/suspicious/noExplicitAny: MCP args are untyped JSON +type Args = Record; + +export async function handleToolCall( + db: Db, + name: string, + args: Args, +): Promise { + // biome-ignore lint/suspicious/noExplicitAny: dispatch requires escape from union typing + const a = args as any; + switch (name) { + case "create_group": + return createGroup(db, a); + case "join_group": + return joinGroup(db, a); + case "get_group": + return getGroup(db, a); + case "add_expense": + return addExpense(db, a); + case "list_expenses": + return listExpenses(db, a); + case "delete_expense": + return deleteExpense(db, a); + case "update_expense": + return updateExpense(db, a); + case "get_balances": + return getBalances(db, a); + case "record_settlement": + return recordSettlement(db, a); + case "get_settlement_history": + return getSettlementHistory(db, a); + case "add_member": + return addMember(db, a); + case "claim_member": + return claimMember(db, a); + case "list_members": + return listMembers(db, a); + case "get_member": + return getMember(db, a); + case "rename_member": + return renameMember(db, a); + default: + return err(`Unknown tool: ${name}`); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..bee04b5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,123 @@ +import postgres from "postgres"; +import { handleToolCall } from "./handlers"; +import { runMigrations } from "./migrate"; +import { TOOLS } from "./tools"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error("[splitcount] DATABASE_URL environment variable is required"); + process.exit(1); +} + +const sql = postgres(DATABASE_URL); + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, x-client-info, apikey, MCP-Protocol-Version", +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...CORS, "Content-Type": "application/json" }, + }); +} + +function rpcOk(id: unknown, result: unknown): Response { + return jsonResponse({ jsonrpc: "2.0", id, result }); +} + +function rpcErr(id: unknown, code: number, message: string): Response { + return jsonResponse({ jsonrpc: "2.0", id, error: { code, message } }); +} + +await runMigrations(sql); + +const port = parseInt(process.env.PORT ?? "3000", 10); +console.log(`[splitcount] listening on port ${port}`); + +Bun.serve({ + port, + async fetch(req: Request): Promise { + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS }); + } + + if (req.method === "GET") { + const url = new URL(req.url); + if (url.searchParams.has("health")) { + return jsonResponse({ name: "splitcount", status: "ok" }); + } + return new Response("Method Not Allowed", { + status: 405, + headers: { ...CORS, Allow: "POST" }, + }); + } + + if (req.method !== "POST") { + return new Response("Method Not Allowed", { status: 405, headers: CORS }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return rpcErr(null, -32700, "Parse error: invalid JSON"); + } + + const rpc = body as { + jsonrpc?: string; + id?: unknown; + method?: string; + params?: unknown; + }; + + if (rpc.jsonrpc !== "2.0" || !rpc.method) { + return rpcErr(rpc.id ?? null, -32600, "Invalid Request"); + } + + const { id, method, params } = rpc; + + if (method.startsWith("notifications/")) { + return new Response(null, { status: 202, headers: CORS }); + } + + try { + switch (method) { + case "initialize": + return rpcOk(id, { + protocolVersion: "2025-03-26", + capabilities: { tools: {} }, + serverInfo: { name: "splitcount", version: "1.0.0" }, + instructions: + "SplitCount helps groups track and split shared expenses. " + + "When a user wants to join a group, call join_group without a display_name first so they can pick who they are from the existing member list. " + + "To log an expense from a receipt photo, read the image yourself to extract the amount, merchant, and items, " + + "then call add_expense with the details and a receipt_note summarizing what you saw. " + + "Use get_balances to see who owes whom, and record_settlement when someone pays another person back.", + }); + + case "tools/list": + return rpcOk(id, { tools: TOOLS }); + + case "tools/call": { + const { name: toolName, arguments: toolArgs } = params as { + name: string; + arguments: Record; + }; + const result = await handleToolCall(sql, toolName, toolArgs ?? {}); + return rpcOk(id, result); + } + + default: + return rpcErr(id, -32601, `Method not found: ${method}`); + } + } catch (e) { + const msg = e instanceof Error ? e.message : "Internal server error"; + console.error(`[splitcount] error in ${method}:`, e); + return rpcErr(id, -32603, msg); + } + }, +}); diff --git a/src/migrate.ts b/src/migrate.ts new file mode 100644 index 0000000..ad8310d --- /dev/null +++ b/src/migrate.ts @@ -0,0 +1,31 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type postgres from "postgres"; + +const migrationsDir = join(import.meta.dir, "../migrations"); + +export async function runMigrations(sql: postgres.Sql): Promise { + await sql` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + const files = (await readdir(migrationsDir)) + .filter((f) => f.endsWith(".sql")) + .sort(); + + for (const file of files) { + const [applied] = await sql` + SELECT filename FROM schema_migrations WHERE filename = ${file} + `; + if (applied) continue; + + const content = await readFile(join(migrationsDir, file), "utf-8"); + console.log(`[migrate] applying ${file}`); + await sql.unsafe(content); + await sql`INSERT INTO schema_migrations (filename) VALUES (${file})`; + console.log(`[migrate] applied ${file}`); + } +} diff --git a/supabase/functions/mcp/tools.ts b/src/tools.ts similarity index 93% rename from supabase/functions/mcp/tools.ts rename to src/tools.ts index d2174a4..167df9e 100644 --- a/supabase/functions/mcp/tools.ts +++ b/src/tools.ts @@ -183,7 +183,8 @@ export const TOOLS = [ expense_id: { type: "string", description: "Expense UUID to update" }, member_id: { type: "string", - description: "Your member_id (must be the member who logged the expense)", + description: + "Your member_id (must be the member who logged the expense)", }, description: { type: "string", description: "New description" }, amount: { @@ -204,15 +205,20 @@ export const TOOLS = [ pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "Date of the expense in YYYY-MM-DD format", }, - receipt_note: { type: "string", description: "Updated receipt summary" }, + receipt_note: { + type: "string", + description: "Updated receipt summary", + }, original_amount: { type: "number", exclusiveMinimum: 0, - description: "Amount in the original foreign currency. Must be paired with original_currency.", + description: + "Amount in the original foreign currency. Must be paired with original_currency.", }, original_currency: { type: "string", - description: "ISO 4217 code of the original currency. Must be paired with original_amount.", + description: + "ISO 4217 code of the original currency. Must be paired with original_amount.", }, split_type: { type: "string", @@ -222,11 +228,13 @@ export const TOOLS = [ split_with: { type: "array", items: { type: "string" }, - description: "member_ids to split among (replaces existing participants)", + description: + "member_ids to split among (replaces existing participants)", }, splits: { type: "array", - description: "Required for exact/percent. Array of { member_id, amount } or { member_id, percent }", + description: + "Required for exact/percent. Array of { member_id, amount } or { member_id, percent }", items: { type: "object", properties: { @@ -300,7 +308,8 @@ export const TOOLS = [ group_id: { type: "string", description: "Group UUID" }, member_id: { type: "string", - description: "Your member_id (must be an existing member of the group)", + description: + "Your member_id (must be an existing member of the group)", }, display_name: { type: "string", diff --git a/supabase/config.toml b/supabase/config.toml deleted file mode 100644 index ec12249..0000000 --- a/supabase/config.toml +++ /dev/null @@ -1,24 +0,0 @@ -project_id = "splitcount" - -[api] -port = 54321 -schemas = ["public", "storage", "graphql_public"] -extra_search_path = ["public", "extensions"] -max_rows = 1000 - -[db] -port = 54322 -shadow_port = 54320 -major_version = 17 - -[studio] -port = 54323 - -[inbucket] -port = 54324 - -[storage] -file_size_limit = "50MiB" - -[functions.mcp] -verify_jwt = false diff --git a/supabase/functions/mcp/handlers.ts b/supabase/functions/mcp/handlers.ts deleted file mode 100644 index 7637ec6..0000000 --- a/supabase/functions/mcp/handlers.ts +++ /dev/null @@ -1,1080 +0,0 @@ -import { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; -import { - computeNetBalances, - equalSplits, - minimizeTransactions, -} from "./balance.ts"; - -type ToolResult = { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; -}; - -function ok(data: unknown): ToolResult { - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; -} - -function err(message: string): ToolResult { - return { content: [{ type: "text", text: message }], isError: true }; -} - -// ── Join code ────────────────────────────────────────────────────────────── - -const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1 - -function randomCode(): string { - return Array.from( - { length: 6 }, - () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)], - ).join(""); -} - -async function uniqueJoinCode(db: SupabaseClient): Promise { - for (let i = 0; i < 10; i++) { - const code = randomCode(); - const { data } = await db - .from("groups") - .select("id") - .eq("join_code", code) - .maybeSingle(); - if (!data) return code; - } - throw new Error("Failed to generate unique join code after 10 attempts"); -} - -// ── Helpers ──────────────────────────────────────────────────────────────── - -async function assertMemberInGroup( - db: SupabaseClient, - memberId: string, - groupId: string, -): Promise<{ id: string; display_name: string } | null> { - const { data } = await db - .from("members") - .select("id, display_name") - .eq("id", memberId) - .eq("group_id", groupId) - .maybeSingle(); - return data; -} - -// ── Tool handlers ────────────────────────────────────────────────────────── - -export async function createGroup( - db: SupabaseClient, - args: { name: string; display_name: string; currency?: string }, -): Promise { - const { name, display_name, currency = "USD" } = args; - - if (!name?.trim()) return err("Group name is required"); - if (!display_name?.trim()) return err("Display name is required"); - - const join_code = await uniqueJoinCode(db); - - const { data: group, error: gErr } = await db - .from("groups") - .insert({ name: name.trim(), join_code, currency }) - .select() - .single(); - - if (gErr) return err(`Failed to create group: ${gErr.message}`); - - const { data: member, error: mErr } = await db - .from("members") - .insert({ group_id: group.id, display_name: display_name.trim() }) - .select() - .single(); - - if (mErr) return err(`Failed to add you as member: ${mErr.message}`); - - return ok({ - group_id: group.id, - group_name: group.name, - join_code: group.join_code, - currency: group.currency, - member_id: member.id, - display_name: member.display_name, - message: `Group "${group.name}" created. Share join code ${group.join_code} with friends.`, - }); -} - -export async function joinGroup( - db: SupabaseClient, - args: { join_code: string; display_name?: string }, -): Promise { - const { join_code, display_name } = args; - - if (!join_code?.trim()) return err("Join code is required"); - - const code = join_code.trim().toUpperCase(); - - const { data: group, error: gErr } = await db - .from("groups") - .select("id, name, currency") - .eq("join_code", code) - .maybeSingle(); - - if (gErr || !group) return err(`No group found with join code ${code}`); - - const { data: allMembers } = await db - .from("members") - .select("id, display_name") - .eq("group_id", group.id) - .order("joined_at"); - - const members = allMembers ?? []; - - // No display_name provided — return member list for user to pick from - if (!display_name?.trim()) { - return ok({ - group_id: group.id, - group_name: group.name, - currency: group.currency, - pending: true, - members, - message: members.length > 0 - ? `Group "${group.name}" has these members: ${members.map((m) => m.display_name).join(", ")}. ` + - `Are you one of them? Call claim_member with their member_id, or call join_group again with a display_name to join as someone new.` - : `Group "${group.name}" has no members yet. Call join_group with a display_name to be the first.`, - }); - } - - // Name matches an existing member — return their identity (works for rejoin too) - const existing = members.find( - (m) => m.display_name.toLowerCase() === display_name.trim().toLowerCase(), - ); - - if (existing) { - return ok({ - group_id: group.id, - group_name: group.name, - currency: group.currency, - member_id: existing.id, - display_name: existing.display_name, - members, - rejoined: true, - message: `Welcome back, ${existing.display_name}!`, - }); - } - - // New member - const { data: member, error: mErr } = await db - .from("members") - .insert({ group_id: group.id, display_name: display_name.trim() }) - .select() - .single(); - - if (mErr) return err(`Failed to join group: ${mErr.message}`); - - const { data: updatedMembers } = await db - .from("members") - .select("id, display_name") - .eq("group_id", group.id) - .order("joined_at"); - - return ok({ - group_id: group.id, - group_name: group.name, - currency: group.currency, - member_id: member.id, - display_name: member.display_name, - members: updatedMembers ?? [], - message: `Joined "${group.name}" as ${member.display_name}.`, - }); -} - -export async function getGroup( - db: SupabaseClient, - args: { group_id: string }, -): Promise { - const { group_id } = args; - - const { data: group, error } = await db - .from("groups") - .select("id, name, join_code, currency, created_at") - .eq("id", group_id) - .maybeSingle(); - - if (error || !group) return err(`Group not found`); - - const { data: members } = await db - .from("members") - .select("id, display_name, joined_at") - .eq("group_id", group_id) - .order("joined_at"); - - const { count } = await db - .from("expenses") - .select("id", { count: "exact", head: true }) - .eq("group_id", group_id) - .is("deleted_at", null); - - return ok({ - ...group, - members: members ?? [], - expense_count: count ?? 0, - }); -} - -export async function addExpense( - db: SupabaseClient, - args: { - group_id: string; - member_id: string; - paid_by: string; - description: string; - amount: number; - currency?: string; - split_type?: "equal" | "exact" | "percent"; - split_with?: string[]; - splits?: Array<{ member_id: string; amount?: number; percent?: number }>; - date?: string; - receipt_note?: string; - original_amount?: number; - original_currency?: string; - }, -): Promise { - const { - group_id, - member_id, - paid_by, - description, - amount, - currency = "USD", - split_type = "equal", - split_with, - splits: rawSplits, - date, - receipt_note, - original_amount, - original_currency, - } = args; - - if (!description?.trim()) return err("Description is required"); - if (!amount || amount <= 0) return err("Amount must be greater than 0"); - - // Verify requester is in group - const requester = await assertMemberInGroup(db, member_id, group_id); - if (!requester) return err("You are not a member of this group"); - - // Verify payer is in group - const payer = await assertMemberInGroup(db, paid_by, group_id); - if (!payer) return err("The member who paid is not in this group"); - - // Get all group members - const { data: allMembers, error: mErr } = await db - .from("members") - .select("id, display_name") - .eq("group_id", group_id); - - if (mErr || !allMembers) return err("Failed to fetch group members"); - - const memberIdSet = new Set(allMembers.map((m) => m.id)); - - // Determine participants - let participants: string[]; - if (split_with && split_with.length > 0) { - const invalid = split_with.find((id) => !memberIdSet.has(id)); - if (invalid) return err(`Member ${invalid} is not in this group`); - participants = split_with; - } else { - participants = allMembers.map((m) => m.id); - } - - if (participants.length === 0) return err("No participants for split"); - - // Compute split amounts - let splitMap: Map; - - if (split_type === "equal") { - splitMap = equalSplits(amount, participants); - } else if (split_type === "exact") { - if (!rawSplits?.length) return err("splits array required for exact split"); - splitMap = new Map(rawSplits.map((s) => [s.member_id, s.amount ?? 0])); - const invalid = [...splitMap.keys()].find((id) => !memberIdSet.has(id)); - if (invalid) return err(`Member ${invalid} is not in this group`); - const total = [...splitMap.values()].reduce((a, b) => a + b, 0); - if (Math.abs(total - amount) > 0.01) { - return err( - `Split amounts sum to ${total.toFixed(2)} but expense total is ${amount.toFixed(2)}`, - ); - } - } else if (split_type === "percent") { - if (!rawSplits?.length) { - return err("splits array required for percent split"); - } - const totalPct = rawSplits.reduce((a, s) => a + (s.percent ?? 0), 0); - if (Math.abs(totalPct - 100) > 0.01) { - return err(`Percentages sum to ${totalPct.toFixed(2)}%, must equal 100%`); - } - const invalid = rawSplits.find((s) => !memberIdSet.has(s.member_id)); - if (invalid) return err(`Member ${invalid.member_id} is not in this group`); - - // Convert percents to amounts in cents then back - const amountCents = Math.round(amount * 100); - const amounts = rawSplits.map((s) => ({ - member_id: s.member_id, - cents: Math.round(amountCents * (s.percent ?? 0) / 100), - })); - // Adjust last item for rounding - const sumCents = amounts.reduce((a, b) => a + b.cents, 0); - amounts[amounts.length - 1].cents += amountCents - sumCents; - splitMap = new Map(amounts.map((a) => [a.member_id, a.cents / 100])); - } else { - return err(`Unknown split_type: ${split_type}`); - } - - const splitsPayload = [...splitMap.entries()].map(([mid, amt]) => ({ - member_id: mid, - amount: amt, - })); - - const { data: expenseId, error: eErr } = await db.rpc( - "create_expense_with_splits", - { - p_group_id: group_id, - p_paid_by: paid_by, - p_created_by: member_id, - p_description: description.trim(), - p_amount: amount, - p_currency: currency, - p_receipt_note: receipt_note ?? null, - p_splits: splitsPayload, - p_original_amount: original_amount ?? null, - p_original_currency: original_currency?.toUpperCase() ?? null, - p_date: date ?? null, - }, - ); - - if (eErr) return err(`Failed to save expense: ${eErr.message}`); - - const nameMap = new Map(allMembers.map((m) => [m.id, m.display_name])); - const splitSummary = splitsPayload.map((s) => ({ - member: nameMap.get(s.member_id), - amount: s.amount, - })); - - return ok({ - expense_id: expenseId, - description: description.trim(), - amount, - currency, - paid_by: { id: paid_by, display_name: payer.display_name }, - date: date ?? new Date().toISOString().slice(0, 10), - splits: splitSummary, - receipt_note: receipt_note ?? null, - ...(original_amount && original_currency - ? { original: { amount: original_amount, currency: original_currency.toUpperCase() } } - : {}), - message: original_amount && original_currency - ? `Expense logged: "${description.trim()}" for ${original_currency.toUpperCase()} ${original_amount.toFixed(2)} → ${currency} ${amount.toFixed(2)}, paid by ${payer.display_name}` - : `Expense logged: "${description.trim()}" for ${currency} ${amount.toFixed(2)}, paid by ${payer.display_name}`, - }); -} - -export async function listExpenses( - db: SupabaseClient, - args: { - group_id: string; - limit?: number; - offset?: number; - paid_by?: string; - }, -): Promise { - const { group_id, limit = 20, offset = 0, paid_by } = args; - - let query = db - .from("expenses") - .select( - "id, description, amount, currency, original_amount, original_currency, date, receipt_note, created_at, paid_by, created_by", - { count: "exact" }, - ) - .eq("group_id", group_id) - .is("deleted_at", null) - .order("created_at", { ascending: false }) - .range(offset, offset + Math.min(limit, 100) - 1); - - if (paid_by) query = query.eq("paid_by", paid_by); - - const { data: expenses, error, count } = await query; - if (error) return err(`Failed to fetch expenses: ${error.message}`); - - if (!expenses?.length) { - return ok({ expenses: [], total: 0, has_more: false }); - } - - // Fetch splits for these expenses - const expenseIds = expenses.map((e) => e.id); - const { data: splits } = await db - .from("expense_splits") - .select("expense_id, member_id, amount") - .in("expense_id", expenseIds); - - // Fetch member names - const { data: members } = await db - .from("members") - .select("id, display_name") - .eq("group_id", group_id); - - const nameMap = new Map((members ?? []).map((m) => [m.id, m.display_name])); - const splitsByExpense = new Map< - string, - Array<{ member: string; amount: number }> - >(); - for (const s of splits ?? []) { - if (!splitsByExpense.has(s.expense_id)) { - splitsByExpense.set(s.expense_id, []); - } - splitsByExpense.get(s.expense_id)!.push({ - member: nameMap.get(s.member_id) ?? s.member_id, - amount: parseFloat(s.amount), - }); - } - - const result = expenses.map((e) => ({ - id: e.id, - description: e.description, - date: e.date, - amount: parseFloat(e.amount), - currency: e.currency, - ...(e.original_amount - ? { original: { amount: parseFloat(e.original_amount), currency: e.original_currency } } - : {}), - paid_by: { - id: e.paid_by, - display_name: nameMap.get(e.paid_by) ?? e.paid_by, - }, - splits: splitsByExpense.get(e.id) ?? [], - receipt_note: e.receipt_note, - created_at: e.created_at, - logged_by: nameMap.get(e.created_by) ?? e.created_by, - })); - - return ok({ - expenses: result, - total: count ?? 0, - has_more: offset + expenses.length < (count ?? 0), - }); -} - -export async function deleteExpense( - db: SupabaseClient, - args: { expense_id: string; member_id: string }, -): Promise { - const { expense_id, member_id } = args; - - const { data: expense, error } = await db - .from("expenses") - .select("id, description, created_by") - .eq("id", expense_id) - .is("deleted_at", null) - .maybeSingle(); - - if (error || !expense) return err("Expense not found"); - - if (expense.created_by !== member_id) { - return err("You can only delete expenses you logged"); - } - - const { error: delErr } = await db - .from("expenses") - .update({ deleted_at: new Date().toISOString() }) - .eq("id", expense_id); - - if (delErr) return err(`Failed to delete expense: ${delErr.message}`); - - return ok({ - deleted: true, - expense_id, - message: `Expense "${expense.description}" has been deleted`, - }); -} - -export async function getBalances( - db: SupabaseClient, - args: { group_id: string }, -): Promise { - const { group_id } = args; - - const { data: group } = await db - .from("groups") - .select("id, name, currency") - .eq("id", group_id) - .maybeSingle(); - - if (!group) return err("Group not found"); - - const { data: members } = await db - .from("members") - .select("id, display_name") - .eq("group_id", group_id); - - const { data: expenseRows } = await db - .from("expenses") - .select("id, paid_by, amount") - .eq("group_id", group_id) - .eq("currency", group.currency) - .is("deleted_at", null); - - const expenseIds = (expenseRows ?? []).map((e) => e.id); - const { data: splitRows } = - expenseIds.length > 0 - ? await db - .from("expense_splits") - .select("expense_id, member_id, amount") - .in("expense_id", expenseIds) - : { data: [] }; - - const { data: settlementRows } = await db - .from("settlements") - .select("paid_by, paid_to, amount") - .eq("group_id", group_id); - - const expenses = (expenseRows ?? []).map((e) => ({ - id: e.id, - paid_by: e.paid_by, - amount: parseFloat(e.amount), - splits: (splitRows ?? []) - .filter((s) => s.expense_id === e.id) - .map((s) => ({ member_id: s.member_id, amount: parseFloat(s.amount) })), - })); - - const settlements = (settlementRows ?? []).map((s) => ({ - paid_by: s.paid_by, - paid_to: s.paid_to, - amount: parseFloat(s.amount), - })); - - const netBalances = computeNetBalances( - members ?? [], - expenses, - settlements, - ); - - const suggestions = minimizeTransactions(netBalances); - const isSettled = suggestions.length === 0; - - // Flag non-default-currency expenses - const { count: foreignCount } = await db - .from("expenses") - .select("id", { count: "exact", head: true }) - .eq("group_id", group_id) - .neq("currency", group.currency) - .is("deleted_at", null); - - return ok({ - group: { id: group.id, name: group.name, currency: group.currency }, - net_balances: netBalances, - settlements_needed: suggestions, - everyone_settled: isSettled, - ...(foreignCount && foreignCount > 0 - ? { - warning: `${foreignCount} expense(s) in non-${group.currency} currency excluded from balances`, - } - : {}), - }); -} - -export async function recordSettlement( - db: SupabaseClient, - args: { - group_id: string; - paid_by: string; - paid_to: string; - amount: number; - note?: string; - }, -): Promise { - const { group_id, paid_by, paid_to, amount, note } = args; - - if (!amount || amount <= 0) return err("Amount must be greater than 0"); - if (paid_by === paid_to) return err("paid_by and paid_to must be different"); - - const payer = await assertMemberInGroup(db, paid_by, group_id); - if (!payer) return err("Payer is not a member of this group"); - - const payee = await assertMemberInGroup(db, paid_to, group_id); - if (!payee) return err("Recipient is not a member of this group"); - - const { error } = await db.from("settlements").insert({ - group_id, - paid_by, - paid_to, - amount, - note: note ?? null, - }); - - if (error) return err(`Failed to record settlement: ${error.message}`); - - return ok({ - recorded: true, - message: `Recorded: ${payer.display_name} paid ${payee.display_name} ${amount.toFixed(2)}`, - paid_by: payer.display_name, - paid_to: payee.display_name, - amount, - }); -} - -export async function getSettlementHistory( - db: SupabaseClient, - args: { group_id: string; limit?: number }, -): Promise { - const { group_id, limit = 20 } = args; - - const { data: settlements, error } = await db - .from("settlements") - .select("id, paid_by, paid_to, amount, note, created_at") - .eq("group_id", group_id) - .order("created_at", { ascending: false }) - .limit(Math.min(limit, 100)); - - if (error) return err(`Failed to fetch settlements: ${error.message}`); - - if (!settlements?.length) return ok({ settlements: [] }); - - const memberIds = [ - ...new Set(settlements.flatMap((s) => [s.paid_by, s.paid_to])), - ]; - const { data: members } = await db - .from("members") - .select("id, display_name") - .in("id", memberIds); - - const nameMap = new Map((members ?? []).map((m) => [m.id, m.display_name])); - - return ok({ - settlements: settlements.map((s) => ({ - id: s.id, - paid_by: { id: s.paid_by, display_name: nameMap.get(s.paid_by) }, - paid_to: { id: s.paid_to, display_name: nameMap.get(s.paid_to) }, - amount: parseFloat(s.amount), - note: s.note, - created_at: s.created_at, - })), - }); -} - -export async function addMember( - db: SupabaseClient, - args: { group_id: string; member_id: string; display_name: string }, -): Promise { - const { group_id, member_id, display_name } = args; - - if (!display_name?.trim()) return err("Display name is required"); - - const requester = await assertMemberInGroup(db, member_id, group_id); - if (!requester) return err("You are not a member of this group"); - - const { data: conflict } = await db - .from("members") - .select("id") - .eq("group_id", group_id) - .eq("display_name", display_name.trim()) - .maybeSingle(); - - if (conflict) return err(`"${display_name}" is already a member of this group`); - - const { data: member, error } = await db - .from("members") - .insert({ group_id, display_name: display_name.trim() }) - .select() - .single(); - - if (error) return err(`Failed to add member: ${error.message}`); - - return ok({ - member_id: member.id, - display_name: member.display_name, - message: `${display_name} has been added. They can join using the group's join code and pick their name from the list.`, - }); -} - -export async function claimMember( - db: SupabaseClient, - args: { member_id: string }, -): Promise { - const { member_id } = args; - - const { data: member, error } = await db - .from("members") - .select("id, display_name, group_id") - .eq("id", member_id) - .maybeSingle(); - - if (error || !member) return err("Member not found"); - - const { data: group } = await db - .from("groups") - .select("id, name, currency") - .eq("id", member.group_id) - .maybeSingle(); - - return ok({ - member_id: member.id, - display_name: member.display_name, - group_id: member.group_id, - group_name: group?.name, - currency: group?.currency, - message: `You are ${member.display_name} in ${group?.name}.`, - }); -} - -export async function listMembers( - db: SupabaseClient, - args: { group_id: string }, -): Promise { - const { data: members, error } = await db - .from("members") - .select("id, display_name, joined_at") - .eq("group_id", args.group_id) - .order("joined_at"); - - if (error) return err(`Failed to fetch members: ${error.message}`); - - return ok({ members: members ?? [] }); -} - -export async function getMember( - db: SupabaseClient, - args: { member_id: string }, -): Promise { - const { member_id } = args; - - const { data: member, error } = await db - .from("members") - .select("id, display_name, joined_at, group_id") - .eq("id", member_id) - .maybeSingle(); - - if (error || !member) return err("Member not found"); - - const { data: group } = await db - .from("groups") - .select("id, name, join_code, currency") - .eq("id", member.group_id) - .maybeSingle(); - - return ok({ - member_id: member.id, - display_name: member.display_name, - joined_at: member.joined_at, - group_id: member.group_id, - group_name: group?.name, - join_code: group?.join_code, - currency: group?.currency, - }); -} - -export async function renameMember( - db: SupabaseClient, - args: { member_id: string; display_name: string }, -): Promise { - const { member_id, display_name } = args; - - if (!display_name?.trim()) return err("Display name is required"); - - const { data: member } = await db - .from("members") - .select("id, group_id, display_name") - .eq("id", member_id) - .maybeSingle(); - - if (!member) return err("Member not found"); - - const { data: conflict } = await db - .from("members") - .select("id") - .eq("group_id", member.group_id) - .eq("display_name", display_name.trim()) - .neq("id", member_id) - .maybeSingle(); - - if (conflict) { - return err(`Display name "${display_name}" is already taken in this group`); - } - - const { error } = await db - .from("members") - .update({ display_name: display_name.trim() }) - .eq("id", member_id); - - if (error) return err(`Failed to rename: ${error.message}`); - - return ok({ - member_id, - old_display_name: member.display_name, - new_display_name: display_name.trim(), - message: `Name changed from "${member.display_name}" to "${display_name.trim()}"`, - }); -} - -export async function updateExpense( - db: SupabaseClient, - args: { - expense_id: string; - member_id: string; - description?: string; - amount?: number; - currency?: string; - paid_by?: string; - date?: string; - receipt_note?: string; - original_amount?: number; - original_currency?: string; - split_type?: "equal" | "exact" | "percent"; - split_with?: string[]; - splits?: Array<{ member_id: string; amount?: number; percent?: number }>; - }, -): Promise { - const { - expense_id, - member_id, - description, - amount, - currency, - paid_by, - date, - receipt_note, - original_amount, - original_currency, - split_type = "equal", - split_with, - splits: rawSplits, - } = args; - - // Validate original_amount/original_currency pairing - if ( - (original_amount !== undefined) !== (original_currency !== undefined) - ) { - return err("original_amount and original_currency must be provided together"); - } - - // Fetch expense - const { data: expense, error: fetchErr } = await db - .from("expenses") - .select("id, description, amount, currency, paid_by, created_by, group_id, date, receipt_note, original_amount, original_currency") - .eq("id", expense_id) - .is("deleted_at", null) - .maybeSingle(); - - if (fetchErr || !expense) return err("Expense not found"); - - // Auth check - if (expense.created_by !== member_id) { - return err("You can only update expenses you logged"); - } - - // Validate paid_by if provided - if (paid_by) { - const payer = await assertMemberInGroup(db, paid_by, expense.group_id); - if (!payer) return err("The member who paid is not in this group"); - } - - const effectiveAmount = amount ?? parseFloat(expense.amount); - - // Determine if splits need recomputation - const splitsExplicitlyProvided = split_with !== undefined || rawSplits !== undefined || split_type !== "equal"; - const amountChanged = amount !== undefined && amount !== parseFloat(expense.amount); - const needSplitRecompute = splitsExplicitlyProvided || amountChanged; - - let splitsPayload: Array<{ member_id: string; amount: number }> | null = null; - - if (needSplitRecompute) { - // Fetch group members - const { data: allMembers, error: mErr } = await db - .from("members") - .select("id, display_name") - .eq("group_id", expense.group_id); - - if (mErr || !allMembers) return err("Failed to fetch group members"); - - const memberIdSet = new Set(allMembers.map((m) => m.id)); - - if (splitsExplicitlyProvided) { - // Recompute from provided split params - let participants: string[]; - if (split_with && split_with.length > 0) { - const invalid = split_with.find((id) => !memberIdSet.has(id)); - if (invalid) return err(`Member ${invalid} is not in this group`); - participants = split_with; - } else { - participants = allMembers.map((m) => m.id); - } - - if (participants.length === 0) return err("No participants for split"); - - let splitMap: Map; - - if (split_type === "equal") { - splitMap = equalSplits(effectiveAmount, participants); - } else if (split_type === "exact") { - if (!rawSplits?.length) return err("splits array required for exact split"); - splitMap = new Map(rawSplits.map((s) => [s.member_id, s.amount ?? 0])); - const invalid = [...splitMap.keys()].find((id) => !memberIdSet.has(id)); - if (invalid) return err(`Member ${invalid} is not in this group`); - const total = [...splitMap.values()].reduce((a, b) => a + b, 0); - if (Math.abs(total - effectiveAmount) > 0.01) { - return err( - `Split amounts sum to ${total.toFixed(2)} but expense total is ${effectiveAmount.toFixed(2)}`, - ); - } - } else if (split_type === "percent") { - if (!rawSplits?.length) return err("splits array required for percent split"); - const totalPct = rawSplits.reduce((a, s) => a + (s.percent ?? 0), 0); - if (Math.abs(totalPct - 100) > 0.01) { - return err(`Percentages sum to ${totalPct.toFixed(2)}%, must equal 100%`); - } - const invalid = rawSplits.find((s) => !memberIdSet.has(s.member_id)); - if (invalid) return err(`Member ${invalid.member_id} is not in this group`); - - const amountCents = Math.round(effectiveAmount * 100); - const amounts = rawSplits.map((s) => ({ - member_id: s.member_id, - cents: Math.round(amountCents * (s.percent ?? 0) / 100), - })); - const sumCents = amounts.reduce((a, b) => a + b.cents, 0); - amounts[amounts.length - 1].cents += amountCents - sumCents; - splitMap = new Map(amounts.map((a) => [a.member_id, a.cents / 100])); - } else { - return err(`Unknown split_type: ${split_type}`); - } - - splitsPayload = [...splitMap.entries()].map(([mid, amt]) => ({ - member_id: mid, - amount: amt, - })); - } else { - // Only amount changed — re-split equally among existing participants - const { data: existingSplits } = await db - .from("expense_splits") - .select("member_id") - .eq("expense_id", expense_id); - - const existingParticipants = (existingSplits ?? []).map((s) => s.member_id); - if (existingParticipants.length === 0) return err("No existing participants found"); - - const splitMap = equalSplits(effectiveAmount, existingParticipants); - splitsPayload = [...splitMap.entries()].map(([mid, amt]) => ({ - member_id: mid, - amount: amt, - })); - } - } - - // Call RPC - const { data: updatedId, error: rpcErr } = await db.rpc( - "update_expense_with_splits", - { - p_expense_id: expense_id, - p_description: description ?? null, - p_amount: amount ?? null, - p_currency: currency ?? null, - p_paid_by: paid_by ?? null, - p_date: date ?? null, - p_receipt_note: receipt_note ?? null, - p_original_amount: original_amount ?? null, - p_original_currency: original_currency?.toUpperCase() ?? null, - p_clear_original: false, - p_splits: splitsPayload !== null ? splitsPayload : null, - }, - ); - - if (rpcErr) return err(`Failed to update expense: ${rpcErr.message}`); - - // Fetch updated expense for response - const { data: updated } = await db - .from("expenses") - .select("id, description, amount, currency, paid_by, date, receipt_note, original_amount, original_currency") - .eq("id", updatedId) - .maybeSingle(); - - // Fetch paid_by display name - const { data: payerMember } = await db - .from("members") - .select("id, display_name") - .eq("id", updated?.paid_by ?? expense.paid_by) - .maybeSingle(); - - // Fetch splits for response - const { data: finalSplits } = await db - .from("expense_splits") - .select("member_id, amount") - .eq("expense_id", expense_id); - - const { data: splitMembers } = await db - .from("members") - .select("id, display_name") - .in("id", (finalSplits ?? []).map((s) => s.member_id)); - - const nameMap = new Map((splitMembers ?? []).map((m) => [m.id, m.display_name])); - const splitSummary = (finalSplits ?? []).map((s) => ({ - member: nameMap.get(s.member_id), - amount: parseFloat(s.amount), - })); - - const finalAmount = parseFloat(updated?.amount ?? expense.amount); - const finalCurrency = updated?.currency ?? expense.currency; - const finalOrigAmount = updated?.original_amount; - const finalOrigCurrency = updated?.original_currency; - - return ok({ - expense_id: updatedId, - description: updated?.description, - amount: finalAmount, - currency: finalCurrency, - paid_by: { id: updated?.paid_by, display_name: payerMember?.display_name }, - date: updated?.date, - splits: splitSummary, - receipt_note: updated?.receipt_note ?? null, - ...(finalOrigAmount && finalOrigCurrency - ? { original: { amount: parseFloat(finalOrigAmount), currency: finalOrigCurrency } } - : {}), - message: `Expense updated: "${updated?.description}"`, - }); -} - -// ── Dispatch ─────────────────────────────────────────────────────────────── - -// deno-lint-ignore no-explicit-any -type Args = Record; - -export async function handleToolCall( - db: SupabaseClient, - name: string, - args: Args, -): Promise { - switch (name) { - case "create_group": - return createGroup(db, args); - case "join_group": - return joinGroup(db, args); - case "get_group": - return getGroup(db, args); - case "add_expense": - return addExpense(db, args); - case "list_expenses": - return listExpenses(db, args); - case "delete_expense": - return deleteExpense(db, args); - case "update_expense": - return updateExpense(db, args); - case "get_balances": - return getBalances(db, args); - case "record_settlement": - return recordSettlement(db, args); - case "get_settlement_history": - return getSettlementHistory(db, args); - case "add_member": - return addMember(db, args); - case "claim_member": - return claimMember(db, args); - case "list_members": - return listMembers(db, args); - case "get_member": - return getMember(db, args); - case "rename_member": - return renameMember(db, args); - default: - return err(`Unknown tool: ${name}`); - } -} diff --git a/supabase/functions/mcp/index.ts b/supabase/functions/mcp/index.ts deleted file mode 100644 index f36610a..0000000 --- a/supabase/functions/mcp/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; -import { TOOLS } from "./tools.ts"; -import { handleToolCall } from "./handlers.ts"; - -const CORS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, x-client-info, apikey, MCP-Protocol-Version", -}; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { ...CORS, "Content-Type": "application/json" }, - }); -} - -function rpcOk(id: unknown, result: unknown): Response { - return jsonResponse({ jsonrpc: "2.0", id, result }); -} - -function rpcErr(id: unknown, code: number, message: string): Response { - return jsonResponse({ jsonrpc: "2.0", id, error: { code, message } }); -} - -Deno.serve(async (req: Request) => { - if (req.method === "OPTIONS") { - return new Response(null, { headers: CORS }); - } - - if (req.method === "GET") { - // Health check probe — return 200 only when explicitly requested - const url = new URL(req.url); - if (url.searchParams.has("health")) { - return jsonResponse({ name: "splitcount", status: "ok" }); - } - // Streamable HTTP spec: GET without SSE support → 405 - return new Response("Method Not Allowed", { - status: 405, - headers: { ...CORS, Allow: "POST" }, - }); - } - - if (req.method !== "POST") { - return new Response("Method Not Allowed", { status: 405, headers: CORS }); - } - - let body: unknown; - try { - body = await req.json(); - } catch { - return rpcErr(null, -32700, "Parse error: invalid JSON"); - } - - const rpc = body as { - jsonrpc?: string; - id?: unknown; - method?: string; - params?: unknown; - }; - - if (rpc.jsonrpc !== "2.0" || !rpc.method) { - return rpcErr(rpc.id ?? null, -32600, "Invalid Request"); - } - - const { id, method, params } = rpc; - - // Notifications — spec requires 202 Accepted - if (method.startsWith("notifications/")) { - return new Response(null, { status: 202, headers: CORS }); - } - - const db = createClient( - Deno.env.get("SUPABASE_URL")!, - Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, - ); - - try { - switch (method) { - case "initialize": - return rpcOk(id, { - protocolVersion: "2025-03-26", - capabilities: { tools: {} }, - serverInfo: { name: "splitcount", version: "1.0.0" }, - instructions: - "SplitCount helps groups track and split shared expenses. " + - "When a user wants to join a group, call join_group without a display_name first so they can pick who they are from the existing member list. " + - "To log an expense from a receipt photo, read the image yourself to extract the amount, merchant, and items, " + - "then call add_expense with the details and a receipt_note summarizing what you saw. " + - "Use get_balances to see who owes whom, and record_settlement when someone pays another person back.", - }); - - case "tools/list": - return rpcOk(id, { tools: TOOLS }); - - case "tools/call": { - const { name: toolName, arguments: toolArgs } = params as { - name: string; - arguments: Record; - }; - const result = await handleToolCall(db, toolName, toolArgs ?? {}); - return rpcOk(id, result); - } - - default: - return rpcErr(id, -32601, `Method not found: ${method}`); - } - } catch (e) { - const msg = e instanceof Error ? e.message : "Internal server error"; - console.error(`[splitcount] error in ${method}:`, e); - return rpcErr(id, -32603, msg); - } -}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..541e35a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["bun-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + } +}