From 39b5a84721841e6e14e1e029eb88613c44e84209 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:29:53 +0000 Subject: [PATCH 1/6] Bring back pay links: Monzo, PayPal, Revolut and bank details Each person can save how they like to be paid. People who share a commune with them see these when they owe them, from Settle, an expense or Home's next step: Monzo and PayPal open with the amount filled in, Revolut opens their profile, and bank details can be copied. Commune still never moves money; the payer reports it as before. Saved through SECURITY DEFINER functions in migration 0042 with no direct table grant, and shared only with active members through the full snapshot. Built on the phone layout branch (PR #38). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TFug6Zkjw7p11FxL3guDfE --- apps/local-api/README.md | 3 + apps/local-api/src/server.ts | 22 ++++ apps/local-api/tests/pay-links.mjs | 69 ++++++++++ apps/web/src/data/household.ts | 4 +- apps/web/src/data/payLinks.ts | 84 ++++++++++++ apps/web/src/live/model.ts | 2 + apps/web/src/live/neon.ts | 11 ++ apps/web/src/screens/Home.tsx | 2 +- apps/web/src/screens/Money.css | 25 ++++ apps/web/src/screens/Money.tsx | 2 +- apps/web/src/screens/Settings.tsx | 4 +- apps/web/src/state.tsx | 44 ++++++- apps/web/src/ui/PayLinks.tsx | 161 +++++++++++++++++++++++ apps/web/src/ui/Receipts.tsx | 5 + apps/web/src/windows/Windows.tsx | 27 +++- apps/web/tests/pay-links.test.tsx | 156 ++++++++++++++++++++++ infra/local/README.md | 4 +- infra/neon/README.md | 5 + infra/neon/migrations/0042_pay_links.sql | 141 ++++++++++++++++++++ infra/neon/tests/grants.sql | 10 +- infra/neon/tests/pay_links.sql | 151 +++++++++++++++++++++ 21 files changed, 916 insertions(+), 16 deletions(-) create mode 100644 apps/local-api/tests/pay-links.mjs create mode 100644 apps/web/src/data/payLinks.ts create mode 100644 apps/web/src/ui/PayLinks.tsx create mode 100644 apps/web/tests/pay-links.test.tsx create mode 100644 infra/neon/migrations/0042_pay_links.sql create mode 100644 infra/neon/tests/pay_links.sql diff --git a/apps/local-api/README.md b/apps/local-api/README.md index 1b014826..1cee623f 100644 --- a/apps/local-api/README.md +++ b/apps/local-api/README.md @@ -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) | diff --git a/apps/local-api/src/server.ts b/apps/local-api/src/server.ts index 5545479e..9bae2bb2 100644 --- a/apps/local-api/src/server.ts +++ b/apps/local-api/src/server.ts @@ -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); diff --git a/apps/local-api/tests/pay-links.mjs b/apps/local-api/tests/pay-links.mjs new file mode 100644 index 00000000..5be3ea65 --- /dev/null +++ b/apps/local-api/tests/pay-links.mjs @@ -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"); diff --git a/apps/web/src/data/household.ts b/apps/web/src/data/household.ts index 4c6eaa3d..e1a99573 100644 --- a/apps/web/src/data/household.ts +++ b/apps/web/src/data/household.ts @@ -180,7 +180,7 @@ export const PAGE_TITLES: Record = { "add-expense": "Add expense", "member-invite": "Invite", pay: "Pay a member", - "pay-method": "Pay me", + "pay-method": "How people pay you", profile: "Profile", settings: "Settings", }; @@ -220,7 +220,7 @@ export const PAGE_LEDES: Record = { "add-expense": "Choose the members and check the split.", "member-invite": "Send an email invitation or share a secure link.", pay: "Choose how to settle outside Commune.", - "pay-method": "A username they can tap from Settle.", + "pay-method": "Monzo, PayPal, Revolut or bank details people in your communes see when they owe you.", profile: "Your account and payment methods.", settings: "You, the house, and how this app behaves.", }; diff --git a/apps/web/src/data/payLinks.ts b/apps/web/src/data/payLinks.ts new file mode 100644 index 00000000..6517f53f --- /dev/null +++ b/apps/web/src/data/payLinks.ts @@ -0,0 +1,84 @@ +import type { CurrencyCode } from "../live/model"; + +/** + * How a person likes to be paid. The database keeps only usernames and UK + * bank details; links are built here. Commune never moves money: a link opens + * the payer's own app, and they report the payment in Commune afterwards. + */ +export type PayProvider = "monzo" | "paypal" | "revolut" | "bank"; +export type PayLink = { + provider: PayProvider; + handle: string | null; + accountName: string | null; + sortCode: string | null; + accountNumber: string | null; +}; +export type PayLinkInput = { + handle?: string; accountName?: string; sortCode?: string; accountNumber?: string; +}; + +export const PAY_PROVIDERS: PayProvider[] = ["monzo", "paypal", "revolut", "bank"]; +export const PAY_PROVIDER_NAME: Record = { + monzo: "Monzo", paypal: "PayPal", revolut: "Revolut", bank: "Bank transfer", +}; +const HOST: Record, string> = { + monzo: "monzo.me", paypal: "paypal.me", revolut: "revolut.me", +}; + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** Reads links from the API or snapshot, dropping anything malformed. */ +export function parsePayLinks(value: unknown): PayLink[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item): PayLink[] => { + if (!item || typeof item !== "object") return []; + const row = item as Record; + const provider = row.provider; + if (typeof provider !== "string" || !PAY_PROVIDERS.includes(provider as PayProvider)) return []; + const link: PayLink = { + provider: provider as PayProvider, handle: text(row.handle), accountName: text(row.accountName), + sortCode: text(row.sortCode), accountNumber: text(row.accountNumber), + }; + if (link.provider === "bank" ? !(link.accountName && link.sortCode && link.accountNumber) : !link.handle) return []; + return [link]; + }).sort((a, b) => PAY_PROVIDERS.indexOf(a.provider) - PAY_PROVIDERS.indexOf(b.provider)); +} + +/** 204577 becomes 20-45-77. */ +export function formatSortCode(value: string | null): string { + const digits = (value ?? "").replace(/\D/g, ""); + return digits.length === 6 ? `${digits.slice(0, 2)}-${digits.slice(2, 4)}-${digits.slice(4)}` : digits; +} + +/** The short address shown under a link, for example monzo.me/samokafor. */ +export function payLinkLabel(link: PayLink): string { + if (link.provider === "bank") return `${link.accountName} · ${formatSortCode(link.sortCode)} · ${link.accountNumber}`; + return `${HOST[link.provider]}/${link.handle}`; +} + +/** + * The link to open, with the amount filled in where the provider supports it: + * Monzo.me takes pounds only, PayPal.me takes an amount and currency, and + * Revolut.me opens the profile. Bank transfers have no link. + */ +export function payLinkHref(link: PayLink, amountMinor: number, currency: CurrencyCode, note?: string): + { href: string; amountFilled: boolean } | null { + if (link.provider === "bank" || !link.handle) return null; + const handle = encodeURIComponent(link.handle); + const amount = amountMinor > 0 ? (amountMinor / 100).toFixed(2) : ""; + if (link.provider === "monzo" && amount && currency === "GBP") { + const description = note?.trim() ? `?d=${encodeURIComponent(note.trim().slice(0, 40))}` : ""; + return { href: `https://monzo.me/${handle}/${amount}${description}`, amountFilled: true }; + } + if (link.provider === "paypal" && amount) { + return { href: `https://paypal.me/${handle}/${amount}${currency}`, amountFilled: true }; + } + return { href: `https://${HOST[link.provider]}/${handle}`, amountFilled: false }; +} + +/** Bank details as one line, for copying into a banking app. */ +export function bankDetailsText(link: PayLink): string { + return `${link.accountName}\nSort code ${formatSortCode(link.sortCode)}\nAccount ${link.accountNumber}`; +} diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index e59f74d1..49a70b9a 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -293,6 +293,8 @@ export type Snapshot = { recurringBills?: RecurringBill[]; splitTemplates?: SplitTemplate[]; announcements?: Announcement[]; + /** Pay links of current members who have saved any, keyed by user id (0042). */ + payLinks?: Record; /** recordType: the kind of record the event is about (0039). */ activityEvents?: Array<{id:string;actorId:string;kind:ActivityEventKind;entityId:string;title:string;description:string;createdAt:string;recordType?:ActivityType}>; /** diff --git a/apps/web/src/live/neon.ts b/apps/web/src/live/neon.ts index 1f9354dc..d92fbdd8 100644 --- a/apps/web/src/live/neon.ts +++ b/apps/web/src/live/neon.ts @@ -697,6 +697,17 @@ async function route(path: string, init: RequestInit): Promise { p_commune_id: pathId(path, /^\/communes\/([^/]+)\/announcements$/), p_title: body.title, p_body: body.body ?? "", p_needs_acknowledgement: body.needsAcknowledgement === true, p_request_id: body.requestId, }))); + } else if (method === "GET" && path === "/pay-links") { + response = { links: unwrap(await db().rpc("my_pay_links", {})) ?? [] }; + } else if (method === "PUT" && pathId(path, /^\/pay-links\/(monzo|paypal|revolut|bank)$/)) { + response = { links: unwrap(await db().rpc("set_pay_link", { + p_provider: pathId(path, /^\/pay-links\/(monzo|paypal|revolut|bank)$/), p_handle: body.handle ?? "", + p_account_name: body.accountName ?? "", p_sort_code: body.sortCode ?? "", p_account_number: body.accountNumber ?? "", + })) ?? [] }; + } else if (method === "DELETE" && pathId(path, /^\/pay-links\/(monzo|paypal|revolut|bank)$/)) { + response = { links: unwrap(await db().rpc("remove_pay_link", { + p_provider: pathId(path, /^\/pay-links\/(monzo|paypal|revolut|bank)$/), + })) ?? [] }; } else if (method === "POST" && pathId(path, /^\/announcements\/([^/]+)\/acknowledge$/)) { response = asObject(unwrap(await db().rpc("acknowledge_announcement", { p_announcement_id: pathId(path, /^\/announcements\/([^/]+)\/acknowledge$/), diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index bc600fb0..1211a811 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -526,7 +526,7 @@ export function HomeScreen() { setMessage(""); if (item.kind === "pay" && item.shareId && item.minor) { receipts.report({ shareId: item.shareId, payerName: nameOf(hub, item.person, "the bill payer"), expenseName: item.title, - outstandingMinor: item.minor, currency: hub.currency, communeId: hub.id }); + outstandingMinor: item.minor, currency: hub.currency, communeId: hub.id, payerId: item.person }); } else if (item.kind === "confirm" && item.report) { receipts.answer({ report: item.report, debtorName: nameOf(hub, item.person, "a former member"), expenseName: item.title, currency: hub.currency, decision: decline ? "decline" : "confirm", communeId: hub.id }); diff --git a/apps/web/src/screens/Money.css b/apps/web/src/screens/Money.css index 606e0092..6829dc82 100644 --- a/apps/web/src/screens/Money.css +++ b/apps/web/src/screens/Money.css @@ -205,3 +205,28 @@ .a-body.is-dark .a-window :is(.c-product-dialog, .c-reminder-dialog) .a-field-row { position: static; margin: 16px 0 0; padding: 0; background: transparent; border-top: 0; } .c-period-split .c-form-help { margin: 0 0 6px; } } + +@layer screens { +/* Pay links: how the bill payer likes to be paid, and your own in Profile. */ +.c-pay-links, .c-pay-editor { display: grid; gap: 0; margin: 0 0 18px; border: 1px solid var(--border); border-radius: var(--radius-control); background: #fff; } +.c-pay-link { display: flex; align-items: center; gap: 10px; padding: 10px 12px; min-height: 56px; } +.c-pay-link + .c-pay-link, .c-pay-form + .c-pay-link, .c-pay-link + .c-pay-form { border-top: 1px solid var(--border); } +.c-pay-copy { display: grid; min-width: 0; flex: 1; font-size: 13.5px; line-height: 1.4; } +.c-pay-copy span { color: var(--ink-soft); overflow-wrap: anywhere; } +.c-pay-copy small { color: var(--ink-soft); font-size: 12px; } +.c-pay-link .a-btn { flex-shrink: 0; display: inline-flex; align-items: center; text-decoration: none; } +.c-pay-mark { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; flex: none; font-weight: 700; font-size: 14px; color: #fff; background: #3c4a46; } +.c-pay-mark.is-monzo { background: #14233c; } +.c-pay-mark.is-paypal { background: #1d4f91; } +.c-pay-mark.is-revolut { background: #191c1f; } +.c-pay-form { display: grid; gap: 4px; padding: 12px; } +.c-pay-form > strong { font-size: 13.5px; margin-bottom: 6px; } +.c-pay-none { font-size: 13px; line-height: 1.6; color: var(--ink-soft); margin: 0 0 18px; } +.is-dark :is(.c-pay-links, .c-pay-editor) { background: transparent; } +.is-dark .c-pay-mark { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); } +@media (max-width: 520px) { + .c-pay-link { flex-wrap: wrap; row-gap: 8px; } + .c-pay-copy { flex: 1 1 calc(100% - 40px); } + .c-pay-link .a-btn:first-of-type { margin-left: 40px; } +} +} diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index e62a32aa..32ab7a6c 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -240,7 +240,7 @@ export function SettleScreen() { ) : (
-
diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 52781301..ebf3aca6 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -82,13 +82,13 @@ export function SettingsScreen() { - diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 5c15872c..8c83a7f3 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -35,6 +35,7 @@ import { type OperationStore, } from "./data/operations"; import { newRequestId } from "./data/requestId"; +import { parsePayLinks, type PayLink, type PayLinkInput, type PayProvider } from "./data/payLinks"; import { COMMUNE_SCREENS, FORM_WINDOWS, addressOf, isNativeNow, keepSearch, readAddress, withCommune } from "./nav"; import { api, post, loadCommunes, loadExpensePage, loadExpenseRecords, loadSnapshot, ApiError } from "./live/api"; import { announceSession, currentViewer, onOtherTabSession, setViewer } from "./live/session"; @@ -279,7 +280,18 @@ export type AppState = { /** Counts the times the finder has opened Search, so Search starts from what it was given. */ searchOpened: number; invites: PendingInvite[]; + /** Browser-only notes from before pay links; nothing shows them now. Kept until preferences drop them. */ payMethods: PayMethod[]; + /** + * How a member likes to be paid, from the snapshot of their commune (the + * open one unless given). Null while only that commune's summary is held. + */ + payLinksFor: (userId: string | null | undefined, communeId?: string) => PayLink[] | null; + /** Your own pay links, loaded on demand. Null until loaded. */ + myPayLinks: PayLink[] | null; + loadMyPayLinks: () => Promise; + savePayLink: (provider: PayProvider, input: PayLinkInput) => Promise; + removePayLink: (provider: PayProvider) => Promise; native: boolean; you: { id: string; name: string; email: string; initials: string; photo?: string }; lastExpense: { name: string; amount: string; paidBy: string } | null; @@ -1617,6 +1629,35 @@ export function AppProvider({ children }: { children: ReactNode }) { await refreshRecords(); return result; }), [mutate, refreshRecords]); + const [myPayLinks, setMyPayLinks] = useState(null); + // Never show one account's links to the next person who signs in here. + useEffect(() => { setMyPayLinks(null); }, [session?.userId]); + const loadMyPayLinks = useCallback(async () => { + const links = parsePayLinks((await api<{ links: unknown }>("/pay-links")).links); + setMyPayLinks(links); + return links; + }, []); + // Saving refreshes the commune too, so the links you see when paying match. + const savePayLink = useCallback((provider: PayProvider, input: PayLinkInput) => mutate(async () => { + const result = await api<{ links: unknown }>(`/pay-links/${provider}`, { method: "PUT", body: JSON.stringify(input) }); + const links = parsePayLinks(result.links); + setMyPayLinks(links); + await refreshRecords(); + return links; + }), [mutate, refreshRecords]); + const removePayLink = useCallback((provider: PayProvider) => mutate(async () => { + const result = await api<{ links: unknown }>(`/pay-links/${provider}`, { method: "DELETE" }); + const links = parsePayLinks(result.links); + setMyPayLinks(links); + await refreshRecords(); + return links; + }), [mutate, refreshRecords]); + const heldSnapshots = data?.snapshots; + const payLinksFor = useCallback((userId: string | null | undefined, communeId?: string) => { + const record = heldSnapshots?.[communeId ?? activeId]; + if (!record || record.detail === "summary") return null; + return userId ? parsePayLinks(record.payLinks?.[userId]) : []; + }, [heldSnapshots, activeId]); const withdrawPaymentReport = useCallback((reportId: string) => mutate(async () => { const result = await post(`/payment-reports/${encodeURIComponent(reportId)}/withdraw`, {}); await refreshRecords(); @@ -1971,7 +2012,8 @@ export function AppProvider({ children }: { children: ReactNode }) { navMode: device.navMode, noteKey, notes, operationKey, operationDate, operationTime, operationsByHub, operations, selectedOperation, fundKey, dark: device.theme === "dark" || (device.theme === "system" && prefersDark), theme: device.theme, noticesOn: device.noticesOn, - searchTarget, searchOpened, invites: data?.invites[activeId] ?? NONE, payMethods: prefs.payMethods, native, you, + searchTarget, searchOpened, invites: data?.invites[activeId] ?? NONE, payMethods: prefs.payMethods, + payLinksFor, myPayLinks, loadMyPayLinks, savePayLink, removePayLink, native, you, lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications, diff --git a/apps/web/src/ui/PayLinks.tsx b/apps/web/src/ui/PayLinks.tsx new file mode 100644 index 00000000..43392b58 --- /dev/null +++ b/apps/web/src/ui/PayLinks.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useApp } from "../state"; +import { money, type CurrencyCode } from "../live/model"; +import { commandFailure } from "../data/commands"; +import { + bankDetailsText, formatSortCode, payLinkHref, payLinkLabel, PAY_PROVIDER_NAME, PAY_PROVIDERS, + type PayLink, type PayLinkInput, type PayProvider, +} from "../data/payLinks"; +import { Button, Feedback, Field } from "./components"; +// Styles sit with the other money surfaces. +import "../screens/Money.css"; + +/** + * How the bill payer likes to be paid, shown to the person who owes them. + * Each link opens the payer's own app; Commune records nothing until the + * person reports the payment. + */ +export function PayeeLinks({ userId, communeId, name, amountMinor, currency, note }: { + userId: string | null | undefined; communeId?: string; name: string; amountMinor: number; currency: CurrencyCode; note?: string; +}) { + const { payLinksFor } = useApp(); + const links = payLinksFor(userId, communeId); + const [copied, setCopied] = useState(false); + const amount = money(amountMinor, currency); + // Not loaded yet (only a commune's summary is held): say nothing rather than "none". + if (links === null) return null; + if (!links.length) { + return

{name} hasn’t added how they like to be paid. Pay them however you usually do.

; + } + async function copy(link: PayLink) { + try { + await navigator.clipboard.writeText(bankDetailsText(link)); + setCopied(true); + } catch { + setCopied(false); + } + } + return
+ {links.map((link) => { + const target = payLinkHref(link, amountMinor, currency, note); + return
+ + + {PAY_PROVIDER_NAME[link.provider]} + {payLinkLabel(link)} + {target ? {target.amountFilled ? `${amount} filled in` : `Enter ${amount} when it opens`} : null} + + {target + ? Open {PAY_PROVIDER_NAME[link.provider]} + : } +
; + })} +
; +} + +const EMPTY: Record = { + monzo: { handle: "" }, paypal: { handle: "" }, revolut: { handle: "" }, + bank: { accountName: "", sortCode: "", accountNumber: "" }, +}; + +function inputFrom(link: PayLink | undefined, provider: PayProvider): PayLinkInput { + if (!link) return { ...EMPTY[provider] }; + return provider === "bank" + ? { accountName: link.accountName ?? "", sortCode: formatSortCode(link.sortCode), accountNumber: link.accountNumber ?? "" } + : { handle: link.handle ?? "" }; +} + +/** Your own pay links: saved in Commune and shown to people in your communes. */ +export function PayLinksEditor() { + const { myPayLinks, loadMyPayLinks, savePayLink, removePayLink } = useApp(); + const [loadError, setLoadError] = useState(""); + const [editing, setEditing] = useState(null); + const [draft, setDraft] = useState({}); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + + useEffect(() => { + let live = true; + loadMyPayLinks().catch((cause) => { if (live) setLoadError(commandFailure(cause, "Could not load your pay links.").message); }); + return () => { live = false; }; + }, [loadMyPayLinks]); + + function start(provider: PayProvider) { + setEditing(provider); + setDraft(inputFrom(myPayLinks?.find((link) => link.provider === provider), provider)); + setError(""); setMessage(""); + } + async function save(event: FormEvent) { + event.preventDefault(); + if (!editing || pending) return; + setPending(true); setError(""); + try { + await savePayLink(editing, draft); + setMessage(`${PAY_PROVIDER_NAME[editing]} saved. People in your communes see it when they owe you.`); + setEditing(null); + } catch (cause) { + setError(commandFailure(cause, "Could not save it.").message); + } finally { + setPending(false); + } + } + async function remove(provider: PayProvider) { + if (pending) return; + setPending(true); setError(""); setMessage(""); + try { + await removePayLink(provider); + setMessage(`${PAY_PROVIDER_NAME[provider]} removed.`); + if (editing === provider) setEditing(null); + } catch (cause) { + setError(commandFailure(cause, "Could not remove it.").message); + } finally { + setPending(false); + } + } + + if (loadError) return ; + if (!myPayLinks) return

Loading your pay links…

; + return
+ {PAY_PROVIDERS.map((provider) => { + const link = myPayLinks.find((item) => item.provider === provider); + if (editing === provider) { + return
void save(event)}> + {PAY_PROVIDER_NAME[provider]} + {provider === "bank" ? <> + + setDraft({ ...draft, accountName: event.target.value })} /> + + + setDraft({ ...draft, sortCode: event.target.value })} /> + + + setDraft({ ...draft, accountNumber: event.target.value })} /> + + : + setDraft({ handle: event.target.value })} /> + } + +
+ + +
+ ; + } + return
+ + + {PAY_PROVIDER_NAME[provider]} + {link ? payLinkLabel(link) : "Not added"} + + + {link ? : null} +
; + })} + {message ?

{message}

: null} +
; +} diff --git a/apps/web/src/ui/Receipts.tsx b/apps/web/src/ui/Receipts.tsx index a56a775e..25e1f490 100644 --- a/apps/web/src/ui/Receipts.tsx +++ b/apps/web/src/ui/Receipts.tsx @@ -8,6 +8,7 @@ import { commandFailure, UNCERTAIN_RETRY_SAFE } from "../data/commands"; import { formatReminderDay, formatTimestampDay } from "../data/reminders"; import { LIMITS } from "../data/limits"; import { Button, Dialog, Feedback, Field } from "./components"; +import { PayeeLinks } from "./PayLinks"; export const RECEIPT_REFERENCE_MAX = 80; // communeId names the commune the share is in, when that may not be the open @@ -22,6 +23,8 @@ export type ReversalTarget = { export type PaymentReportTarget = { shareId: string; payerName: string; expenseName: string; outstandingMinor: number; currency: CurrencyCode; communeId?: string; + /** The bill payer, so their pay links can be shown. */ + payerId?: string; }; export type ReportAnswerTarget = { report: PaymentReport; debtorName: string; expenseName: string; currency: CurrencyCode; @@ -169,6 +172,8 @@ export function ReportPaymentDialog({ target, onClose, onReported }: { return
void save(event)}>

