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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The application was migrated. The stack below is what runs today.
- **Local development backend:** `apps/local-api`, a loopback-only PostgreSQL API on port 3002 that calls the same database functions. Vite proxies `/api` to it. Its database is set up as `infra/local/README.md` describes. It is never deployed.
- **Email:** `apps/reminder-worker`, a Neon Function that sends reminder and invitation emails from the outbox through Resend, with one-click unsubscribe and a bounce webhook.
- **Shared logic:** `packages/core` (split arithmetic, balances, reading typed amounts, and writing money and dates), imported as `@commune/core` through a path in `apps/web/tsconfig.json` and an alias in `apps/web/vite.config.ts`. It has no `package.json`. Amounts and day-and-month dates are written by its `formatMoney` and `formatDay`; `apps/web/tests/formatting.test.ts` fails if app code uses `Intl.NumberFormat`, `toLocaleString` or `toLocaleDateString`.
- **Phone app:** `apps/native` (Expo, expo-router), built from the approved phone design. It runs on demo data or the local API, not yet on hosted Neon, and keeps its own lockfile outside the workspace. See `apps/native/README.md`.
- **Mobile:** `apps/mobile-v2` (Expo). Separate work. It does not use Commune's database or share code with `apps/web`.
- **Website:** `web/` is the marketing website: the editorial homepage and its company pages (moved from `apps/landing-monzo-study` on 5 October 2026). The previous v2 site is kept in `web-backup/`. Neither is in the pnpm workspace. Separate work from the app.

Expand All @@ -28,6 +29,7 @@ commune/
├── apps/web/ # The product: React 19 + Vite + wouter
├── apps/local-api/ # Local loopback API over PostgreSQL (dev only)
├── apps/reminder-worker/ # Reminder and invitation email sender
├── apps/native/ # Expo phone app (own lockfile)
├── apps/mobile-v2/ # Expo app (separate work)
├── web/ # Marketing website (separate work)
├── web-backup/ # Previous v2 website, kept for reference
Expand Down
3 changes: 3 additions & 0 deletions apps/local-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ browser code sends same-origin `/api` requests. Non-2xx errors are
| POST | `/api/shares/:id/confirm` | `{}` payer only; records one receipt for everything outstanding | `{ok:true}` |
| POST | `/api/shares/:id/receipts` | `{amountMinor,receivedOn,reference?,requestId}` payer only; amount up to the outstanding share; a repeated `requestId` returns the same entry | `{id,duplicate,settledMinor,outstandingMinor}` (201, or 200 for a repeat) |
| POST | `/api/receipts/:id/reverse` | `{reason}` payer only; keeps the original entry; refused for a former member's share | `{id,duplicate,settledMinor,outstandingMinor}` |
| GET | `/api/pay-links` | | `{links:[...]}`, your own Monzo, PayPal, Revolut or bank details |
| PUT | `/api/pay-links/:provider` | provider `monzo`, `paypal`, `revolut` or `bank`; `{handle}` or `{accountName,sortCode,accountNumber}`; a pasted link keeps only the username | `{links:[...]}` |
| DELETE | `/api/pay-links/:provider` | removing one that is not there is harmless | `{links:[...]}` |
| PUT | `/api/expenses/:id` | `{expectedVersion,title,amountMinor,category,dueOn?,paidByUserId,shares:[{userId,amountMinor}],reason,requestId}` payer or admin; only an admin changes the payer; money fields are locked while repayments exist | `{version,duplicate}`; a stale `expectedVersion` is 409 |
| POST | `/api/expenses/:id/void` | `{expectedVersion,reason,requestId}` payer or admin; refused while repayments exist; nothing is deleted | `{version,duplicate}` |
| POST | `/api/communes/:id/funds` | `{name,targetMinor?,requestId?}` admin; a repeated `requestId` returns the same fund | `{id,duplicate}` (201, or 200 for a repeat) |
Expand Down
22 changes: 22 additions & 0 deletions apps/local-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,28 @@ route("POST", /^\/api\/announcements\/([0-9a-f-]{36})\/archive$/, async (req, re
send(res, result.rows[0].result);
});

