Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
.next/
.env
.env.local
*.log
Expand Down
72 changes: 69 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

A free, open-source, self-hosted expense-splitting platform. Track shared expenses, split bills, and settle debts with friends and groups.

OpenSplit provides a REST API backend that you host yourself, a TypeScript SDK for building client applications, and an MCP server for AI agent integration.
OpenSplit provides a REST API backend that you host yourself, a web frontend, a TypeScript SDK for building client applications, and an MCP server for AI agent integration.

## Architecture

```
opensplit/
├── apps/
│ └── web/ # Next.js web frontend
├── packages/
│ ├── api/ # NestJS backend (REST API)
│ ├── sdk/ # TypeScript SDK for consuming the API
Expand All @@ -20,6 +22,7 @@ opensplit/

| Package | Description | Stack |
|---------|-------------|-------|
| `@opensplit/web` | Web frontend | Next.js 15, shadcn/ui, Tailwind CSS |
| `@opensplit/api` | Self-hosted REST API server | NestJS, Prisma, SQLite (configurable) |
| `@opensplit/sdk` | Typed client SDK (zero dependencies) | TypeScript, native `fetch` |
| `@opensplit/mcp` | MCP server for AI agent integration | MCP SDK, `@opensplit/sdk` |
Expand All @@ -33,7 +36,7 @@ opensplit/
- **Comments** — comment on any expense
- **Notifications** — activity feed for expense/group changes
- **Categories** — pre-seeded category hierarchy (Food, Transportation, Utilities, etc.)
- **Multi-currency** — 37 pre-seeded currencies with full multi-currency balance tracking
- **Multi-currency** — 37 pre-seeded currencies with per-currency balance tracking (each currency shows its own "owes you" / "you owe" direction independently)
- **Soft-delete** — expenses and groups are soft-deleted and can be restored
- **Rate limiting** — per-IP throttling (5 req/min on auth, 100 req/min globally)
- **Swagger** — auto-generated API documentation at `/api`
Expand Down Expand Up @@ -119,11 +122,20 @@ pnpm dev

The API starts on `http://localhost:3000`. Swagger docs are at `http://localhost:3000/api`.

### 5. Start the web frontend (optional)

```bash
cp apps/web/.env.example apps/web/.env
pnpm --filter @opensplit/web dev
```

The web app starts on `http://localhost:3100`. Set `OPENSPLIT_API_URL` in `apps/web/.env` if your API runs on a different port.

## Docker

Run OpenSplit with Docker (SQLite, no database service needed):

1. Set the host ports in `docker-compose.yml` — replace `<host-api-port>` and `<host-mcp-port>` with your desired ports (e.g. `3000` and `3001`)
1. Set the host ports in `docker-compose.yml` — replace `<host-api-port>`, `<host-web-port>`, and `<host-mcp-port>` with your desired ports (e.g. `3000`, `3100`, and `3001`)
2. Start the services:

```bash
Expand All @@ -132,8 +144,11 @@ docker compose up

This starts:
- **OpenSplit API** on your chosen API port — SQLite, migrations and seeding run automatically
- **Web frontend** on your chosen web port — connects to the API internally
- **MCP server** on your chosen MCP port — HTTP mode, connects to the API internally

You can customize the brand name via the `BRAND_NAME` env var: `BRAND_NAME=MyApp docker compose up`

To run in the background:

```bash
Expand Down Expand Up @@ -533,6 +548,36 @@ The AI agent:
2. Calls `list_friends` to find Alex's user ID
3. Calls `create_expense` with `{ description: "Dinner", cost: 60, currencyCode: "USD", splitEqually: true, shares: [...] }`

## Web Frontend

The web frontend (`apps/web`) is a Next.js 15 app using shadcn/ui components and Tailwind CSS. It communicates with the API exclusively through server-side SDK calls — the API key is stored in an HTTP-only cookie and never exposed to client-side JavaScript.

### Pages

| Path | Description |
|------|-------------|
| `/login` | Sign in with email and password |
| `/register` | Create a new account — displays the API key as a "Secret" to save |
| `/` | Home — per-currency balance summary for each friend (left), add expense form (right) |
| `/friends` | Friends list — accordion with per-currency balances on each header, expandable to see individual expenses |
| `/profile` | Update name, rotate API key |

### Key Design Decisions