{target.expenseName} · {money(target.outstandingMinor, target.currency)} outstanding

+ {target.payerId ? : null} setAmount(event.target.value)} /> diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index e0b794f9..e843ad9b 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -27,6 +27,7 @@ import { SplitEditor } from "../ui/SplitEditor"; import { moneyInputError, parseMoneyInput } from "../data/moneyInput"; import type { Notification, UnreadNotice } from "../live/model"; import { lazyComponent } from "../shell/lazyComponent"; +import { PayeeLinks, PayLinksEditor } from "../ui/PayLinks"; // Schedule items and announcements open less often: their windows load when // first opened (or once the browser is idle), behind the record skeleton. @@ -188,7 +189,7 @@ function titleFor(id: string): string { profile: "Profile", breakdown: "Breakdown", pay: "Settle a share", - "pay-method": "Pay me", + "pay-method": "How people pay you", "note-new": "Add note", "note-edit": "Edit note", "operation-new": "Add to calendar", @@ -560,7 +561,7 @@ function Expense() { ) : ownDebt && !report ? (
- +
) : null} @@ -823,6 +824,15 @@ function Profile() {
{places.length} {places.length === 1 ? "commune" : "communes"} · billing is not enabled
+
+
+ How people pay you +
Monzo, PayPal, Revolut or bank details
+
+ +
Settings @@ -954,10 +964,12 @@ function Pay() { {selectedExpense ? `You → ${personName(selectedExpense.paidByUserId, "bill payer", { left: true })}` : "Payment"} {formatMoney(outstanding, hub.currency)}

- Commune does not move money or hold payment details yet. Arrange a transfer directly with the bill payer; - they can confirm your individual share here after receiving it. + Commune does not move money. Pay the bill payer in your own app, then tell them in Commune; + they confirm your share after receiving it.

+ {selectedExpense && outstanding > 0 ? : null}
{selectedExpense ? : null} +

When someone owes you, they see these with the amount filled in where the app allows it. Only people in your communes can see them, and leaving a commune stops sharing them there.

+ +

Commune never moves money and never sees your bank login. These are only the details you choose to share.

+ ); } diff --git a/apps/web/tests/pay-links.test.tsx b/apps/web/tests/pay-links.test.tsx new file mode 100644 index 00000000..8222a2d2 --- /dev/null +++ b/apps/web/tests/pay-links.test.tsx @@ -0,0 +1,156 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { App } from "../src/App"; +import { AppProvider } from "../src/state"; +import { api, loadCommunes, loadSnapshot } from "../src/live/api"; +import type { Commune, Snapshot } from "../src/live/model"; +import { formatSortCode, parsePayLinks, payLinkHref, payLinkLabel, type PayLink } from "../src/data/payLinks"; + +vi.mock("../src/live/api", async (importOriginal) => ({ + ...(await importOriginal()), + api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(), +})); + +const monzo: PayLink = { provider: "monzo", handle: "samokafor", accountName: null, sortCode: null, accountNumber: null }; +const paypal: PayLink = { provider: "paypal", handle: "samokafor", accountName: null, sortCode: null, accountNumber: null }; +const revolut: PayLink = { provider: "revolut", handle: "sam.o", accountName: null, sortCode: null, accountNumber: null }; +const bank: PayLink = { provider: "bank", handle: null, accountName: "Sam Okafor", sortCode: "204577", accountNumber: "41029388" }; + +describe("pay link addresses", () => { + it("fills in the amount where the provider allows it", () => { + expect(payLinkHref(monzo, 1600, "GBP", "Printer ink")).toEqual({ href: "https://monzo.me/samokafor/16.00?d=Printer%20ink", amountFilled: true }); + expect(payLinkHref(paypal, 2160, "EUR")).toEqual({ href: "https://paypal.me/samokafor/21.60EUR", amountFilled: true }); + expect(payLinkHref(revolut, 1600, "GBP")).toEqual({ href: "https://revolut.me/sam.o", amountFilled: false }); + expect(payLinkHref(bank, 1600, "GBP")).toBeNull(); + }); + + it("never puts a non-pound amount into a Monzo link", () => { + expect(payLinkHref(monzo, 1600, "EUR")).toEqual({ href: "https://monzo.me/samokafor", amountFilled: false }); + }); + + it("reads only well-formed links, in a fixed order", () => { + const links = parsePayLinks([ + { provider: "bank", accountName: "Sam Okafor", sortCode: "204577", accountNumber: "41029388" }, + { provider: "venmo", handle: "sam" }, + { provider: "paypal", handle: "" }, + { provider: "monzo", handle: "samokafor" }, + "nonsense", + ]); + expect(links.map((link) => link.provider)).toEqual(["monzo", "bank"]); + expect(parsePayLinks(undefined)).toEqual([]); + }); + + it("shows bank details in the usual UK form", () => { + expect(formatSortCode("204577")).toBe("20-45-77"); + expect(payLinkLabel(bank)).toBe("Sam Okafor · 20-45-77 · 41029388"); + expect(payLinkLabel(monzo)).toBe("monzo.me/samokafor"); + }); +}); + +const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true }; +const commune: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "member" }; +let current: Snapshot; +let saved: PayLink[]; +function snapshot(): Snapshot { + return { + commune, + members: [ + { userId: user.id, name: user.name, role: "member", active: true }, + { userId: "user-2", name: "Sam", role: "admin", active: true }, + ], + expenses: [{ + id: "expense-1", title: "Printer ink", category: "Other", amountMinor: 3200, paidByUserId: "user-2", + dueOn: "2026-01-05", createdAt: "2026-01-01T10:00:00Z", + shares: [ + { id: "share-payer", userId: "user-2", amountMinor: 1600, settledMinor: 1600 }, + { id: "share-mine", userId: user.id, amountMinor: 1600, settledMinor: 0, paymentReports: [] }, + ], + }], + funds: [], notes: [], operations: [], notifications: [], reminderTargets: [], sentReminders: [], + recurringBills: [], splitTemplates: [], activityEvents: [], + payLinks: { "user-2": [monzo, bank] }, + }; +} +function mount(path: string) { + const location = memoryLocation({ path, record: true }); + render(); +} +const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal"); +const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close"); +const originalScroll = Element.prototype.scrollIntoView; +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: function (this: HTMLDialogElement) { this.removeAttribute("open"); } }); +}); +afterAll(() => { + Element.prototype.scrollIntoView = originalScroll; + if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor); + else delete (HTMLDialogElement.prototype as unknown as Record).showModal; + if (closeDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "close", closeDescriptor); + else delete (HTMLDialogElement.prototype as unknown as Record).close; +}); +beforeEach(() => { + vi.resetAllMocks(); + sessionStorage.clear(); + current = snapshot(); + saved = []; + vi.mocked(api).mockImplementation(async (path, init) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + if (path === "/pay-links") return { links: saved }; + if (path === "/pay-links/monzo" && init?.method === "PUT") { + saved = [{ ...monzo, handle: JSON.parse(String(init.body)).handle }]; + return { links: saved }; + } + if (path === "/pay-links/monzo" && init?.method === "DELETE") { saved = []; return { links: saved }; } + return { ok: true }; + }); + vi.mocked(loadCommunes).mockResolvedValue({ communes: [commune] }); + vi.mocked(loadSnapshot).mockImplementation(async () => current); +}); +afterEach(cleanup); + +describe("paying someone who has shared their pay links", () => { + it("offers their Monzo with the amount filled in, and their bank details, before you report it", async () => { + mount("/settle"); + fireEvent.click(await screen.findByRole("button", { name: /^I’ve sent it/ })); + const dialog = screen.getByRole("dialog", { name: "Tell Sam you’ve paid" }); + const open = within(dialog).getByRole("link", { name: "Open Monzo" }); + expect(open.getAttribute("href")).toBe("https://monzo.me/samokafor/16.00?d=Printer%20ink"); + expect(open.getAttribute("rel")).toContain("noopener"); + expect(within(dialog).getByText("£16.00 filled in")).toBeTruthy(); + expect(within(dialog).getByText("Sam Okafor · 20-45-77 · 41029388")).toBeTruthy(); + expect(within(dialog).getByRole("button", { name: "Copy details" })).toBeTruthy(); + }); + + it("says plainly when the bill payer has not added any", async () => { + current.payLinks = {}; + mount("/settle"); + fireEvent.click(await screen.findByRole("button", { name: /^I’ve sent it/ })); + const dialog = screen.getByRole("dialog", { name: "Tell Sam you’ve paid" }); + expect(within(dialog).queryByRole("link")).toBeNull(); + expect(within(dialog).getByText(/Sam hasn’t added how they like to be paid/)).toBeTruthy(); + }); +}); + +describe("your own pay links", () => { + it("saves a Monzo username in Commune and can remove it", async () => { + mount("/settings"); + fireEvent.click(await screen.findByRole("button", { name: /How people pay you/ })); + const add = await screen.findAllByRole("button", { name: "Add" }); + fireEvent.click(add[0]); + fireEvent.change(screen.getByLabelText(/Monzo username/), { target: { value: "testperson" } }); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save" })); }); + const put = vi.mocked(api).mock.calls.find(([path, init]) => path === "/pay-links/monzo" && init?.method === "PUT"); + expect(JSON.parse(String(put?.[1]?.body))).toEqual({ handle: "testperson" }); + expect(await screen.findByText("monzo.me/testperson")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toContain("People in your communes see it when they owe you"); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Remove" })); }); + expect(vi.mocked(api).mock.calls.some(([path, init]) => path === "/pay-links/monzo" && init?.method === "DELETE")).toBe(true); + expect(screen.getByRole("status").textContent).toBe("Monzo removed."); + }); +}); diff --git a/infra/local/README.md b/infra/local/README.md index 71383e49..0064551f 100644 --- a/infra/local/README.md +++ b/infra/local/README.md @@ -45,7 +45,9 @@ files in order using a database owner connection: 39. `infra/neon/migrations/0038_invite_hint_and_client_reports.sql` 40. `infra/neon/migrations/0039_notices_and_activity.sql` 41. `infra/neon/migrations/0040_search_dates.sql` -42. `infra/local/after-core.sql` +42. `infra/neon/migrations/0041_task_done_by.sql` +43. `infra/neon/migrations/0042_pay_links.sql` +44. `infra/local/after-core.sql` `after-core.sql` is idempotent. Re-run it after applying a newer migration to an existing local database so the local-only reminder helpers stay current. diff --git a/infra/neon/README.md b/infra/neon/README.md index e6247656..168bfdd2 100644 --- a/infra/neon/README.md +++ b/infra/neon/README.md @@ -14,6 +14,11 @@ this is not a replacement of Neon Auth or a switch back to Supabase. - [Current design language and preserved layouts](../../docs/DESIGN-LANGUAGE.md) - [Local build-out from 5 October: migrations 0020 onwards](../../docs/LOCAL-BUILDOUT-2026-10-05.md) +Pay links, **0042** (local only), add `my_pay_links`, `set_pay_link`, +`remove_pay_link` and `payLinks` in the full snapshot (not the summary). +Apply it after 0041, then deploy the client that calls them. The previous +client ignores the new snapshot field. Test: `tests/pay_links.sql`. + Migrations **0015 to 0041 are local only**. They have not been applied to any Neon branch. Release order when they are: apply 0013 to 0041 in numeric order to the preview branch, refresh that branch's Data API schema cache, then diff --git a/infra/neon/migrations/0042_pay_links.sql b/infra/neon/migrations/0042_pay_links.sql new file mode 100644 index 00000000..a013edb8 --- /dev/null +++ b/infra/neon/migrations/0042_pay_links.sql @@ -0,0 +1,141 @@ +-- Pay links: how each person likes to be paid (a Monzo, PayPal or Revolut +-- username, or UK bank details). People who share a commune with them see +-- these when they owe them, so they can open their own banking app with the +-- amount filled in and then report the payment in Commune as before. +-- Commune still never moves money; these are details a person chose to share. +-- Numbered 0042 so it cannot collide with the open review branches +-- (0026 to 0041). It depends only on 0001 and the 0025 snapshot shape. +BEGIN; + +CREATE TABLE public.member_pay_links ( + user_id text NOT NULL, + provider text NOT NULL CHECK (provider IN ('monzo', 'paypal', 'revolut', 'bank')), + handle text CHECK (handle ~ '^[A-Za-z0-9._-]{1,40}$'), + account_name text CHECK (length(btrim(account_name)) BETWEEN 1 AND 60), + sort_code text CHECK (sort_code ~ '^[0-9]{6}$'), + account_number text CHECK (account_number ~ '^[0-9]{8}$'), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, provider), + CHECK (CASE WHEN provider = 'bank' + THEN handle IS NULL AND account_name IS NOT NULL AND sort_code IS NOT NULL AND account_number IS NOT NULL + ELSE handle IS NOT NULL AND account_name IS NULL AND sort_code IS NULL AND account_number IS NULL END) +); + +ALTER TABLE public.member_pay_links ENABLE ROW LEVEL SECURITY; +-- Deliberately no grant at all: a person's own links come from my_pay_links(), +-- and other people's only through the snapshot of a commune they share. + +CREATE FUNCTION commune_private.pay_links_for(p_user_id text) +RETURNS jsonb LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT coalesce(jsonb_agg(jsonb_build_object('provider', l.provider, 'handle', l.handle, + 'accountName', l.account_name, 'sortCode', l.sort_code, 'accountNumber', l.account_number, + 'updatedAt', l.updated_at) + ORDER BY array_position(ARRAY['monzo', 'paypal', 'revolut', 'bank'], l.provider)), '[]'::jsonb) + FROM public.member_pay_links l WHERE l.user_id = p_user_id; +$$; +REVOKE ALL ON FUNCTION commune_private.pay_links_for(text) FROM PUBLIC; + +CREATE FUNCTION public.my_pay_links() +RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE + v_actor text := commune_private.data_api_user_id(); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'Sign in to see how people pay you' USING ERRCODE = '42501'; + END IF; + RETURN commune_private.pay_links_for(v_actor); +END; +$$; + +-- Add or replace one way to be paid. Saving the same details again is harmless. +CREATE FUNCTION public.set_pay_link(p_provider text, p_handle text, p_account_name text, + p_sort_code text, p_account_number text) +RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE + v_actor text := commune_private.data_api_user_id(); + v_provider text := lower(btrim(coalesce(p_provider, ''))); + v_handle text := nullif(btrim(coalesce(p_handle, '')), ''); + v_name text := nullif(btrim(coalesce(p_account_name, '')), ''); + v_sort text := nullif(regexp_replace(coalesce(p_sort_code, ''), '[\s-]', '', 'g'), ''); + v_account text := nullif(regexp_replace(coalesce(p_account_number, ''), '\s', '', 'g'), ''); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'Sign in to save how people pay you' USING ERRCODE = '42501'; + END IF; + IF v_provider NOT IN ('monzo', 'paypal', 'revolut', 'bank') THEN + RAISE EXCEPTION 'Choose Monzo, PayPal, Revolut or bank transfer' USING ERRCODE = '22023'; + END IF; + IF v_provider = 'bank' THEN + IF v_name IS NULL OR length(v_name) > 60 THEN + RAISE EXCEPTION 'Enter the name on the account, up to 60 characters' USING ERRCODE = '22023'; + END IF; + IF v_sort IS NULL OR v_sort !~ '^[0-9]{6}$' THEN + RAISE EXCEPTION 'Enter a six-digit sort code, for example 20-45-77' USING ERRCODE = '22023'; + END IF; + IF v_account IS NULL OR v_account !~ '^[0-9]{8}$' THEN + RAISE EXCEPTION 'Enter an eight-digit account number' USING ERRCODE = '22023'; + END IF; + v_handle := NULL; + ELSE + -- Accept a pasted link or an @name and keep only the username. + v_handle := regexp_replace(coalesce(v_handle, ''), '^(https?://)?(www\.)?(monzo\.me|paypal\.me|paypal\.com/paypalme|revolut\.me)/', '', 'i'); + v_handle := regexp_replace(regexp_replace(v_handle, '^@', ''), '/.*$', ''); + IF v_handle !~ '^[A-Za-z0-9._-]{1,40}$' THEN + RAISE EXCEPTION 'Enter your username: letters, numbers, dots, dashes or underscores, up to 40' USING ERRCODE = '22023'; + END IF; + v_name := NULL; v_sort := NULL; v_account := NULL; + END IF; + INSERT INTO public.member_pay_links (user_id, provider, handle, account_name, sort_code, account_number) + VALUES (v_actor, v_provider, v_handle, v_name, v_sort, v_account) + ON CONFLICT (user_id, provider) DO UPDATE SET handle = EXCLUDED.handle, account_name = EXCLUDED.account_name, + sort_code = EXCLUDED.sort_code, account_number = EXCLUDED.account_number, updated_at = now(); + RETURN commune_private.pay_links_for(v_actor); +END; +$$; + +-- Stop offering one way to be paid. Removing one that is not there is harmless. +CREATE FUNCTION public.remove_pay_link(p_provider text) +RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE + v_actor text := commune_private.data_api_user_id(); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'Sign in to change how people pay you' USING ERRCODE = '42501'; + END IF; + DELETE FROM public.member_pay_links WHERE user_id = v_actor AND provider = lower(btrim(coalesce(p_provider, ''))); + RETURN commune_private.pay_links_for(v_actor); +END; +$$; + +-- Everyone in the commune, keyed by user id. Only current members appear, +-- so leaving a commune stops sharing your details with it. +CREATE FUNCTION commune_private.snapshot_pay_links(p_commune_id uuid) +RETURNS jsonb LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT coalesce(jsonb_object_agg(m.user_id, commune_private.pay_links_for(m.user_id)), '{}'::jsonb) + FROM public.commune_members m + WHERE m.commune_id = p_commune_id AND m.active + AND EXISTS (SELECT 1 FROM public.member_pay_links l WHERE l.user_id = m.user_id); +$$; +REVOKE ALL ON FUNCTION commune_private.snapshot_pay_links(uuid) FROM PUBLIC; + +DO $$ +DECLARE v_original text; v_source text; +BEGIN + v_original := pg_get_functiondef('public.commune_snapshot(uuid)'::regprocedure); + v_source := replace(v_original, '''announcements'',commune_private.snapshot_announcements(co.id),', + '''announcements'',commune_private.snapshot_announcements(co.id), + ''payLinks'',commune_private.snapshot_pay_links(co.id),'); + IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported snapshot definition'; END IF; + EXECUTE v_source; +END; +$$; + +REVOKE ALL ON FUNCTION public.my_pay_links() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.my_pay_links() TO authenticated; +REVOKE ALL ON FUNCTION public.set_pay_link(text, text, text, text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.set_pay_link(text, text, text, text, text) TO authenticated; +REVOKE ALL ON FUNCTION public.remove_pay_link(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.remove_pay_link(text) TO authenticated; + +NOTIFY pgrst, 'reload schema'; +COMMIT; diff --git a/infra/neon/tests/grants.sql b/infra/neon/tests/grants.sql index 91e89f58..9fdafac9 100644 --- a/infra/neon/tests/grants.sql +++ b/infra/neon/tests/grants.sql @@ -13,7 +13,7 @@ DECLARE 'split_templates', 'commune_events', 'expense_share_receipts', 'expense_revisions', 'operation_occurrences', 'expense_share_payment_reports', 'note_revisions', 'commune_announcements', 'announcement_acknowledgements', - 'commune_notes', 'commune_operations' + 'commune_notes', 'commune_operations', 'member_pay_links' ]; BEGIN FOREACH table_name IN ARRAY denied_tables LOOP @@ -31,6 +31,14 @@ BEGIN END IF; END LOOP; + IF has_table_privilege('authenticated', 'public.member_pay_links', 'SELECT') THEN + RAISE EXCEPTION 'Pay links must be read only through my_pay_links and the snapshot'; + END IF; + IF has_function_privilege('anonymous', 'public.set_pay_link(text,text,text,text,text)', 'EXECUTE') + OR has_function_privilege('anonymous', 'public.my_pay_links()', 'EXECUTE') + OR has_function_privilege('anonymous', 'public.remove_pay_link(text)', 'EXECUTE') THEN + RAISE EXCEPTION 'Anonymous pay link execution grant'; + END IF; IF has_function_privilege('anonymous', 'public.commune_snapshot(uuid)', 'EXECUTE') OR NOT has_function_privilege('authenticated', 'public.commune_snapshot(uuid)', 'EXECUTE') THEN RAISE EXCEPTION 'Unexpected snapshot execution grant'; diff --git a/infra/neon/tests/pay_links.sql b/infra/neon/tests/pay_links.sql new file mode 100644 index 00000000..2e964cc7 --- /dev/null +++ b/infra/neon/tests/pay_links.sql @@ -0,0 +1,151 @@ +-- Run only in an empty disposable local DB after local_auth_stub.sql and +-- migrations 0001 through 0025 and 0042. Real Neon verification must use signed JWTs. +BEGIN; + +DO $$ +DECLARE + v_home uuid; + v_other uuid; +BEGIN + INSERT INTO neon_auth."user" (id, name, email, "emailVerified") + VALUES ('pay-alice', 'Alice', 'alice@pay.test', true), + ('pay-bob', 'Bob', 'bob@pay.test', true), + ('pay-carol', 'Carol', 'carol@pay.test', true); + PERFORM set_config('request.jwt.claims', json_build_object('sub', 'pay-alice', 'role', 'authenticated')::text, true); + v_home := public.create_commune('Pay home', 'household', 'GBP'); + INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'pay-bob', 'member'); + PERFORM set_config('test.pay_home', v_home::text, true); + PERFORM set_config('request.jwt.claims', json_build_object('sub', 'pay-carol', 'role', 'authenticated')::text, true); + v_other := public.create_commune('Carol home', 'household', 'GBP'); + PERFORM set_config('test.pay_other', v_other::text, true); +END; +$$; + +SET ROLE authenticated; + +-- Alice saves her links. Pasted links and @names keep only the username. +SELECT set_config('request.jwt.claims', json_build_object('sub', 'pay-alice', 'role', 'authenticated')::text, true); +DO $$ +DECLARE + v_links jsonb; +BEGIN + PERFORM public.set_pay_link('Monzo', 'https://monzo.me/alice-h/16.00', NULL, NULL, NULL); + PERFORM public.set_pay_link('paypal', '@AliceH', 'ignored', '000000', '00000000'); + v_links := public.set_pay_link('bank', 'ignored', ' Alice Hale ', '20-45-77', '4102 9388'); + IF jsonb_array_length(v_links) <> 3 THEN RAISE EXCEPTION 'Expected three links, got %', v_links; END IF; + IF v_links->0->>'provider' <> 'monzo' OR v_links->0->>'handle' <> 'alice-h' THEN + RAISE EXCEPTION 'Monzo link was not reduced to its username: %', v_links->0; + END IF; + IF v_links->1->>'handle' <> 'AliceH' OR v_links->1->>'sortCode' IS NOT NULL THEN + RAISE EXCEPTION 'PayPal link kept the wrong fields: %', v_links->1; + END IF; + IF v_links->2->>'accountName' <> 'Alice Hale' OR v_links->2->>'sortCode' <> '204577' + OR v_links->2->>'accountNumber' <> '41029388' OR v_links->2->>'handle' IS NOT NULL THEN + RAISE EXCEPTION 'Bank details were not normalised: %', v_links->2; + END IF; + -- Saving again replaces rather than duplicates. + v_links := public.set_pay_link('monzo', 'alicehale', NULL, NULL, NULL); + IF jsonb_array_length(v_links) <> 3 OR v_links->0->>'handle' <> 'alicehale' THEN + RAISE EXCEPTION 'Saving again did not replace the Monzo link: %', v_links; + END IF; + + BEGIN PERFORM public.set_pay_link('venmo', 'alice', NULL, NULL, NULL); + RAISE EXCEPTION 'An unknown provider was accepted'; + EXCEPTION WHEN SQLSTATE '22023' THEN NULL; END; + BEGIN PERFORM public.set_pay_link('monzo', 'alice hale', NULL, NULL, NULL); + RAISE EXCEPTION 'A username with a space was accepted'; + EXCEPTION WHEN SQLSTATE '22023' THEN NULL; END; + BEGIN PERFORM public.set_pay_link('revolut', '', NULL, NULL, NULL); + RAISE EXCEPTION 'An empty username was accepted'; + EXCEPTION WHEN SQLSTATE '22023' THEN NULL; END; + BEGIN PERFORM public.set_pay_link('bank', NULL, 'Alice', '20457', '41029388'); + RAISE EXCEPTION 'A five-digit sort code was accepted'; + EXCEPTION WHEN SQLSTATE '22023' THEN NULL; END; + BEGIN PERFORM public.set_pay_link('bank', NULL, 'Alice', '204577', '4102938'); + RAISE EXCEPTION 'A seven-digit account number was accepted'; + EXCEPTION WHEN SQLSTATE '22023' THEN NULL; END; + BEGIN PERFORM public.set_pay_link('bank', NULL, '', '204577', '41029388'); + RAISE EXCEPTION 'A bank account without a name was accepted'; + EXCEPTION WHEN SQLSTATE '22023' THEN NULL; END; + + -- No direct access to the table. + BEGIN PERFORM 1 FROM public.member_pay_links; + RAISE EXCEPTION 'The table was readable directly'; + EXCEPTION WHEN SQLSTATE '42501' THEN NULL; END; + BEGIN INSERT INTO public.member_pay_links (user_id, provider, handle) VALUES ('pay-bob', 'monzo', 'forged'); + RAISE EXCEPTION 'The table was writable directly'; + EXCEPTION WHEN SQLSTATE '42501' THEN NULL; END; +END; +$$; + +-- Bob shares a commune with Alice: he sees her links, and his own list is empty. +SELECT set_config('request.jwt.claims', json_build_object('sub', 'pay-bob', 'role', 'authenticated')::text, true); +DO $$ +DECLARE + v_home uuid := current_setting('test.pay_home')::uuid; + v_links jsonb; +BEGIN + v_links := public.commune_snapshot(v_home)->'payLinks'; + IF jsonb_array_length(v_links->'pay-alice') <> 3 THEN + RAISE EXCEPTION 'A commune member did not see Alice''s links: %', v_links; + END IF; + IF v_links ? 'pay-bob' THEN RAISE EXCEPTION 'Bob appeared without any links'; END IF; + IF jsonb_array_length(public.my_pay_links()) <> 0 THEN RAISE EXCEPTION 'Bob was given someone else''s links'; END IF; + -- Bob can only change his own. + PERFORM public.remove_pay_link('monzo'); +END; +$$; + +-- Carol shares no commune with Alice and cannot see her links. +SELECT set_config('request.jwt.claims', json_build_object('sub', 'pay-carol', 'role', 'authenticated')::text, true); +DO $$ +DECLARE + v_home uuid := current_setting('test.pay_home')::uuid; + v_other uuid := current_setting('test.pay_other')::uuid; +BEGIN + IF public.commune_snapshot(v_other)->'payLinks' ? 'pay-alice' THEN + RAISE EXCEPTION 'Alice''s links leaked into a commune she is not in'; + END IF; + BEGIN + IF public.commune_snapshot(v_home)->'payLinks' ? 'pay-alice' THEN + RAISE EXCEPTION 'An outsider read Alice''s links'; + END IF; + EXCEPTION WHEN SQLSTATE '42501' THEN NULL; END; +END; +$$; + +-- Alice still has all three, removes one, and leaving the commune stops sharing. +SELECT set_config('request.jwt.claims', json_build_object('sub', 'pay-alice', 'role', 'authenticated')::text, true); +DO $$ +DECLARE + v_links jsonb; +BEGIN + v_links := public.my_pay_links(); + IF jsonb_array_length(v_links) <> 3 OR v_links->0->>'handle' <> 'alicehale' THEN + RAISE EXCEPTION 'Bob changed Alice''s links: %', v_links; + END IF; + v_links := public.remove_pay_link('paypal'); + IF jsonb_array_length(v_links) <> 2 OR v_links @> '[{"provider":"paypal"}]' THEN + RAISE EXCEPTION 'PayPal was not removed: %', v_links; + END IF; + v_links := public.remove_pay_link('paypal'); + IF jsonb_array_length(v_links) <> 2 THEN RAISE EXCEPTION 'Removing twice changed something'; END IF; +END; +$$; + +RESET ROLE; +-- A departure keeps the membership row, marked inactive. +UPDATE public.commune_members SET active = false, left_at = now() + WHERE user_id = 'pay-alice' AND commune_id = current_setting('test.pay_home')::uuid; +SET ROLE authenticated; +SELECT set_config('request.jwt.claims', json_build_object('sub', 'pay-bob', 'role', 'authenticated')::text, true); +DO $$ +BEGIN + IF public.commune_snapshot(current_setting('test.pay_home')::uuid)->'payLinks' ? 'pay-alice' THEN + RAISE EXCEPTION 'Links were still shared after Alice left'; + END IF; +END; +$$; + +RESET ROLE; +ROLLBACK; From 1652b99c2392be812d6c1b5a5dfdf6d4f3355603 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:05:25 +0000 Subject: [PATCH 2/6] Add the Commune phone app in Expo (apps/native) A native iPhone and Android app built from the approved phone design: Home Direction B, the five-tab bar with More, and the "I've paid" sheet with the bill payer's pay links. It runs on demo data kept on the phone, or against the local API, using the same snapshots and database commands as the web app. Totals stay per currency and payments stay claims until the bill payer confirms them. It keeps its own lockfile outside the pnpm workspace so the web CI install is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TFug6Zkjw7p11FxL3guDfE --- CLAUDE.md | 2 + apps/native/.gitignore | 6 + apps/native/.npmrc | 3 + apps/native/README.md | 57 + apps/native/app.json | 28 + apps/native/app/(tabs)/_layout.tsx | 61 + apps/native/app/(tabs)/expenses.tsx | 74 + apps/native/app/(tabs)/hub.tsx | 62 + apps/native/app/(tabs)/index.tsx | 101 + apps/native/app/(tabs)/more.tsx | 56 + apps/native/app/(tabs)/settle.tsx | 131 + apps/native/app/_layout.tsx | 43 + apps/native/app/account.tsx | 42 + apps/native/app/add-expense.tsx | 120 + apps/native/app/expense/[id].tsx | 88 + apps/native/app/funds.tsx | 19 + apps/native/app/members.tsx | 17 + apps/native/app/notes.tsx | 17 + apps/native/app/notifications.tsx | 58 + apps/native/app/pay-links.tsx | 97 + apps/native/app/record/[shareId].tsx | 137 + apps/native/app/recurring.tsx | 18 + apps/native/app/sign-in.tsx | 39 + apps/native/app/switch.tsx | 28 + apps/native/app/tasks.tsx | 28 + apps/native/app/templates.tsx | 15 + apps/native/expo-env.d.ts | 1 + apps/native/package.json | 39 + apps/native/pnpm-lock.yaml | 7654 ++++++++++++++++++++++++++ apps/native/src/lib/backend.ts | 90 + apps/native/src/lib/demo.ts | 246 + apps/native/src/lib/derive.ts | 305 + apps/native/src/lib/format.ts | 132 + apps/native/src/lib/payLinks.ts | 84 + apps/native/src/lib/types.ts | 137 + apps/native/src/state.tsx | 194 + apps/native/src/ui/chrome.tsx | 73 + apps/native/src/ui/kit.tsx | 192 + apps/native/src/ui/parts.tsx | 185 + apps/native/src/ui/theme.ts | 84 + apps/native/src/ui/toast.tsx | 36 + apps/native/tests/derive.test.ts | 94 + apps/native/tsconfig.json | 10 + pnpm-workspace.yaml | 3 + 44 files changed, 10906 insertions(+) create mode 100644 apps/native/.gitignore create mode 100644 apps/native/.npmrc create mode 100644 apps/native/README.md create mode 100644 apps/native/app.json create mode 100644 apps/native/app/(tabs)/_layout.tsx create mode 100644 apps/native/app/(tabs)/expenses.tsx create mode 100644 apps/native/app/(tabs)/hub.tsx create mode 100644 apps/native/app/(tabs)/index.tsx create mode 100644 apps/native/app/(tabs)/more.tsx create mode 100644 apps/native/app/(tabs)/settle.tsx create mode 100644 apps/native/app/_layout.tsx create mode 100644 apps/native/app/account.tsx create mode 100644 apps/native/app/add-expense.tsx create mode 100644 apps/native/app/expense/[id].tsx create mode 100644 apps/native/app/funds.tsx create mode 100644 apps/native/app/members.tsx create mode 100644 apps/native/app/notes.tsx create mode 100644 apps/native/app/notifications.tsx create mode 100644 apps/native/app/pay-links.tsx create mode 100644 apps/native/app/record/[shareId].tsx create mode 100644 apps/native/app/recurring.tsx create mode 100644 apps/native/app/sign-in.tsx create mode 100644 apps/native/app/switch.tsx create mode 100644 apps/native/app/tasks.tsx create mode 100644 apps/native/app/templates.tsx create mode 100644 apps/native/expo-env.d.ts create mode 100644 apps/native/package.json create mode 100644 apps/native/pnpm-lock.yaml create mode 100644 apps/native/src/lib/backend.ts create mode 100644 apps/native/src/lib/demo.ts create mode 100644 apps/native/src/lib/derive.ts create mode 100644 apps/native/src/lib/format.ts create mode 100644 apps/native/src/lib/payLinks.ts create mode 100644 apps/native/src/lib/types.ts create mode 100644 apps/native/src/state.tsx create mode 100644 apps/native/src/ui/chrome.tsx create mode 100644 apps/native/src/ui/kit.tsx create mode 100644 apps/native/src/ui/parts.tsx create mode 100644 apps/native/src/ui/theme.ts create mode 100644 apps/native/src/ui/toast.tsx create mode 100644 apps/native/tests/derive.test.ts create mode 100644 apps/native/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index 70ab0b7c..963a66cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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 diff --git a/apps/native/.gitignore b/apps/native/.gitignore new file mode 100644 index 00000000..245ee81e --- /dev/null +++ b/apps/native/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.expo/ +dist/ +ios/ +android/ +*.tsbuildinfo diff --git a/apps/native/.npmrc b/apps/native/.npmrc new file mode 100644 index 00000000..d05f5bd5 --- /dev/null +++ b/apps/native/.npmrc @@ -0,0 +1,3 @@ +ignore-workspace=true +auto-install-peers=true +node-linker=hoisted diff --git a/apps/native/README.md b/apps/native/README.md new file mode 100644 index 00000000..55dc1b14 --- /dev/null +++ b/apps/native/README.md @@ -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`. diff --git a/apps/native/app.json b/apps/native/app.json new file mode 100644 index 00000000..2999a234 --- /dev/null +++ b/apps/native/app.json @@ -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 } + } +} diff --git a/apps/native/app/(tabs)/_layout.tsx b/apps/native/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..b22875e3 --- /dev/null +++ b/apps/native/app/(tabs)/_layout.tsx @@ -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 + {TABS.map((tab, index) => { + const on = state.index === index; + const color = on ? c.onFrame : c.onFrame2; + return { + 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 }}> + + + + {tab.label} + ; + })} + ; +} + +export default function TabsLayout() { + const { c } = useTheme(); + const { status, error, refresh } = useApp(); + if (status === "signed-out") return ; + if (status === "starting" || status === "loading") { + return + commune. + + ; + } + if (status === "error") { + return + Commune didn’t load + {error} + ); } diff --git a/apps/web/src/ui/Toast.tsx b/apps/web/src/ui/Toast.tsx new file mode 100644 index 00000000..644ea38e --- /dev/null +++ b/apps/web/src/ui/Toast.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { cx } from "./cx"; +import { motionMs } from "./motion"; + +/** + * One line near the thumb for every recording act (design panel, 11 October + * 2026): it names what was recorded and who now knows. It replaces the line + * at the top of Settle, the nudge's result page and add expense's silence. + * Errors stay beside the form they are about. + */ +export type ToastAction = { + label: string; + run: () => void | Promise; + /** Undo keeps the line for 9 seconds, and takes focus when the act removed the control that had it. */ + undo?: boolean; + /** What the action button is called while its own command runs. */ + busyLabel?: string; +}; +export type Toast = { text: string; action?: ToastAction }; +type Shown = Toast & { id: number }; + +/** How long a line stays: 6 seconds, or 9 with Undo. Hover or focus holds it. */ +export const TOAST_MS = 6000; +export const TOAST_UNDO_MS = 9000; + +let current: Shown | null = null; +let nextId = 1; +const listeners = new Set<() => void>(); +function emit() { for (const listener of listeners) listener(); } + +export function showToast(toast: Toast): void { + current = { ...toast, id: nextId++ }; + emit(); +} +export function hideToast(): void { + if (!current) return; + current = null; + emit(); +} +/** The line on screen now, for tests and for the host. */ +export function currentToast(): Toast | null { + return current; +} +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +/** "Sam", "Sam and Alex", "Sam, Alex and Jordan", or "Sam, Alex, Jordan and 2 others". */ +export function namesInWords(names: string[], most = 3): string { + if (names.length > most + 1) return `${names.slice(0, most).join(", ")} and ${names.length - most} others`; + if (names.length < 2) return names[0] ?? ""; + return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`; +} +export const firstWord = (name: string) => name.trim().split(/\s+/)[0] || name; + +/** + * Where the line appears: 16px above the tab bar on a phone, at the bottom + * left of the content beside the sidebar. It rises 16px and fades in over the + * medium time, and fades out over the short one. role="status" is always + * there, so each new line is announced. + */ +export function ToastHost() { + const toast = useSyncExternalStore(subscribe, () => current, () => null); + const [leaving, setLeaving] = useState(null); + const [busy, setBusy] = useState(false); + const box = useRef(null); + const timer = useRef(undefined); + const held = useRef(false); + const shownRef = useRef(null); + + // A new line replaces the last at once; one that is closing fades out. + useEffect(() => { + const previous = shownRef.current; + shownRef.current = toast; + const out = motionMs("short"); + if (!toast && previous && out) { + setLeaving(previous); + const done = window.setTimeout(() => setLeaving(null), out); + return () => window.clearTimeout(done); + } + setLeaving(null); + return undefined; + }, [toast]); + + // Off the page, the line goes with it. + useEffect(() => () => { window.clearTimeout(timer.current); hideToast(); }, []); + + useEffect(() => { + window.clearTimeout(timer.current); + held.current = false; + setBusy(false); + if (!toast) return; + const element = box.current; + // When the act took away the control that had focus, Undo takes it, where + // the keyboard can reach it in time. + const active = document.activeElement; + if (toast.action?.undo && element && (!active || active === document.body || !active.isConnected)) { + element.querySelector(".c-toast-action")?.focus(); + } + held.current = Boolean(element && (element.contains(document.activeElement) || element.matches(":hover"))); + arm(toast); + return () => window.clearTimeout(timer.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toast]); + + function arm(shown: Shown) { + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => { + if (held.current || current?.id !== shown.id) return; + const hadFocus = box.current?.contains(document.activeElement); + hideToast(); + if (hadFocus) document.querySelector(".a-header-title h1, #main-content")?.focus({ preventScroll: true }); + }, shown.action?.undo ? TOAST_UNDO_MS : TOAST_MS); + } + function hold(on: boolean) { + held.current = on; + if (!on && toast) arm(toast); + } + async function act(shown: Shown) { + if (!shown.action || busy) return; + setBusy(true); + try { + await shown.action.run(); + } finally { + setBusy(false); + // The action's own result replaces this line; otherwise it has done its job. + if (current?.id === shown.id) hideToast(); + } + } + + const shown = toast ?? leaving; + return ( +
+ {shown ? ( +
hold(true)} + onPointerLeave={() => hold(Boolean(box.current?.contains(document.activeElement)))} + onFocus={() => hold(true)} + onBlur={(event) => { if (!box.current?.contains(event.relatedTarget as Node | null)) hold(Boolean(box.current?.matches(":hover"))); }} + > + {shown.text} + {shown.action ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/ui/components.tsx b/apps/web/src/ui/components.tsx index 22f1cf0d..0c717685 100644 --- a/apps/web/src/ui/components.tsx +++ b/apps/web/src/ui/components.tsx @@ -2,27 +2,36 @@ import { useCallback, useEffect, useId, + useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode, + type RefObject, } from "react"; import { X } from "lucide-react"; import { cx } from "./cx"; +import { leaveGhost } from "./motion"; /** Shared product primitives; these keep semantics and spacing in one place. */ export function Button({ variant = "primary", className, type = "button", + busy = false, + disabled, ...props }: ButtonHTMLAttributes & { variant?: "primary" | "secondary"; + /** Its command is running: the label says so at full colour, and a second press does nothing. */ + busy?: boolean; }) { return (