// Pay links: how this person likes to be paid. People who share a commune
// with them read these from the snapshot; the database validates every field.
route("GET", /^\/api\/pay-links$/, async (req, res) => {
const result = await userAction(req, async (client) => client.query("SELECT public.my_pay_links() AS links"));
send(res, { links: result.rows[0].links });
});

route("PUT", /^\/api\/pay-links\/(monzo|paypal|revolut|bank)$/, async (req, res, match) => {
const input = await body(req);
const result = await userAction(req, async (client) => client.query(
"SELECT public.set_pay_link($1, $2, $3, $4, $5) AS links",
[match[1], optionalText(input.handle, "handle", 120), optionalText(input.accountName, "accountName", 60),
optionalText(input.sortCode, "sortCode", 12), optionalText(input.accountNumber, "accountNumber", 12)]));
send(res, { links: result.rows[0].links });
});

route("DELETE", /^\/api\/pay-links\/(monzo|paypal|revolut|bank)$/, async (req, res, match) => {
const result = await userAction(req, async (client) => client.query(
"SELECT public.remove_pay_link($1) AS links", [match[1]]));
send(res, { links: result.rows[0].links });
});

route("POST", /^\/api\/communes\/([0-9a-f-]{36})\/funds$/, async (req, res, match) => {
const input = await body(req);
const name = text(input.name, "name", 80);
Expand Down
69 changes: 69 additions & 0 deletions apps/local-api/tests/pay-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";

// Pay links: saving your own, who can see them, and leaving a commune.
// Creates accounts and records, so it refuses the normal application API.
const base = process.env.API_BASE;
if (!base) throw new Error("Set API_BASE to an isolated local test API, not the user's database.");
const url = new URL(base);
if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port ||
url.port === "3002" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
throw new Error("Pay link tests require an isolated loopback API port (not 3002).");
}
const suffix = randomUUID().slice(0, 8);
const password = "DisposablePayLinkFixture-2026!";
function session() {
let cookie = "";
return async (method, path, input) => {
const response = await fetch(`${base}/api${path}`, {
method,
headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) },
body: input === undefined ? undefined : JSON.stringify(input),
});
const set = response.headers.get("set-cookie");
if (set) cookie = set.split(";")[0];
return { ...(await response.json()), status: response.status };
};
}
const ok = (result, status = 200) => { assert.equal(result.status, status, JSON.stringify(result)); return result; };
const a = session(), b = session(), outsider = session();
const users = [];
for (const [index, call] of [a, b, outsider].entries()) {
users.push(ok(await call("POST", "/auth/signup", {
name: ["Avery Pay", "Blake Pay", "Outsider"][index], email: `pay-links-${index}-${suffix}@example.test`, password,
}), 201).user);
}
const [avery, blake] = users;
const id = ok(await a("POST", "/communes", { name: `Pay links ${suffix}`, kind: "household", currency: "GBP" }), 201).id;
const invite = ok(await a("POST", `/communes/${id}/invites`, { email: blake.email, role: "member" }), 201);
ok(await b("POST", `/invites/${invite.token}/accept`, {}));

// Validation happens in the database and comes back as a 400 with a reason.
assert.equal((await a("PUT", "/pay-links/monzo", { handle: "not a username" })).status, 400);
assert.equal((await a("PUT", "/pay-links/bank", { accountName: "Avery Pay", sortCode: "12345", accountNumber: "12345678" })).status, 400);
assert.equal((await a("PUT", "/pay-links/venmo", { handle: "avery" })).status, 404);
assert.equal((await outsider("GET", "/pay-links")).links.length, 0);