- **Server components by default** — data fetching happens on the server via the SDK. Client components are used only for forms and interactive elements.
- **Suspense streaming** — the friends list and expense form load inside `<Suspense>` boundaries with skeleton fallbacks, so the page shell renders instantly.
- **Auth via HTTP-only cookie** — the API key is set as an HTTP-only cookie after login/register. Middleware redirects unauthenticated users to `/login`. All API calls go through Next.js server actions or server components.
- **i18n ready** — all UI strings are externalized via [next-intl](https://next-intl.dev/), with English as the default locale. Add new locales by creating translation files in `apps/web/src/messages/`.
- **Invited users** — when you add a friend by email who hasn't registered yet, a placeholder account is created with `INVITED` status. They can register later with the same email to claim their account and see all shared expenses.
- **Brand name from env** — set `NEXT_PUBLIC_BRAND_NAME` in `apps/web/.env` to customize the app name shown in the navbar and auth pages.

### Environment Variables

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXT_PUBLIC_BRAND_NAME` | No | `OpenSplit` | Brand name displayed in the UI |
| `OPENSPLIT_API_URL` | No | `http://localhost:3000` | URL of the OpenSplit API (server-side only) |

## Data Model

```
Expand Down Expand Up @@ -576,6 +621,27 @@ pnpm db:studio # Open Prisma Studio (database GUI)
### Project Structure

```
apps/web/
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout
│ │ ├── (auth)/ # Auth pages (no navbar)
│ │ │ ├── login/page.tsx
│ │ │ └── register/page.tsx
│ │ └── (app)/ # Authenticated pages (with navbar)
│ │ ├── page.tsx # Home (friends + expenses)
│ │ └── profile/page.tsx
│ ├── components/
│ │ ├── shadcn/ # shadcn/ui components
│ │ └── *.tsx # App components (navbar, forms, lists)
│ ├── lib/
│ │ ├── api.ts # SDK client factory
│ │ ├── auth.ts # Cookie management
│ │ └── actions/ # Server actions (auth, expenses, friends, profile)
│ └── middleware.ts # Auth redirect middleware
├── .env.example
└── components.json # shadcn config

packages/api/
├── prisma/
│ ├── schema.prisma # Database schema
Expand Down
2 changes: 2 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
NEXT_PUBLIC_BRAND_NAME=OpenSplit
OPENSPLIT_API_URL=http://localhost:3000
37 changes: 37 additions & 0 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
FROM node:22-slim AS base
RUN corepack enable && corepack prepare pnpm@10 --activate

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
COPY packages/sdk/package.json packages/sdk/package.json
COPY apps/web/package.json apps/web/package.json
RUN pnpm install --frozen-lockfile

FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/sdk/node_modules ./packages/sdk/node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY packages/sdk packages/sdk
COPY apps/web apps/web
COPY package.json pnpm-workspace.yaml tsconfig.json ./

RUN pnpm --filter @opensplit/sdk build

ARG NEXT_PUBLIC_BRAND_NAME=OpenSplit
ENV NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME

RUN pnpm --filter @opensplit/web build

FROM base AS production
WORKDIR /app
ENV NODE_ENV=production

COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public

EXPOSE 3100

CMD ["node", "apps/web/server.js"]
21 changes: 21 additions & 0 deletions apps/web/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components/shadcn",
"utils": "@/lib/utils",
"ui": "@/components/shadcn",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
6 changes: 6 additions & 0 deletions apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
10 changes: 10 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");

const nextConfig: NextConfig = {
output: "standalone",
};

export default withNextIntl(nextConfig);
39 changes: 39 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@opensplit/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3100",
"build": "next build",
"start": "next start --port 3100",
"lint": "next lint"
},
"dependencies": {
"@opensplit/sdk": "workspace:*",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.513.0",
"next": "^15.3.3",
"next-intl": "^4.13.0",
"radix-ui": "^1.5.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^22.15.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.0"
}
}
8 changes: 8 additions & 0 deletions apps/web/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};

export default config;
45 changes: 45 additions & 0 deletions apps/web/src/app/(app)/friends/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import { getClient } from "@/lib/api";
import { FriendsAccordion } from "@/components/friends-accordion";
import { AddFriendForm } from "@/components/add-friend-form";
import { FriendsListSkeleton } from "@/components/friends-list-skeleton";
import { Users } from "lucide-react";

async function FriendsContent() {
const t = await getTranslations("friends");
const client = await getClient();
const [me, friends] = await Promise.all([
client.users.me(),
client.friends.list(),
]);

if (friends.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-sm text-muted-foreground">{t("empty")}</p>
</div>
);
}

return <FriendsAccordion friends={friends} currentUserId={me.id} />;
}

export default async function FriendsPage() {
const t = await getTranslations("friends");

return (
<div className="flex gap-16">
<div className="flex-1">
<h1 className="mb-6 text-2xl font-semibold">{t("title")}</h1>
<Suspense fallback={<FriendsListSkeleton />}>
<FriendsContent />
</Suspense>
</div>
<div className="w-80 shrink-0">
<AddFriendForm />
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions apps/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Navbar } from "@/components/navbar";

export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-dvh">
<Navbar />
<main className="mx-auto max-w-5xl px-6 py-6">{children}</main>
</div>
);
}
35 changes: 35 additions & 0 deletions apps/web/src/app/(app)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import { BalancesList } from "@/components/balances-list";
import { BalancesListSkeleton } from "@/components/balances-list-skeleton";
import { AddExpenseSection } from "@/components/add-expense-section";
import { Skeleton } from "@/components/shadcn/skeleton";

export default async function HomePage() {
const t = await getTranslations("home");

return (
<div className="flex gap-16">
<div className="flex-1">
<h1 className="mb-6 text-2xl font-semibold">{t("balancesTitle")}</h1>
<Suspense fallback={<BalancesListSkeleton />}>
<BalancesList />
</Suspense>
</div>
<div className="w-80 shrink-0">
<Suspense
fallback={
<div className="space-y-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
}
>
<AddExpenseSection />
</Suspense>
</div>
</div>
);
}
Loading
Loading