// Avery saves two; a pasted link keeps only the username.
ok(await a("PUT", "/pay-links/monzo", { handle: "https://monzo.me/averypay" }));
const saved = ok(await a("PUT", "/pay-links/bank", { accountName: "Avery Pay", sortCode: "20-45-77", accountNumber: "41029388" })).links;
assert.deepEqual(saved.map((link) => link.provider), ["monzo", "bank"]);
assert.equal(saved[0].handle, "averypay");
assert.equal(saved[1].sortCode, "204577");
assert.deepEqual(ok(await a("GET", "/pay-links")).links.map((link) => link.provider), ["monzo", "bank"]);

// Blake, in the same commune, sees them in the snapshot; the outsider cannot open it.
const seen = ok(await b("GET", `/communes/${id}/snapshot`)).payLinks;
assert.deepEqual(seen[avery.id].map((link) => link.provider), ["monzo", "bank"]);
assert.equal(seen[blake.id], undefined);
assert.equal((await outsider("GET", `/communes/${id}/snapshot`)).status, 404);
assert.equal(ok(await b("GET", "/pay-links")).links.length, 0);

// Removing is Avery's alone and harmless to repeat.
ok(await b("DELETE", "/pay-links/monzo"));
assert.equal(ok(await a("GET", "/pay-links")).links.length, 2);
assert.equal(ok(await a("DELETE", "/pay-links/monzo")).links.length, 1);
assert.equal(ok(await a("DELETE", "/pay-links/monzo")).links.length, 1);
assert.deepEqual(Object.keys(ok(await b("GET", `/communes/${id}/snapshot`)).payLinks), [avery.id]);

console.log("Pay link tests passed");
6 changes: 6 additions & 0 deletions apps/native/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
.expo/
dist/
ios/
android/
*.tsbuildinfo
3 changes: 3 additions & 0 deletions apps/native/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ignore-workspace=true
auto-install-peers=true
node-linker=hoisted
57 changes: 57 additions & 0 deletions apps/native/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Commune phone app (Expo)

The native iPhone and Android app, built from the approved phone design
(Home Direction B, the five-tab bar with More, and the pay sheet with pay
links). It is separate from `apps/mobile` and `apps/mobile-v2`, which are older
experiments.

## What it does

- **Home**: your standing on the dark band, the next step raised over it, your
communes, what needs you (grouped by when it is due), what you are waiting on,
and what other people did.
- **Hub, Expenses, Settle, More**: the commune in view, its expenses, who you
pay and who pays you (each currency kept separate), and the rest of the
commune (chores and tasks, notes, members, recurring bills, funds, templates).
- **"I’ve paid"**: opens a sheet with the bill payer's pay links first (Monzo
and PayPal with the amount filled in, Revolut, or bank details to copy), then
records the payment. It stays a claim until the bill payer confirms it.
Commune never moves money.
- **Confirm or "Not received"**, **Nudge**, **Mark done**, **Add an expense**
(split equally), **How people pay you** (add, change, remove), notifications.

Uneven splits, receipts, corrections, invitations and settings stay on the web
app for now.

## Where its data comes from

Screens never do arithmetic. `src/lib/derive.ts` works everything out from the
same commune snapshots the web app reads, and every command goes to the same
validated database functions with a request id.

| `EXPO_PUBLIC_COMMUNE_API` | Data |
| --- | --- |
| not set | Maya's demo communes, kept on the phone. Nothing leaves the device. |
| `http://127.0.0.1:3005` | A local API (`apps/local-api`). Use a disposable database and port, never 3002 for tests. |
| `same-origin` | A web build served next to the local API (used for browser checks). |

The hosted app (Neon Auth and the Data API) is not connected yet. That needs
Neon Auth sign-in to work from a phone, which is the next piece of work.

## Running it

It keeps its own lockfile so the web CI install is unchanged.

```sh
cd apps/native
pnpm install --ignore-workspace
pnpm start # Expo Go or a simulator; press i or a
pnpm typecheck && pnpm test
pnpm export:web # a static web build in dist/
```

`EXPO_PUBLIC_*` values are built into the bundle. After changing one, run
`npx expo start --clear` (or `expo export --clear`) so the old value is not reused.

The local API only listens on 127.0.0.1, so a real phone cannot reach it; the
iOS simulator can, and the Android emulator reaches it as `10.0.2.2`.
28 changes: 28 additions & 0 deletions apps/native/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"expo": {
"name": "Commune",
"slug": "commune",
"scheme": "commune",
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"backgroundColor": "#1a1e2b",
"newArchEnabled": true,
"ios": {
"bundleIdentifier": "io.ourcommune.app",
"supportsTablet": false,
"config": { "usesNonExemptEncryption": false }
},
"android": {
"package": "io.ourcommune.app",
"edgeToEdgeEnabled": true
},
"web": {
"bundler": "metro",
"output": "single",
"name": "Commune"
},
"plugins": ["expo-router"],
"experiments": { "typedRoutes": false }
}
}
61 changes: 61 additions & 0 deletions apps/native/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Redirect, Tabs } from "expo-router";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { BottomTabBarProps } from "@react-navigation/bottom-tabs";
import { ArrowRightLeft, Ellipsis, House, LayoutGrid, ReceiptText } from "lucide-react-native";
import { useApp } from "../../src/state";
import { Button, T } from "../../src/ui/kit";
import { useTheme } from "../../src/ui/theme";

const TABS = [
{ name: "index", label: "Home", Icon: LayoutGrid },
{ name: "hub", label: "Hub", Icon: House },
{ name: "expenses", label: "Expenses", Icon: ReceiptText },
{ name: "settle", label: "Settle", Icon: ArrowRightLeft },
{ name: "more", label: "More", Icon: Ellipsis },
];

/** Five tabs on the dark frame, the selected one in a pill, as in the approved mock. */
function TabBar({ state, navigation }: BottomTabBarProps) {
const { c } = useTheme();
const insets = useSafeAreaInsets();
return <View accessibilityRole="tablist" style={{ flexDirection: "row", backgroundColor: c.frame, paddingTop: 8, paddingHorizontal: 6, paddingBottom: Math.max(insets.bottom, 10) }}>
{TABS.map((tab, index) => {
const on = state.index === index;
const color = on ? c.onFrame : c.onFrame2;
return <Pressable key={tab.name} accessibilityRole="tab" accessibilityState={{ selected: on }} accessibilityLabel={tab.label}
onPress={() => {
const event = navigation.emit({ type: "tabPress", target: state.routes[index].key, canPreventDefault: true });
if (!on && !event.defaultPrevented) navigation.navigate(state.routes[index].name);
}}
style={{ flex: 1, alignItems: "center", gap: 4 }}>
<View style={{ width: 54, height: 30, borderRadius: 15, alignItems: "center", justifyContent: "center", backgroundColor: on ? c.frameRaised : "transparent" }}>
<tab.Icon size={21} color={color} strokeWidth={on ? 2 : 1.7} />
</View>
<Text style={{ fontSize: 11, fontWeight: "500", color }}>{tab.label}</Text>
</Pressable>;
})}
</View>;
}

export default function TabsLayout() {
const { c } = useTheme();
const { status, error, refresh } = useApp();
if (status === "signed-out") return <Redirect href="/sign-in" />;
if (status === "starting" || status === "loading") {
return <View style={{ flex: 1, backgroundColor: c.frame, alignItems: "center", justifyContent: "center", gap: 16 }}>
<Text style={{ color: c.onFrame, fontSize: 34, fontWeight: "700", letterSpacing: -2 }}>commune.</Text>
<ActivityIndicator color={c.onFrame} />
</View>;
}
if (status === "error") {
return <View style={{ flex: 1, backgroundColor: c.canvas, padding: 24, justifyContent: "center", gap: 12 }}>
<T kind="title">Commune didn’t load</T>
<T kind="body" muted>{error}</T>
<Button label="Try again" onPress={() => void refresh()} />
</View>;
}
return <Tabs tabBar={(props) => <TabBar {...props} />} screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: c.canvas } }}>
{TABS.map((tab) => <Tabs.Screen key={tab.name} name={tab.name} options={{ title: tab.label }} />)}
</Tabs>;
}
Loading
Loading