From b4fa30602d99f3b10e5f03652e4d2a38852e0194 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:24:23 -0500 Subject: [PATCH 1/9] core: what one expense leaves between two people expenseBetween is one expense's step of pairwiseDebts between two people: what the other owes (or is owed) on it, 0 when both are in it but nothing passes between them, null when one is not in it. Summed with their payments it is their pairwise debt, which a property test checks. The one-friend export's between column now uses it instead of its own copy, and pgTAP gets the same two cases for private.pairwise_debts. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- packages/core/src/balances/between.test.ts | 131 ++++++++++++++++++++- packages/core/src/balances/between.ts | 30 ++++- packages/core/src/balances/index.ts | 2 +- packages/core/src/portability/ledger.ts | 44 +++---- supabase/tests/23_payments.test.sql | 29 ++++- 5 files changed, 207 insertions(+), 29 deletions(-) diff --git a/packages/core/src/balances/between.test.ts b/packages/core/src/balances/between.test.ts index dc364abb..8a6da03b 100644 --- a/packages/core/src/balances/between.test.ts +++ b/packages/core/src/balances/between.test.ts @@ -1,5 +1,13 @@ +import fc from 'fast-check' import { describe, expect, it } from 'vitest' -import { expenseInvolvesBoth, settlementBetween, type BalanceExpense } from '../index' +import { + expenseBetween, + expenseInvolvesBoth, + pairwiseDebts, + settlementBetween, + type BalanceExpense, + type BalanceSettlement, +} from '../index' const AVA = 'a0000000-0000-4000-8000-000000000001' const BEN = 'b0000000-0000-4000-8000-000000000002' @@ -47,3 +55,124 @@ describe('settlementBetween', () => { expect(settlementBetween(payment, AVA, BEN)).toBe(false) }) }) + +describe('expenseBetween', () => { + // Short ids sort in name order, so the spread between creditors is easy to follow. + const [me, jules, owen, pat] = ['a', 'b', 'c', 'd'] as const + + it('is what the other person owes on an expense the viewer paid, not the viewer net', () => { + // Concert, 200.00 paid by me, 80% Jules and 20% Owen: I am up 200, Jules owes me 160. + const concert: BalanceExpense = { + paidBy: me, + amountCents: 20000, + splits: [ + { userId: jules, shareCents: 16000 }, + { userId: owen, shareCents: 4000 }, + ], + } + expect(expenseBetween(concert, me, jules)).toBe(16000) + expect(expenseBetween(concert, jules, me)).toBe(-16000) + expect(expenseBetween(concert, me, owen)).toBe(4000) + // Jules and Owen both owe me: nothing passes between them. + expect(expenseBetween(concert, jules, owen)).toBe(0) + }) + + it('is 0 between two people who both owe whoever paid, and null when one is not in it', () => { + // Rent, 1,500.00 paid by Owen, split three ways with Pat and Jules. + const rent: BalanceExpense = { + paidBy: owen, + amountCents: 150000, + splits: [ + { userId: owen, shareCents: 50000 }, + { userId: pat, shareCents: 50000 }, + { userId: jules, shareCents: 50000 }, + ], + } + expect(expenseBetween(rent, pat, jules)).toBe(0) + expect(expenseBetween(rent, pat, owen)).toBe(-50000) + expect(expenseBetween(rent, me, owen)).toBeNull() + expect(expenseBetween({ ...rent, deletedAt: '2026-01-01T00:00:00Z' }, pat, owen)).toBeNull() + }) + + it('spreads each debtor over several payers by what they are still owed', () => { + // 12.00 paid 8.00 by me and 4.00 by Jules, 3.00 each: I am up 5.00, Jules 1.00. + const dinner: BalanceExpense = { + paidBy: me, + amountCents: 1200, + payers: [ + { userId: me, paidCents: 800 }, + { userId: jules, paidCents: 400 }, + ], + splits: [me, jules, owen, pat].map((userId) => ({ userId, shareCents: 300 })), + } + expect(expenseBetween(dinner, me, owen)).toBe(250) + expect(expenseBetween(dinner, jules, owen)).toBe(50) + expect(expenseBetween(dinner, me, pat)).toBe(250) + expect(expenseBetween(dinner, jules, pat)).toBe(50) + expect(expenseBetween(dinner, me, jules)).toBe(0) + }) + + it('flips for a refund and converts a foreign expense once into the group currency', () => { + const refund: BalanceExpense = { + paidBy: me, + isRefund: true, + amountCents: 1000, + splits: [{ userId: jules, shareCents: 1000 }], + } + expect(expenseBetween(refund, me, jules)).toBe(-1000) + + const taxi: BalanceExpense = { + paidBy: me, + amountCents: 1000, + currency: 'EUR', + fxRate: '1.100000000000', + splits: [ + { userId: me, shareCents: 500 }, + { userId: jules, shareCents: 500 }, + ], + } + expect(expenseBetween(taxi, me, jules, { groupCurrency: 'USD' })).toBe(550) + }) + + it('adds up, with the payments between them, to what pairwiseDebts says they owe', () => { + const user = fc.constantFrom('a', 'b', 'c', 'd') + const expense = fc.record({ + paidBy: user, + amountCents: fc.integer({ min: 1, max: 100_000 }), + isRefund: fc.boolean(), + splits: fc.uniqueArray(fc.record({ userId: user, shareCents: fc.nat(1000) }), { + minLength: 1, + maxLength: 4, + selector: (s) => s.userId, + }), + }) + const payment = fc.record({ + fromUser: user, + toUser: user, + amountCents: fc.integer({ min: 1, max: 10_000 }), + }) + fc.assert( + fc.property( + fc.array(expense, { maxLength: 8 }), + fc.array(payment, { maxLength: 4 }), + user, + user, + (expenses: BalanceExpense[], payments: BalanceSettlement[], a, b) => { + fc.pre(a !== b) + let rows = 0 + for (const e of expenses) rows += expenseBetween(e, a, b) ?? 0 + for (const p of payments) { + if (p.fromUser === a && p.toUser === b) rows += p.amountCents + if (p.fromUser === b && p.toUser === a) rows -= p.amountCents + } + let owed = 0 + for (const t of pairwiseDebts(expenses, payments)) { + if (t.fromUser === b && t.toUser === a) owed += t.amountCents + if (t.fromUser === a && t.toUser === b) owed -= t.amountCents + } + expect(rows).toBe(owed) + }, + ), + ) + }) +}) diff --git a/packages/core/src/balances/between.ts b/packages/core/src/balances/between.ts index 2fd81a93..8d441322 100644 --- a/packages/core/src/balances/between.ts +++ b/packages/core/src/balances/between.ts @@ -1,4 +1,10 @@ -import type { BalanceExpense, BalanceSettlement } from './balances' +import { + expenseEffect, + type BalanceExpense, + type BalanceOptions, + type BalanceSettlement, +} from './balances' +import { pairEffect } from './pairwise' /** Whether both people paid for or have a share in `expense`. */ export function expenseInvolvesBoth(expense: BalanceExpense, a: string, b: string): boolean { @@ -9,6 +15,28 @@ export function expenseInvolvesBoth(expense: BalanceExpense, a: string, b: strin return people.has(a) && people.has(b) } +/** + * What one expense leaves between two people, in the group currency: positive when `other` + * owes `person`, negative when `person` owes `other`, 0 when both are in it but nothing passes + * between them (say both owe a third person who paid), and null when it is not theirs to share + * (one of them is not in it, or it is deleted). + * + * This is one expense's step of `pairwiseDebts` (`private.pairwise_debts` in SQL): summed over a + * group's expenses, plus the payments between the two, it gives exactly what they owe each other + * there without simplifying. Friend pages and the one-friend export show it per row. + */ +export function expenseBetween( + expense: BalanceExpense, + person: string, + other: string, + options: BalanceOptions = {}, +): number | null { + if (!expenseInvolvesBoth(expense, person, other)) return null + const effect = expenseEffect(expense, options) + if (!effect) return null + return pairEffect(effect, person, other) +} + /** Whether `settlement` is a payment between the two of them, either way. */ export function settlementBetween(settlement: BalanceSettlement, a: string, b: string): boolean { return ( diff --git a/packages/core/src/balances/index.ts b/packages/core/src/balances/index.ts index db132f4a..9fc3a70e 100644 --- a/packages/core/src/balances/index.ts +++ b/packages/core/src/balances/index.ts @@ -9,4 +9,4 @@ export { } from './balances' export { pairEffect, pairwiseDebts } from './pairwise' export { simplifyDebts, type Transfer } from './simplify' -export { expenseInvolvesBoth, settlementBetween } from './between' +export { expenseBetween, expenseInvolvesBoth, settlementBetween } from './between' diff --git a/packages/core/src/portability/ledger.ts b/packages/core/src/portability/ledger.ts index 22887442..d0ccac5b 100644 --- a/packages/core/src/portability/ledger.ts +++ b/packages/core/src/portability/ledger.ts @@ -1,4 +1,4 @@ -import { expenseEffect, pairEffect } from '../balances' +import { expenseBetween, expenseEffect, type BalanceExpense } from '../balances' import { convert } from '../currency' import { compareIds } from '../money/allocate' import { isBlankRow, toCsv, unguardCell, type CsvValue } from './csv' @@ -135,22 +135,16 @@ function itemsText(expense: CsvExpense, labels: LedgerLabels, nameOf: (id: strin return parts.join('; ') } -/** The effect of one expense on everyone, in the group currency (positive = is owed). */ -function effectOf(expense: CsvExpense, groupCurrency: string): Record { - return ( - expenseEffect( - { - paidBy: expense.payers[0]?.userId ?? '', - payers: expense.payers, - isRefund: expense.isRefund, - amountCents: expense.amountCents, - currency: expense.currency, - fxRate: expense.fxRate, - splits: expense.splits, - }, - { groupCurrency }, - ) ?? {} - ) +function balanceExpense(expense: CsvExpense): BalanceExpense { + return { + paidBy: expense.payers[0]?.userId ?? '', + payers: expense.payers, + isRefund: expense.isRefund, + amountCents: expense.amountCents, + currency: expense.currency, + fxRate: expense.fxRate, + splits: expense.splits, + } } /** The export as CSV text. */ @@ -202,15 +196,15 @@ export function ledgerCsv(groups: readonly LedgerGroup[], options: LedgerOptions const lines: Line[] = [] for (const e of group.expenses) { - const effect = effectOf(e, gc) + const balance = balanceExpense(e) + const effect = expenseEffect(balance, { groupCurrency: gc }) ?? {} for (const [id, cents] of Object.entries(effect)) add(id, cents) - const pair = friend ? pairEffect(effect, friend.me, friend.friend) : 0 - if (friend) { - const involved = (id: string) => - e.payers.some((p) => p.userId === id) || e.splits.some((s) => s.userId === id) - if (pair === 0 && !(involved(friend.me) && involved(friend.friend))) continue - between += pair - } + // The same per expense amount the friend page shows on each row. + const pair = friend + ? expenseBetween(balance, friend.me, friend.friend, { groupCurrency: gc }) + : 0 + if (pair === null) continue + between += pair const sign = e.isRefund ? -1 : 1 const payers = [...e.payers].sort((a, b) => b.paidCents - a.paidCents) const perPerson = people.flatMap((p) => { diff --git a/supabase/tests/23_payments.test.sql b/supabase/tests/23_payments.test.sql index 29644c10..bcf32704 100644 --- a/supabase/tests/23_payments.test.sql +++ b/supabase/tests/23_payments.test.sql @@ -2,7 +2,7 @@ -- payments, payment_methods, pairwise and group debts, settle_all, friend_balances, -- settle_with_friend, remind_debt and the settlement change notifications. begin; -select plan(117); +select plan(119); delete from public.fx_rates; insert into public.fx_rates (base, quote, day, rate) values @@ -479,6 +479,33 @@ select results_eq( 'a refund reduces what someone owes for the original expense' ); +-- Per expense between two people (expenseBetween in between.test.ts, what a friend page row shows). +select tests.remember('pc', pg_temp.fresh(array['a', 'b', 'c'], false)); +select tests.login('a'); +select public.create_expense_v2(jsonb_build_object('group_id', tests.id('pc'), 'description', 'Concert', + 'amount_cents', 20000, 'payers', jsonb_build_array(pg_temp.payer('a', 20000)), + 'split_type', 'exact', 'splits', jsonb_build_array(pg_temp.exact('b', 16000), pg_temp.exact('c', 4000)))); +reset role; +select set_eq( + format($$ select from_user, to_user, amount_cents from private.pairwise_debts(%L) $$, tests.id('pc')), + format($$ values (%L::uuid, %L::uuid, 16000::bigint), (%L::uuid, %L::uuid, 4000::bigint) $$, + tests.uid('b'), tests.uid('a'), tests.uid('c'), tests.uid('a')), + 'between two people: each owes the payer their own share, not the payer''s whole lead' +); + +select tests.remember('pr', pg_temp.fresh(array['a', 'b', 'c'], false)); +select tests.login('c'); +select public.create_expense_v2(jsonb_build_object('group_id', tests.id('pr'), 'description', 'Rent', + 'amount_cents', 150000, 'payers', jsonb_build_array(pg_temp.payer('c', 150000)), + 'splits', tests.people('a', 'b', 'c'))); +reset role; +select set_eq( + format($$ select from_user, to_user, amount_cents from private.pairwise_debts(%L) $$, tests.id('pr')), + format($$ values (%L::uuid, %L::uuid, 50000::bigint), (%L::uuid, %L::uuid, 50000::bigint) $$, + tests.uid('a'), tests.uid('c'), tests.uid('b'), tests.uid('c')), + 'between two people: two who both owe the payer owe each other nothing' +); + -- Payment days are the caller's own calendar day. update public.profiles set timezone = 'Pacific/Auckland' where id = tests.uid('c'); select tests.login('c'); From 4d23257527841bbb86959c4e17013f8467e57a81 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:24:27 -0500 Subject: [PATCH 2/9] balances: "You are owed", never "You is owed" Everyone on the balances tab (and the balances preview on import) reads as one sentence per row for screen readers, with the viewer's own grammar: "You are owed $1,535.80", "You owe $12.00 in total". The small label above the amount follows too. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- .../features/balances/balances-tab.test.tsx | 32 +++++++++++++++++++ .../src/features/balances/balances-tab.tsx | 19 +++++------ apps/web/src/features/balances/net-label.ts | 24 ++++++++++++++ .../features/export-import/import-screen.tsx | 18 ++++++----- packages/i18n/src/de/money.ts | 5 +++ packages/i18n/src/en/money.ts | 6 ++++ packages/i18n/src/es/money.ts | 5 +++ packages/i18n/src/fr/money.ts | 5 +++ packages/i18n/src/pt/money.ts | 5 +++ 9 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/features/balances/net-label.ts diff --git a/apps/web/src/features/balances/balances-tab.test.tsx b/apps/web/src/features/balances/balances-tab.test.tsx index 5950c6e5..ba1001a1 100644 --- a/apps/web/src/features/balances/balances-tab.test.tsx +++ b/apps/web/src/features/balances/balances-tab.test.tsx @@ -84,6 +84,38 @@ describe('BalancesTab', () => { expect.stringContaining('You'), ]) expect(within(rows[0]!).getByText(t('balances.isOwedShort'))).toBeInTheDocument() + // Each row is one sentence for a screen reader, with "you" grammar for the viewer. + expect( + within(rows[0]!).getByText(t('balances.isOwed', { name: 'Ben', amount: '$60.00' })), + ).toBeInTheDocument() + expect( + within(rows[1]!).getByText(t('balances.netOwe', { name: 'Cleo', amount: '$12.50' })), + ).toBeInTheDocument() + expect( + within(rows[2]!).getByText(t('balances.youOweNet', { amount: '$47.50' })), + ).toBeInTheDocument() + expect(within(rows[2]!).getByText(t('balances.oweShort'))).toBeInTheDocument() + }) + + it('says "You are owed" for the viewer, never "You is owed"', async () => { + renderTab({ + nets: [ + [ME, 153580], + [BEN, -153580], + [CLEO, 0], + ], + simplified: [{ from_user: BEN, to_user: ME, amount_cents: 153580 }], + }) + const everyone = await screen.findByRole('heading', { name: t('balances.members') }) + const rows = within(everyone.closest('section')!).getAllByRole('listitem') + const mine = rows.find((row) => row.textContent?.includes('You'))! + expect( + within(mine).getByText(t('balances.youAreOwed', { amount: '$1,535.80' })), + ).toBeInTheDocument() + expect(within(mine).getByText(t('balances.areOwedShort'))).toBeInTheDocument() + expect(mine.textContent).not.toContain(t('balances.isOwedShort')) + const cleo = rows.find((row) => row.textContent?.includes('Cleo'))! + expect(within(cleo).getByText(t('balances.settled', { name: 'Cleo' }))).toBeInTheDocument() }) it('lists the debts you can act on before everyone else’s', async () => { diff --git a/apps/web/src/features/balances/balances-tab.tsx b/apps/web/src/features/balances/balances-tab.tsx index 5453d490..040d14e9 100644 --- a/apps/web/src/features/balances/balances-tab.tsx +++ b/apps/web/src/features/balances/balances-tab.tsx @@ -9,6 +9,7 @@ import { ArrowRight, Plus } from 'lucide-react' import { useState } from 'react' import { RemindButton } from '@/features/settle/remind-button' import { SettleAllSheet } from '@/features/settle/settle-all-sheet' +import { netLabel } from './net-label' import { useDebts } from './use-debts' import { useUserId } from '@/lib/auth' import { supabase } from '@/lib/supabase' @@ -236,6 +237,11 @@ export function BalancesTab({ groupId }: { groupId: string }) { {rows.map((r) => { const person = personOf(r.id) const width = `${(Math.abs(r.cents) / scale) * 50}%` + const label = netLabel(r.cents, { + name: nameOf(r.id), + isViewer: r.id === me, + amount: formatMoney(Math.abs(r.cents), currency), + }) return (
  • @@ -245,17 +251,12 @@ export function BalancesTab({ groupId }: { groupId: string }) { src={avatarSrc(person?.avatar_path)} size="sm" /> - + {label.full} + {nameOf(r.id)} - - - {r.cents > 0 - ? t('balances.isOwedShort') - : r.cents < 0 - ? t('balances.owesShort') - : t('balances.settledShort')} - + + {label.short} {r.cents !== 0 && ( 0) + return { short: t('balances.areOwedShort'), full: t('balances.youAreOwed', { amount }) } + if (cents < 0) + return { short: t('balances.oweShort'), full: t('balances.youOweNet', { amount }) } + return { short: t('balances.settledShort'), full: t('balances.youAreSettled') } + } + if (cents > 0) + return { short: t('balances.isOwedShort'), full: t('balances.isOwed', { name, amount }) } + if (cents < 0) + return { short: t('balances.owesShort'), full: t('balances.netOwe', { name, amount }) } + return { short: t('balances.settledShort'), full: t('balances.settled', { name }) } +} diff --git a/apps/web/src/features/export-import/import-screen.tsx b/apps/web/src/features/export-import/import-screen.tsx index 6ffdb743..f34bcb4c 100644 --- a/apps/web/src/features/export-import/import-screen.tsx +++ b/apps/web/src/features/export-import/import-screen.tsx @@ -69,6 +69,7 @@ import { useTimeZone, } from '@/features/expenses/money-data' import { Help } from '@/features/help/help' +import { netLabel } from '@/features/balances/net-label' import { useUserId } from '@/lib/auth' import { avatarSrc } from '@/lib/queries' import { supabase } from '@/lib/supabase' @@ -255,6 +256,7 @@ function rowLine(row: ImportRow, me: string, nameOf: ReturnType {rows.map((r) => { const person = people.get(r.id) + const label = netLabel(r.after, { + name: nameOf(r.id), + isViewer: r.id === me, + amount: formatMoney(Math.abs(r.after), group.currency), + }) return (
  • @@ -288,14 +295,9 @@ function BalancesAfter({ plan, group }: { plan: ImportPlan; group: MoneyGroup }) {t('transfer.import.now', { amount: formatMoney(r.now, group.currency) })} - - - {r.after > 0 - ? t('balances.isOwedShort') - : r.after < 0 - ? t('balances.owesShort') - : t('balances.settledShort')} - + {label.full} + + {label.short} {r.after !== 0 && ( Date: Sat, 14 Nov 2026 01:24:33 -0500 Subject: [PATCH 3/9] settle up: lead with what was just paid after paying part Paying part of a debt showed the amount still owed as the big red headline. The headline is now the payment, in green, with what is left underneath in plain type. Settling in full still ticks down to $0.00 with confetti. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- .../src/features/settle/settle-flow.test.tsx | 6 ++++- apps/web/src/features/settle/settle-flow.tsx | 25 ++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/web/src/features/settle/settle-flow.test.tsx b/apps/web/src/features/settle/settle-flow.test.tsx index 2a3db2f5..053758f7 100644 --- a/apps/web/src/features/settle/settle-flow.test.tsx +++ b/apps/web/src/features/settle/settle-flow.test.tsx @@ -110,7 +110,11 @@ describe('SettleFlow', () => { setup({ to: BEN, amount: 2000 }) fireEvent.click(screen.getByRole('button', { name: 'Record payment' })) expect(await screen.findByRole('heading', { name: 'Payment recorded' })).toBeInTheDocument() - expect(screen.getByText('You still owe $27.50')).toBeInTheDocument() + // The headline is what was just paid; what is left sits underneath in plain type. + const status = screen.getByRole('status') + expect(within(status).getByText('$20.00')).toBeInTheDocument() + expect(within(status).queryByText('$27.50')).not.toBeInTheDocument() + expect(within(status).getByText('You still owe $27.50')).toBeInTheDocument() }) it('records "Cleo paid me" on a past day', async () => { diff --git a/apps/web/src/features/settle/settle-flow.tsx b/apps/web/src/features/settle/settle-flow.tsx index d212d22a..05312d68 100644 --- a/apps/web/src/features/settle/settle-flow.tsx +++ b/apps/web/src/features/settle/settle-flow.tsx @@ -574,9 +574,11 @@ export function PaymentForm({ } /** - * Signature moment: what was owed ticks down to what is left (usually $0.00), then confetti in - * the palette colors. With reduced motion it simply shows the result. A payment in another - * currency skips the count, since its group currency value is the server's to work out. + * Signature moment. Settling in full: what was owed ticks down to $0.00, then confetti in the + * palette colors. Paying part: the amount just paid is the headline and what is left sits + * underneath in plain type, so the screen celebrates the payment rather than the debt. With + * reduced motion it simply shows the result. A payment in another currency skips the count, + * since its group currency value is the server's to work out. */ export function SettledMoment({ done, @@ -596,8 +598,9 @@ export function SettledMoment({ const start = sameCurrency ? Math.max(done.owedBefore, done.paid) : 0 const remaining = sameCurrency ? Math.max(0, done.owedBefore - done.paid) : 0 const settled = sameCurrency && remaining === 0 - // Reduced motion: show the result straight away. - const [shown, setShown] = useState(reduced ? remaining : start) + // Only settling in full counts down; reduced motion shows the result straight away. + const counts = settled && !reduced + const [shown, setShown] = useState(counts ? start : remaining) const [fire, setFire] = useState(reduced && settled ? 1 : 0) const from = firstName(done.from.display_name) const to = firstName(done.to.display_name) @@ -605,18 +608,16 @@ export function SettledMoment({ const youGot = done.to.id === viewerId useEffect(() => { - if (reduced || !sameCurrency) return + if (!counts) return const controls = animate(start, remaining, { duration: 1.1, delay: 0.25, ease: [0.16, 1, 0.3, 1], onUpdate: (v) => setShown(Math.round(v)), - onComplete: () => { - if (settled) setFire((n) => n + 1) - }, + onComplete: () => setFire((n) => n + 1), }) return () => controls.stop() - }, [reduced, sameCurrency, start, remaining, settled]) + }, [counts, start, remaining]) const left = formatMoney(remaining, groupCurrency) const paid = formatMoney(done.paid, done.currency) @@ -658,10 +659,10 @@ export function SettledMoment({ aria-hidden className={ 'tabular text-[4rem] leading-none font-extrabold tracking-tight transition-colors duration-500 ' + - (!sameCurrency || shown === 0 ? 'text-olive-ink' : 'text-tomato-ink') + (settled && shown !== 0 ? 'text-tomato-ink' : 'text-olive-ink') } > - {sameCurrency + {settled ? formatMoney(shown, groupCurrency, { locale: intlLocale() }) : formatMoney(done.paid, done.currency, { locale: intlLocale() })}

    From 3addd02c0d9ed8f81f6ebbc9db1830d3381436df Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:24:36 -0500 Subject: [PATCH 4/9] name a one-on-one tab after the friend everywhere The hidden 1:1 group is stored as "Direct" and that leaked into the CSV Group column and file name, the expense and payment back links, the activity feed ("Just the two of you", plus joining the tab as news) and notifications ("New expense in Owen Owner"). groupDisplayName in core names a tab after the other person, groupTitle uses it on the web, backups export it that way, and notifications say "your tab with Owen". Push copy never names a group for a tab, and email already leaves the name out. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- .../features/activity/activity-feed.test.tsx | 33 +++++++++--- .../src/features/activity/activity-feed.tsx | 34 ++++++++---- .../features/activity/describe-activity.ts | 4 +- .../src/features/expenses/expense-detail.tsx | 4 +- apps/web/src/features/expenses/format.ts | 24 +++++++-- .../src/features/export-import/use-exports.ts | 17 ++++-- .../export-import/your-data-section.test.tsx | 32 +++++++++++- .../features/notifications/describe.test.ts | 52 ++++++++++++++++++- .../src/features/notifications/describe.ts | 35 +++++++++++-- .../notifications/notifications-page.tsx | 10 ++-- .../src/features/recurring/recurring-edit.tsx | 4 +- .../src/features/settle/payment-detail.tsx | 4 +- packages/core/src/groups/group-name.test.ts | 27 ++++++++++ packages/core/src/groups/group-name.ts | 32 ++++++++++++ packages/core/src/groups/index.ts | 1 + packages/core/src/portability/backup.test.ts | 19 +++++++ packages/core/src/portability/backup.ts | 10 +++- packages/i18n/src/de/me.ts | 1 + packages/i18n/src/de/money.ts | 1 - packages/i18n/src/en/me.ts | 2 + packages/i18n/src/en/money.ts | 1 - packages/i18n/src/es/me.ts | 1 + packages/i18n/src/es/money.ts | 1 - packages/i18n/src/fr/me.ts | 1 + packages/i18n/src/fr/money.ts | 1 - packages/i18n/src/pt/me.ts | 1 + packages/i18n/src/pt/money.ts | 1 - 27 files changed, 309 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/groups/group-name.test.ts create mode 100644 packages/core/src/groups/group-name.ts diff --git a/apps/web/src/features/activity/activity-feed.test.tsx b/apps/web/src/features/activity/activity-feed.test.tsx index 75f959d3..d6618b23 100644 --- a/apps/web/src/features/activity/activity-feed.test.tsx +++ b/apps/web/src/features/activity/activity-feed.test.tsx @@ -2,6 +2,7 @@ import { ACTIVITY_PAGE_SIZE } from '@potluck/api' import { t } from '@potluck/i18n' import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { group, groupTables, member } from '@/test/fixtures' import { renderWithProviders } from '@/test/render' import { argsOf, db, emitChange, resetDb, subscriptions } from '@/test/supabase' import { BEN, ME, seededClient } from '../expenses/test-utils' @@ -11,7 +12,14 @@ vi.mock('@/lib/supabase', () => import('@/test/supabase')) vi.mock('@/lib/auth', () => ({ useUserId: () => 'aaaaaaaa-0000-4000-8000-000000000001' })) const hike = { id: 'g-hike', name: 'Hike crew', emoji: '🥾', is_direct: false } -const direct = { id: 'g-ben', name: '', emoji: null, is_direct: true } +const direct = { id: 'g-ben', name: 'Direct', emoji: null, is_direct: true } +const tab = group({ + id: 'g-ben', + name: 'Direct', + emoji: null, + is_direct: true, + members: [member(ME, 'Ava Chen', 'owner'), member(BEN, 'Ben Okafor', 'admin')], +}) function activity(id: string, created_at: string, patch: object = {}) { return { @@ -29,7 +37,8 @@ function activity(id: string, created_at: string, patch: object = {}) { } function renderFeed(rows: Record[]) { - resetDb({ tables: { activity: rows, groups: [{ ...hike, group_members: [] }] } }) + const { groups } = groupTables(tab) + resetDb({ tables: { activity: rows, groups: [{ ...hike, group_members: [] }, ...groups] } }) return renderWithProviders(, { path: '/activity', client: seededClient() }) } @@ -46,15 +55,22 @@ describe('ActivityFeed', () => { group: direct, }), activity('a3', '2026-11-03T10:00:00Z', { verb: 'group.created', group: direct }), + activity('a4', '2026-11-03T10:00:00Z', { + verb: 'member.joined', + actor_id: ME, + data: {}, + group: direct, + }), ]) const tacos = await screen.findByRole('link', { name: /Ben added "Tacos"/ }) expect(tacos).toHaveAttribute('href', '/expenses/x-a1') expect(within(tacos).getByText(/Hike crew/)).toBeInTheDocument() - expect(screen.getByRole('link', { name: /You paid Ben/ })).toHaveTextContent( - t('activity.direct'), - ) + // A one-to-one tab goes by the friend, never by its stored name. + const paid = screen.getByRole('link', { name: /You paid Ben/ }) + expect(paid).toHaveTextContent('Ben Okafor') + expect(paid).not.toHaveTextContent('Direct') expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(2) - // Opening a hidden one-to-one tab is not news. + // Opening a hidden one-to-one tab, and joining it, is not news. expect(screen.getAllByRole('listitem')).toHaveLength(2) }) @@ -150,7 +166,10 @@ describe('ActivityFeed', () => { it("listens only to the viewer's groups", async () => { renderFeed([]) await waitFor(() => - expect(subscriptions()).toContainEqual({ table: 'activity', filter: 'group_id=in.(g-hike)' }), + expect(subscriptions()).toContainEqual({ + table: 'activity', + filter: 'group_id=in.(g-ben,g-hike)', + }), ) expect(subscriptions().filter((s) => s.table === 'activity')).toHaveLength(1) }) diff --git a/apps/web/src/features/activity/activity-feed.tsx b/apps/web/src/features/activity/activity-feed.tsx index ba907868..246ac87f 100644 --- a/apps/web/src/features/activity/activity-feed.tsx +++ b/apps/web/src/features/activity/activity-feed.tsx @@ -65,12 +65,25 @@ export function ActivityFeed({ groupId, person }: { groupId?: string; person?: s : null const sections = useMemo(() => { - // The hidden 1:1 groups are an implementation detail; their creation is not news. + // The hidden 1:1 groups are an implementation detail: opening one (the group and its two + // members) is not news. const items = (feed.data?.pages.flat() ?? []).filter( - (item) => !(item.verb === 'group.created' && item.group?.is_direct), + (item) => + !( + item.group?.is_direct && + (item.verb === 'group.created' || item.verb.startsWith('member.')) + ), ) return groupByDay(items, (item) => dayKey(item.created_at, timeZone)) }, [feed.data, timeZone]) + const groupById = useMemo(() => new Map((groups.data ?? []).map((g) => [g.id, g])), [groups.data]) + /** A 1:1 tab goes by the friend, never by its stored name. */ + const groupName = (group: (typeof sections)[number]['items'][number]['group']) => { + if (!group) return '' + const full = groupById.get(group.id) + if (full) return groupTitle(full, me) + return group.is_direct ? t('people.friendPage.oneOnOne') : group.name + } return (
    @@ -131,11 +144,14 @@ export function ActivityFeed({ groupId, person }: { groupId?: string; person?: s
      {section.items.map((item, i) => { - const view = describeActivity(item, { viewerId: me, timeZone, nameOf }) + const where = groupName(item.group) + const view = describeActivity(item, { + viewerId: me, + timeZone, + nameOf, + groupName: where, + }) const actor = item.actor_id ? people.get(item.actor_id) : undefined - const groupName = item.group?.is_direct - ? t('activity.direct') - : (item.group?.name ?? '') const body = ( <> @@ -165,15 +181,15 @@ export function ActivityFeed({ groupId, person }: { groupId?: string; person?: s )} - {groupName && ( + {where && ( {item.group?.emoji && !item.group.is_direct ? `${item.group.emoji} ` : ''} - {groupName} + {where} )} - {groupName && ', '} + {where && ', '} diff --git a/apps/web/src/features/activity/describe-activity.ts b/apps/web/src/features/activity/describe-activity.ts index bf520d65..9aa0aa83 100644 --- a/apps/web/src/features/activity/describe-activity.ts +++ b/apps/web/src/features/activity/describe-activity.ts @@ -23,6 +23,8 @@ interface Context { timeZone: string /** First name for a user id, or "you" style text for the viewer. */ nameOf: (id: string | null | undefined, options?: { lower?: boolean }) => string + /** The group's name as the viewer sees it (a 1:1 tab goes by the friend); defaults to its name. */ + groupName?: string } const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) @@ -51,7 +53,7 @@ export function describeActivity(item: ActivityItem, ctx: Context): ActivityView const params: Record = { actor, // A deleted group is hidden, so its delete line carries the name. - group: item.group?.name ?? str(d.name) ?? '', + group: (item.group ? (ctx.groupName ?? item.group.name) : undefined) ?? str(d.name) ?? '', description: str(d.description) ?? '', amount, event: [str(d.emoji), str(d.title)].filter(Boolean).join(' '), diff --git a/apps/web/src/features/expenses/expense-detail.tsx b/apps/web/src/features/expenses/expense-detail.tsx index 96802cc7..65dff272 100644 --- a/apps/web/src/features/expenses/expense-detail.tsx +++ b/apps/web/src/features/expenses/expense-detail.tsx @@ -117,7 +117,9 @@ export function ExpenseDetail({ expenseId }: { expenseId: string }) { className={buttonClassName({ variant: 'ghost', size: 'sm', className: 'pl-2' })} > - {group.data?.name ?? t('common.back')} + + {group.data ? groupTitle(group.data, me) : t('common.back')} + {!deleted && (mayEdit || mayDelete) && (
      diff --git a/apps/web/src/features/expenses/format.ts b/apps/web/src/features/expenses/format.ts index dee0165d..f91d8ba7 100644 --- a/apps/web/src/features/expenses/format.ts +++ b/apps/web/src/features/expenses/format.ts @@ -1,4 +1,11 @@ -import { categoryEmoji, dayKey, isCategory, isSubcategory, type TopCategoryId } from '@potluck/core' +import { + categoryEmoji, + dayKey, + groupDisplayName, + isCategory, + isSubcategory, + type TopCategoryId, +} from '@potluck/core' import { formatDate, hasMessage, t } from '@potluck/i18n' /** Label for a stored category or subcategory, including legacy values outside the core list. */ @@ -45,12 +52,19 @@ export function groupByDay(items: readonly T[], dayOfItem: (item: T) => strin return sections } -/** Title for a group, using the other person's name for 1:1 groups. */ +/** + * A group's name as the viewer sees it. A one-on-one tab's stored name ("Direct") is never + * shown: it goes by the other person (core `groupDisplayName`). Use this wherever a group is + * named on screen or in a file. + */ export function groupTitle( group: { name: string; is_direct: boolean; members: { id: string; display_name: string }[] }, me: string, ) { - if (!group.is_direct) return group.name - const other = group.members.find((m) => m.id !== me) - return other ? other.display_name : group.name + return groupDisplayName( + { name: group.name, isDirect: group.is_direct, memberIds: group.members.map((m) => m.id) }, + me, + (id) => group.members.find((m) => m.id === id)?.display_name, + t('people.friendPage.oneOnOne'), + ) } diff --git a/apps/web/src/features/export-import/use-exports.ts b/apps/web/src/features/export-import/use-exports.ts index 51ee84fd..f0ae4498 100644 --- a/apps/web/src/features/export-import/use-exports.ts +++ b/apps/web/src/features/export-import/use-exports.ts @@ -5,8 +5,9 @@ import { toast } from '@potluck/ui' import { useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { groupCsv } from '@/features/expenses/expense-math' -import { categoryLabel } from '@/features/expenses/format' +import { categoryLabel, groupTitle } from '@/features/expenses/format' import { useTimeZone } from '@/features/expenses/money-data' +import { useUserId } from '@/lib/auth' import { errorMessage } from '@/lib/errors' import { supabase } from '@/lib/supabase' import { fileName, saveFile } from './download' @@ -32,6 +33,7 @@ function useRunner() { /** The group's spreadsheet, from the same data the expenses tab shows. */ export function useGroupExport(groupId: string) { + const me = useUserId() const queryClient = useQueryClient() const timeZone = useTimeZone() const { busy, run } = useRunner<'group'>() @@ -43,10 +45,12 @@ export function useGroupExport(groupId: string) { queryClient.fetchQuery(moneyQueries.settlements(supabase, groupId)), queryClient.fetchQuery(moneyQueries.people(supabase)), ]) - const file = fileName(group.name, 'csv', 'expenses') + // A one-on-one tab goes by the friend, in the file name and the Group column alike. + const named = { ...group, name: groupTitle(group, me) } + const file = fileName(named.name, 'csv', 'expenses') saveFile( file, - groupCsv(group, expenses, settlements, new Map(people.map((p) => [p.id, p])), timeZone), + groupCsv(named, expenses, settlements, new Map(people.map((p) => [p.id, p])), timeZone), 'csv', ) return file @@ -78,7 +82,12 @@ function everyone(backup: Backup, timeZone: string) { } function accountCsv(backup: Backup, timeZone: string, friend?: string): string { - const { groups, nameOf } = backupLedger(backup, timeZone, t('transfer.csv.someoneWhoLeft')) + const { groups, nameOf } = backupLedger( + backup, + timeZone, + t('transfer.csv.someoneWhoLeft'), + t('people.friendPage.oneOnOne'), + ) return ledgerCsv(groups, { labels: ledgerLabels(), people: everyone(backup, timeZone), diff --git a/apps/web/src/features/export-import/your-data-section.test.tsx b/apps/web/src/features/export-import/your-data-section.test.tsx index 9bf7612c..12871912 100644 --- a/apps/web/src/features/export-import/your-data-section.test.tsx +++ b/apps/web/src/features/export-import/your-data-section.test.tsx @@ -6,7 +6,15 @@ import { toast } from '@potluck/ui' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderInRouter } from '@/features/expenses/router-test-utils' -import { BEN, CLEO, GROUP_ID, ME, expense, seededClient } from '@/features/expenses/test-utils' +import { + BEN, + CLEO, + GROUP_ID, + ME, + expense, + group, + seededClient, +} from '@/features/expenses/test-utils' import type * as Download from './download' import { saveFile } from './download' import { GroupDataSection } from './group-data-section' @@ -201,6 +209,28 @@ describe('GroupDataSection', () => { ) }) + it('names a one-on-one tab after the friend, in the file and the Group column', async () => { + renderInRouter( + , + seededClient((c) => { + c.setQueryData(moneyKeys.groupInfo(GROUP_ID), { + ...group, + name: 'Direct', + is_direct: true, + members: group.members.filter((m) => m.id === ME || m.id === BEN), + }) + c.setQueryData(moneyKeys.expenses(GROUP_ID), [expense({})]) + c.setQueryData(moneyKeys.settlements(GROUP_ID), []) + }), + ) + fireEvent.click(await screen.findByRole('button', { name: t('transfer.group.export') })) + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)) + const [name, content] = save.mock.calls[0]! + expect(name).toMatch(/^ben-okafor-/) + expect(content).toContain('2026-11-04,Ben Okafor,Expense,Tacos') + expect(content).not.toContain('Direct') + }) + it('points archived groups at unarchiving first', async () => { renderInRouter() expect(await screen.findByText(t('transfer.group.importArchived'))).toBeVisible() diff --git a/apps/web/src/features/notifications/describe.test.ts b/apps/web/src/features/notifications/describe.test.ts index 948f1049..d63d5f0b 100644 --- a/apps/web/src/features/notifications/describe.test.ts +++ b/apps/web/src/features/notifications/describe.test.ts @@ -1,7 +1,7 @@ import type { NotificationRow } from '@potluck/api' import { formatDate, formatTime } from '@potluck/i18n' import { describe, expect, it } from 'vitest' -import { describeNotification } from './describe' +import { describeNotification, notificationGroupName } from './describe' const ctx = { people: new Map([['u-ben', 'Ben Ortiz']]), @@ -283,6 +283,56 @@ describe('deletions, being added, and the monthly summary', () => { }) }) +describe('one-on-one tabs', () => { + const tab = { + id: 'g-tab', + name: 'Direct', + is_direct: true, + members: [ + { id: 'me', display_name: 'Pat Lee' }, + { id: 'u-owen', display_name: 'Owen Owner' }, + ], + } + + it('are named after the friend inside a sentence, never by their stored name', () => { + expect(notificationGroupName(tab, 'me')).toBe('your tab with Owen') + expect(notificationGroupName({ ...tab, members: [tab.members[0]!] }, 'me')).toBe('your group') + expect(notificationGroupName({ ...tab, name: 'Hike crew', is_direct: false }, 'me')).toBe( + 'Hike crew', + ) + const view = describeNotification( + row('expense.created', { + actor_id: 'u-ben', + group_id: 'g-tab', + expense_id: 'e-1', + description: 'Rent', + amount_cents: 50000, + currency: 'USD', + }), + { ...ctx, groups: new Map([['g-tab', notificationGroupName(tab, 'me')]]) }, + ) + expect(view.title).toBe('New expense in your tab with Owen') + }) + + it('keep the friend name the monthly summary gives them', () => { + const view = describeNotification( + row('summary.monthly', { + groups: [ + { + group_id: 'g-tab', + name: 'Owen Owner', + is_direct: true, + currency: 'USD', + net_cents: 500, + }, + ], + }), + { ...ctx, groups: new Map([['g-tab', 'your tab with Owen']]) }, + ) + expect(view.body).toBe('Owen Owner: you are owed $5.00') + }) +}) + describe('repeating expense notices', () => { const upcoming = (data: Record) => describeNotification( diff --git a/apps/web/src/features/notifications/describe.ts b/apps/web/src/features/notifications/describe.ts index 27a4e744..18ac0a58 100644 --- a/apps/web/src/features/notifications/describe.ts +++ b/apps/web/src/features/notifications/describe.ts @@ -1,4 +1,4 @@ -import { firstName } from '@potluck/core' +import { directFriendId, firstName } from '@potluck/core' import type { NotificationRow } from '@potluck/api' import { formatDate, formatMoney, formatRelative, formatTime, t } from '@potluck/i18n' @@ -31,7 +31,7 @@ export interface NotificationView { export interface DescribeContext { /** Display names by profile id. */ people: Map - /** Group names by id. */ + /** Group names by id, as they read after "in" (`notificationGroupName`). */ groups: Map timeZone: string now?: Date @@ -46,6 +46,30 @@ const num = (value: unknown): number | undefined => const firstNameOf = (full: string | undefined) => (full ? firstName(full) : undefined) +/** + * How a group is named inside a notification ("New expense in {group}"): its name, or for a + * one-on-one tab "your tab with Jules", never the tab's stored name. + */ +export function notificationGroupName( + group: { + id: string + name: string + is_direct: boolean + members: { id: string; display_name: string }[] + }, + viewerId: string, +): string { + if (!group.is_direct) return group.name + const friendId = directFriendId( + { name: group.name, isDirect: true, memberIds: group.members.map((m) => m.id) }, + viewerId, + ) + const friend = group.members.find((m) => m.id === friendId) + return friend + ? t('notifications.directTab', { name: firstName(friend.display_name) }) + : t('notifications.aGroup') +} + /** "Roommates: you are owed $12.50, Hike crew: you owe $5.00 and 1 more group". */ function summaryLine(groups: unknown, ctx: DescribeContext): string { const owing = (Array.isArray(groups) ? groups : []) @@ -55,7 +79,12 @@ function summaryLine(groups: unknown, ctx: DescribeContext): string { const parts = owing.slice(0, 2).map((g) => { const net = num(g.net_cents)! const groupId = str(g.group_id) - const group = (groupId && ctx.groups.get(groupId)) || str(g.name) || t('notifications.aGroup') + // The summary names a one-on-one tab after the friend already (`monthly_summary`). + const group = + (g.is_direct === true && str(g.name)) || + (groupId && ctx.groups.get(groupId)) || + str(g.name) || + t('notifications.aGroup') const amount = formatMoney(Math.abs(net), str(g.currency) ?? 'USD') return net > 0 ? t('notifications.types.summary.owed', { group, amount }) diff --git a/apps/web/src/features/notifications/notifications-page.tsx b/apps/web/src/features/notifications/notifications-page.tsx index 1c9724f9..b6e833f1 100644 --- a/apps/web/src/features/notifications/notifications-page.tsx +++ b/apps/web/src/features/notifications/notifications-page.tsx @@ -16,12 +16,16 @@ import { } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { useMemo, type ReactNode } from 'react' -import { groupTitle } from '@/features/expenses/format' import { useMyGroups } from '@/features/groups/use-groups' import { useUserId } from '@/lib/auth' import { useViewerTimezone } from '@/lib/queries' import { supabase } from '@/lib/supabase' -import { describeNotification, type NotificationKind, type NotificationView } from './describe' +import { + describeNotification, + notificationGroupName, + type NotificationKind, + type NotificationView, +} from './describe' import { useMarkRead, useNotifications } from './use-notifications' const kindStyle: Record = { @@ -118,7 +122,7 @@ export function NotificationsPage() { const ctx = useMemo( () => ({ people: new Map((people ?? []).map((p) => [p.id, p.display_name])), - groups: new Map((groups ?? []).map((g) => [g.id, groupTitle(g, viewerId)])), + groups: new Map((groups ?? []).map((g) => [g.id, notificationGroupName(g, viewerId)])), timeZone, }), [people, groups, viewerId, timeZone], diff --git a/apps/web/src/features/recurring/recurring-edit.tsx b/apps/web/src/features/recurring/recurring-edit.tsx index 332ea593..f94fcff5 100644 --- a/apps/web/src/features/recurring/recurring-edit.tsx +++ b/apps/web/src/features/recurring/recurring-edit.tsx @@ -6,7 +6,7 @@ import { Link, useCanGoBack, useNavigate, useRouter } from '@tanstack/react-rout import { useUserId } from '@/lib/auth' import { supabase } from '@/lib/supabase' import { ExpenseForm } from '@/features/expenses/form/expense-form' -import { categoryEmoji } from '@/features/expenses/format' +import { categoryEmoji, groupTitle } from '@/features/expenses/format' import { MoneyPageShell } from '@/features/expenses/money-page-shell' import { useMoneyGroup, useNameOf, usePeople } from '@/features/expenses/money-data' import { joinNames } from '@/features/plans/format' @@ -184,7 +184,7 @@ function TemplateSummary({ )}

      {group.archived_at - ? t('expenses.form.archived', { group: group.name }) + ? t('expenses.form.archived', { group: groupTitle(group, me) }) : t('expenses.recurring.readOnly')}

      diff --git a/apps/web/src/features/settle/payment-detail.tsx b/apps/web/src/features/settle/payment-detail.tsx index 18d7e986..12130816 100644 --- a/apps/web/src/features/settle/payment-detail.tsx +++ b/apps/web/src/features/settle/payment-detail.tsx @@ -97,7 +97,9 @@ export function PaymentDetail({ paymentId }: { paymentId: string }) { className={buttonClassName({ variant: 'ghost', size: 'sm', className: 'pl-2' })} > - {group.data?.name ?? t('common.back')} + + {group.data ? groupTitle(group.data, me) : t('common.back')} + {!deleted && !archived && (mayEdit || mayDelete) && (
      diff --git a/packages/core/src/groups/group-name.test.ts b/packages/core/src/groups/group-name.test.ts new file mode 100644 index 00000000..befd1e1f --- /dev/null +++ b/packages/core/src/groups/group-name.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { directFriendId, groupDisplayName } from '../index' + +const names: Record = { me: 'Pat', jules: 'Jules Park' } +const nameOf = (id: string) => names[id] + +describe('groupDisplayName', () => { + it('keeps the name of a group', () => { + const trip = { name: 'Lisbon', isDirect: false, memberIds: ['me', 'jules'] } + expect(groupDisplayName(trip, 'me', nameOf, 'One-on-one')).toBe('Lisbon') + expect(directFriendId(trip, 'me')).toBeNull() + }) + + it('names a one-on-one tab after the other person, never its stored name', () => { + const tab = { name: 'Direct', isDirect: true, memberIds: ['me', 'jules'] } + expect(directFriendId(tab, 'me')).toBe('jules') + expect(groupDisplayName(tab, 'me', nameOf, 'One-on-one')).toBe('Jules Park') + expect(groupDisplayName(tab, 'jules', nameOf, 'One-on-one')).toBe('Pat') + }) + + it('falls back when the other person is unknown', () => { + const alone = { name: 'Direct', isDirect: true, memberIds: ['me'] } + expect(groupDisplayName(alone, 'me', nameOf, 'One-on-one')).toBe('One-on-one') + const stranger = { name: 'Direct', isDirect: true, memberIds: ['me', 'gone'] } + expect(groupDisplayName(stranger, 'me', nameOf, 'One-on-one')).toBe('One-on-one') + }) +}) diff --git a/packages/core/src/groups/group-name.ts b/packages/core/src/groups/group-name.ts new file mode 100644 index 00000000..40861383 --- /dev/null +++ b/packages/core/src/groups/group-name.ts @@ -0,0 +1,32 @@ +/** + * A group as people see it. A one-on-one tab between two friends (`groups.is_direct`) is stored + * under a placeholder name nobody picked ("Direct"), so that name is never shown: the tab goes by + * the other person instead, as `private.monthly_summary` names it in SQL. + */ +export interface NamedGroup { + name: string + isDirect: boolean + /** Everyone in the group, the viewer included. */ + memberIds: readonly string[] +} + +/** The other person in a one-on-one tab, or null for a group (or a tab nobody else is in). */ +export function directFriendId(group: NamedGroup, viewerId: string): string | null { + if (!group.isDirect) return null + return group.memberIds.find((id) => id !== viewerId) ?? null +} + +/** + * The group's name, or for a one-on-one tab the other person's name from `nameOf`. + * `fallback` names a tab whose other person is unknown, so the stored name never shows. + */ +export function groupDisplayName( + group: NamedGroup, + viewerId: string, + nameOf: (id: string) => string | undefined, + fallback: string, +): string { + if (!group.isDirect) return group.name + const friend = directFriendId(group, viewerId) + return (friend && nameOf(friend)) || fallback +} diff --git a/packages/core/src/groups/index.ts b/packages/core/src/groups/index.ts index 4c5b13bf..81c73c62 100644 --- a/packages/core/src/groups/index.ts +++ b/packages/core/src/groups/index.ts @@ -11,3 +11,4 @@ export { type HomeStarter, type TripDatesProblem, } from './group-types' +export { directFriendId, groupDisplayName, type NamedGroup } from './group-name' diff --git a/packages/core/src/portability/backup.test.ts b/packages/core/src/portability/backup.test.ts index e03b631c..87ab2164 100644 --- a/packages/core/src/portability/backup.test.ts +++ b/packages/core/src/portability/backup.test.ts @@ -179,6 +179,25 @@ describe('backupLedger', () => { expect(nameOf(id(9))).toBe(id(9)) }) + it('names a one-on-one tab after the other person, never "Direct"', () => { + const backup = backupSchema.parse(JSON.parse(JSON.stringify(sample)) as typeof sample) + const trip = backup.groups[0]! + backup.people.push({ id: id(2), display_name: 'Jules', deleted: false, placeholder: false }) + backup.groups.push({ + ...trip, + id: id(11), + name: 'Direct', + is_direct: true, + members: [ + { user_id: id(1), role: 'owner', joined_at: at }, + { user_id: id(2), role: 'admin', joined_at: at }, + ], + }) + backup.groups.push({ ...trip, id: id(12), name: 'Direct', is_direct: true }) + const { groups } = backupLedger(backup, 'UTC', 'Someone', 'One-on-one') + expect(groups.map((g) => g.name)).toEqual(['Trip', 'Jules', 'One-on-one']) + }) + it("dates a payment on the viewer's day, as settle_up_v2 stored it", () => { const backup = backupSchema.parse(JSON.parse(JSON.stringify(sample)) as typeof sample) // Noon on March 2 in Auckland (UTC+13) is 23:00 UTC on March 1. diff --git a/packages/core/src/portability/backup.ts b/packages/core/src/portability/backup.ts index 45736508..6a3f81b3 100644 --- a/packages/core/src/portability/backup.ts +++ b/packages/core/src/portability/backup.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { dayKey } from '../dates' +import { groupDisplayName } from '../groups' import type { LedgerGroup } from './ledger' /** @@ -189,13 +190,20 @@ export function backupLedger( timeZone: string, /** Name for an id the backup does not know (never expected; the export lists everyone). */ unknown?: string, + /** Name for a one-on-one tab whose other person is unknown; a tab otherwise goes by them. */ + oneOnOne?: string, ): { groups: LedgerGroup[] nameOf: (id: string) => string } { const names = new Map(backup.people.map((p) => [p.id, p.display_name])) const groups = backup.groups.map((g): LedgerGroup => ({ - name: g.name, + name: groupDisplayName( + { name: g.name, isDirect: g.is_direct, memberIds: g.members.map((m) => m.user_id) }, + backup.profile.id, + (id) => names.get(id), + oneOnOne ?? unknown ?? g.name, + ), currency: g.currency, expenses: g.expenses .filter((e) => !e.deleted_at) diff --git a/packages/i18n/src/de/me.ts b/packages/i18n/src/de/me.ts index 0f03b3df..349d44f2 100644 --- a/packages/i18n/src/de/me.ts +++ b/packages/i18n/src/de/me.ts @@ -11,6 +11,7 @@ export const notifications = { pushBlocked: 'Benachrichtigungen sind in deinen Browsereinstellungen blockiert.', someone: 'Jemand', aGroup: 'deiner Gruppe', + directTab: 'eurer Runde mit {name}', anEvent: 'einen Plan', anExpense: 'eine Ausgabe', generic: { title: 'Es ist was passiert', body: 'Tipp drauf, um es dir anzusehen.' }, diff --git a/packages/i18n/src/de/money.ts b/packages/i18n/src/de/money.ts index 1bd91002..91b27f56 100644 --- a/packages/i18n/src/de/money.ts +++ b/packages/i18n/src/de/money.ts @@ -727,7 +727,6 @@ export const activity = { someone: 'Jemand', you: 'Du', youLower: 'du', - direct: 'Nur ihr zwei', fallback: '{actor} hat etwas geändert', aPlan: 'einen Plan', anExpense: 'eine Ausgabe', diff --git a/packages/i18n/src/en/me.ts b/packages/i18n/src/en/me.ts index 7607a92b..41318aae 100644 --- a/packages/i18n/src/en/me.ts +++ b/packages/i18n/src/en/me.ts @@ -8,6 +8,8 @@ export const notifications = { pushBlocked: 'Notifications are blocked in your browser settings.', someone: 'Someone', aGroup: 'your group', + /** A one-on-one tab, read after "in": "New expense in your tab with Jules". */ + directTab: 'your tab with {name}', anEvent: 'a plan', anExpense: 'an expense', generic: { title: 'Something happened', body: 'Tap to take a look.' }, diff --git a/packages/i18n/src/en/money.ts b/packages/i18n/src/en/money.ts index 51c101c6..d295be0c 100644 --- a/packages/i18n/src/en/money.ts +++ b/packages/i18n/src/en/money.ts @@ -720,7 +720,6 @@ export const activity = { someone: 'Someone', you: 'You', youLower: 'you', - direct: 'Just the two of you', fallback: '{actor} made a change', aPlan: 'a plan', anExpense: 'an expense', diff --git a/packages/i18n/src/es/me.ts b/packages/i18n/src/es/me.ts index 7561711c..63a8a203 100644 --- a/packages/i18n/src/es/me.ts +++ b/packages/i18n/src/es/me.ts @@ -11,6 +11,7 @@ export const notifications = { pushBlocked: 'Las notificaciones están bloqueadas en los ajustes de tu navegador.', someone: 'Alguien', aGroup: 'tu grupo', + directTab: 'tu cuenta con {name}', anEvent: 'un plan', anExpense: 'un gasto', generic: { title: 'Pasó algo', body: 'Toca para echar un vistazo.' }, diff --git a/packages/i18n/src/es/money.ts b/packages/i18n/src/es/money.ts index 4fa712be..0fe0396b 100644 --- a/packages/i18n/src/es/money.ts +++ b/packages/i18n/src/es/money.ts @@ -718,7 +718,6 @@ export const activity = { someone: 'Alguien', you: 'Tú', youLower: 'tú', - direct: 'Solo ustedes dos', fallback: '{actor} hizo un cambio', aPlan: 'un plan', anExpense: 'un gasto', diff --git a/packages/i18n/src/fr/me.ts b/packages/i18n/src/fr/me.ts index 867d4bae..712752f1 100644 --- a/packages/i18n/src/fr/me.ts +++ b/packages/i18n/src/fr/me.ts @@ -11,6 +11,7 @@ export const notifications = { pushBlocked: 'Les notifications sont bloquées dans les réglages de ton navigateur.', someone: "Quelqu'un", aGroup: 'ton groupe', + directTab: 'ton compte avec {name}', anEvent: 'une sortie', anExpense: 'une dépense', generic: { title: "Il s'est passé quelque chose", body: 'Touche pour jeter un œil.' }, diff --git a/packages/i18n/src/fr/money.ts b/packages/i18n/src/fr/money.ts index a83c39ee..6904c272 100644 --- a/packages/i18n/src/fr/money.ts +++ b/packages/i18n/src/fr/money.ts @@ -732,7 +732,6 @@ export const activity = { someone: "Quelqu'un", you: 'Toi', youLower: 'toi', - direct: 'Juste vous deux', fallback: '{actor} a fait une modification', aPlan: 'une sortie', anExpense: 'une dépense', diff --git a/packages/i18n/src/pt/me.ts b/packages/i18n/src/pt/me.ts index e45f82ae..dac38d4d 100644 --- a/packages/i18n/src/pt/me.ts +++ b/packages/i18n/src/pt/me.ts @@ -11,6 +11,7 @@ export const notifications = { pushBlocked: 'As notificações estão bloqueadas nas configurações do navegador.', someone: 'Alguém', aGroup: 'seu grupo', + directTab: 'sua conta com {name}', anEvent: 'um plano', anExpense: 'uma despesa', generic: { title: 'Aconteceu alguma coisa', body: 'Toque para dar uma olhada.' }, diff --git a/packages/i18n/src/pt/money.ts b/packages/i18n/src/pt/money.ts index bb50329a..b1d72b84 100644 --- a/packages/i18n/src/pt/money.ts +++ b/packages/i18n/src/pt/money.ts @@ -715,7 +715,6 @@ export const activity = { someone: 'Alguém', you: 'Você', youLower: 'você', - direct: 'Só vocês dois', fallback: '{actor} fez uma alteração', aPlan: 'um plano', anExpense: 'uma despesa', From 7310929bb1a3d5eb020bfb06129b1452bcdf61f5 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:24:38 -0500 Subject: [PATCH 5/9] home: list the other currencies with their totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The balance card said "Plus balances in EUR" with no amount. It now lists what you are owed and owe in each other currency, unconverted: "Plus you are owed €12.00 and you owe £5.00". Its group rows name a one-on-one tab after the friend. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- .../src/features/dashboard/balance-hero.tsx | 25 +++++++++--- .../dashboard/home-dashboard.test.tsx | 38 ++++++++++++++++++- .../src/features/dashboard/summary.test.ts | 14 ++++++- apps/web/src/features/dashboard/summary.ts | 21 ++++++---- packages/api/src/plans.test.ts | 31 +++++++++++++-- packages/api/src/plans.ts | 17 ++++++++- packages/i18n/src/de/plans.ts | 4 +- packages/i18n/src/en/plans.ts | 5 ++- packages/i18n/src/es/plans.ts | 4 +- packages/i18n/src/fr/plans.ts | 4 +- packages/i18n/src/pt/plans.ts | 4 +- 11 files changed, 141 insertions(+), 26 deletions(-) diff --git a/apps/web/src/features/dashboard/balance-hero.tsx b/apps/web/src/features/dashboard/balance-hero.tsx index 264a6a5f..5613f3bd 100644 --- a/apps/web/src/features/dashboard/balance-hero.tsx +++ b/apps/web/src/features/dashboard/balance-hero.tsx @@ -8,6 +8,21 @@ import type { BalanceSummary } from './summary' const TEAR = 'radial-gradient(circle 6px at 50% 100%, #0000 5.5px, #000 6px) 50% 100% / 16px 100% repeat-x' +/** "Plus you are owed 12.00 EUR and you owe 5.00 GBP": each other currency, unconverted. */ +function otherTotals(summary: BalanceSummary): string { + const money = (cents: number, currency: string) => + formatMoney(cents, currency, { locale: intlLocale() }) + const parts = summary.otherCurrencies.flatMap(({ currency, owed, owe }) => [ + ...(owed > 0 ? [t('home.balance.otherOwed', { amount: money(owed, currency) })] : []), + ...(owe < 0 ? [t('home.balance.otherOwe', { amount: money(-owe, currency) })] : []), + ]) + const list = + parts.length > 1 + ? `${parts.slice(0, -1).join(', ')} ${t('common.and')} ${parts[parts.length - 1]}` + : (parts[0] ?? '') + return t('home.balance.otherTotals', { list }) +} + function GroupLinks({ summary }: { summary: BalanceSummary }) { const groups = [...summary.oweGroups, ...summary.owedGroups].slice(0, 5) if (groups.length === 0) return null @@ -21,9 +36,11 @@ function GroupLinks({ summary }: { summary: BalanceSummary }) { className="-mx-2 flex items-center gap-2.5 rounded-[0.625rem] px-2 py-1.5 text-sm hover:bg-on-hero/10" > - {g.groupEmoji ?? '•'} + {g.groupEmoji ?? (g.isDirect ? '🤝' : '•')} + + + {g.groupName || t('people.friendPage.oneOnOne')} - {g.groupName} {summary.otherCurrencies.length > 0 && ( -

      - {t('home.balance.otherCurrencies', { currencies: summary.otherCurrencies.join(', ') })} -

      +

      {otherTotals(summary)}

      )} ) diff --git a/apps/web/src/features/dashboard/home-dashboard.test.tsx b/apps/web/src/features/dashboard/home-dashboard.test.tsx index a4239e44..6b1a0f17 100644 --- a/apps/web/src/features/dashboard/home-dashboard.test.tsx +++ b/apps/web/src/features/dashboard/home-dashboard.test.tsx @@ -76,6 +76,27 @@ const groupRows = [ archived_at: null, group_members: [{ count: 3 }], }, + { + id: 'g-london', + name: 'London', + emoji: null, + currency: 'GBP', + is_direct: false, + archived_at: null, + group_members: [{ count: 2 }], + }, + { + id: 'g-jules', + name: 'Direct', + emoji: null, + currency: 'USD', + is_direct: true, + archived_at: null, + group_members: [ + { profiles: { id: VIEWER, display_name: 'Ava' } }, + { profiles: { id: 'u-jules', display_name: 'Jules Park' } }, + ], + }, ] function seed({ items = timeline as object[], balances = true } = {}) { @@ -88,6 +109,8 @@ function seed({ items = timeline as object[], balances = true } = {}) { { user_id: VIEWER, group_id: 'g-hike', net_cents: -6051 }, { user_id: VIEWER, group_id: 'g-flat', net_cents: 2500 }, { user_id: VIEWER, group_id: 'g-paris', net_cents: 1200 }, + { user_id: VIEWER, group_id: 'g-london', net_cents: -500 }, + { user_id: VIEWER, group_id: 'g-jules', net_cents: 300 }, ] : [], event_rsvps: [], @@ -104,13 +127,24 @@ describe('HomeDashboard', () => { expect(await screen.findByRole('heading', { level: 1, name: /Ava/ })).toBeInTheDocument() const hero = await screen.findByRole('region', { name: t('home.balance.title') }) expect(within(hero).getByText(t('home.balance.headlineOwe'))).toBeInTheDocument() - expect(within(hero).getByText('You owe $35.51')).toBeInTheDocument() + expect(within(hero).getByText('You owe $32.51')).toBeInTheDocument() expect(within(hero).getByRole('link', { name: /Hike crew/ })).toHaveAttribute( 'href', '/groups/g-hike', ) + // A one-on-one tab goes by the friend, never its stored name. + expect(within(hero).getByRole('link', { name: /Jules Park/ })).toHaveAttribute( + 'href', + '/groups/g-jules', + ) + expect(within(hero).queryByText('Direct')).not.toBeInTheDocument() + // Every other currency with its amounts, never converted. expect( - within(hero).getByText(t('home.balance.otherCurrencies', { currencies: 'EUR' })), + within(hero).getByText( + t('home.balance.otherTotals', { + list: `${t('home.balance.otherOwed', { amount: '€12.00' })} ${t('common.and')} ${t('home.balance.otherOwe', { amount: '£5.00' })}`, + }), + ), ).toBeInTheDocument() }) diff --git a/apps/web/src/features/dashboard/summary.test.ts b/apps/web/src/features/dashboard/summary.test.ts index bc254407..ef905007 100644 --- a/apps/web/src/features/dashboard/summary.test.ts +++ b/apps/web/src/features/dashboard/summary.test.ts @@ -5,6 +5,7 @@ const b = (groupId: string, netCents: number, currency = 'USD') => ({ groupId, groupName: groupId, groupEmoji: null, + isDirect: false, currency, netCents, }) @@ -20,7 +21,7 @@ describe('summarizeBalances', () => { expect(s.owed).toBe(5000) expect(s.net).toBe(3500) expect(s.oweGroups.map((g) => g.groupId)).toEqual(['a', 'c']) - expect(s.otherCurrencies).toEqual(['EUR']) + expect(s.otherCurrencies).toEqual([{ currency: 'EUR', owed: 900, owe: 0 }]) expect(s.groupCount).toBe(4) }) @@ -30,6 +31,17 @@ describe('summarizeBalances', () => { expect(s.net).toBe(50) }) + it('keeps what is owed and owing apart in each other currency', () => { + const s = summarizeBalances( + [b('a', 100), b('b', 700, 'GBP'), b('c', -200, 'GBP'), b('d', -50, 'EUR'), b('e', 0, 'JPY')], + 'USD', + ) + expect(s.otherCurrencies).toEqual([ + { currency: 'EUR', owed: 0, owe: -50 }, + { currency: 'GBP', owed: 700, owe: -200 }, + ]) + }) + it('is all square with no balances', () => { expect(summarizeBalances([], 'CAD')).toMatchObject({ currency: 'CAD', net: 0, groupCount: 0 }) }) diff --git a/apps/web/src/features/dashboard/summary.ts b/apps/web/src/features/dashboard/summary.ts index c9e9f37d..d210adf6 100644 --- a/apps/web/src/features/dashboard/summary.ts +++ b/apps/web/src/features/dashboard/summary.ts @@ -10,8 +10,11 @@ export interface BalanceSummary { owed: number oweGroups: HomeBalance[] owedGroups: HomeBalance[] - /** Other currencies with a non-zero balance (not summed: no conversion on the client). */ - otherCurrencies: string[] + /** + * What the viewer is owed and owes in each other currency, kept apart (no conversion on the + * client), by currency code. Only currencies with something on either side. + */ + otherCurrencies: { currency: string; owed: number; owe: number }[] groupCount: number } @@ -38,11 +41,15 @@ export function summarizeBalances( const owedGroups = inCurrency.filter((b) => b.netCents > 0).sort(byMagnitude) const owe = oweGroups.reduce((s, b) => s + b.netCents, 0) const owed = owedGroups.reduce((s, b) => s + b.netCents, 0) - const otherCurrencies = [ - ...new Set( - balances.filter((b) => b.currency !== currency && b.netCents !== 0).map((b) => b.currency), - ), - ].sort() + const others = new Map() + for (const b of balances) { + if (b.currency === currency || b.netCents === 0) continue + const totals = others.get(b.currency) ?? { currency: b.currency, owed: 0, owe: 0 } + if (b.netCents > 0) totals.owed += b.netCents + else totals.owe += b.netCents + others.set(b.currency, totals) + } + const otherCurrencies = [...others.values()].sort((a, b) => a.currency.localeCompare(b.currency)) return { currency, diff --git a/packages/api/src/plans.test.ts b/packages/api/src/plans.test.ts index af0aae86..009b3535 100644 --- a/packages/api/src/plans.test.ts +++ b/packages/api/src/plans.test.ts @@ -110,18 +110,41 @@ describe('fetchHomeBalances', () => { { group_id: 'g2', net_cents: null }, { group_id: 'gone', net_cents: 9 }, { group_id: null, net_cents: 9 }, + { group_id: 'g3', net_cents: -1200 }, ], }) .respond('from:groups', { data: [ - { id: 'g1', name: 'Flat', emoji: null, currency: 'EUR' }, - { id: 'g2', name: 'Trip', emoji: 'x', currency: 'USD' }, + { id: 'g1', name: 'Flat', emoji: null, currency: 'EUR', is_direct: false }, + { id: 'g2', name: 'Trip', emoji: 'x', currency: 'USD', is_direct: false }, + { + id: 'g3', + name: 'Direct', + emoji: null, + currency: 'USD', + is_direct: true, + group_members: [ + { profiles: { id: 'u1', display_name: 'Pat' } }, + { profiles: { id: 'u2', display_name: 'Jules Park' } }, + { profiles: null }, + ], + }, ], }) + const home = { groupEmoji: null, isDirect: false } expect(await fetchHomeBalances(fake.client, 'u1')).toEqual([ - { groupId: 'g1', groupName: 'Flat', groupEmoji: null, currency: 'EUR', netCents: 250 }, - { groupId: 'g2', groupName: 'Trip', groupEmoji: 'x', currency: 'USD', netCents: 0 }, + { ...home, groupId: 'g1', groupName: 'Flat', currency: 'EUR', netCents: 250 }, + { ...home, groupId: 'g2', groupName: 'Trip', groupEmoji: 'x', currency: 'USD', netCents: 0 }, + // A one-on-one tab goes by the friend, never its stored name. + { + ...home, + groupId: 'g3', + groupName: 'Jules Park', + isDirect: true, + currency: 'USD', + netCents: -1200, + }, ]) expect(argsOf(fake.queries('group_balances')[0]!, 'eq')).toEqual([['user_id', 'u1']]) }) diff --git a/packages/api/src/plans.ts b/packages/api/src/plans.ts index aa3ce849..39485d04 100644 --- a/packages/api/src/plans.ts +++ b/packages/api/src/plans.ts @@ -3,6 +3,7 @@ * * Every function takes the client first so web and mobile can pass their own. */ +import { groupDisplayName } from '@potluck/core' import { queryOptions } from '@tanstack/react-query' import type { Database } from './database.types' import type { PotluckClient } from './index' @@ -65,7 +66,10 @@ export interface PlanProfile { /** One group's net for the viewer, in that group's currency. Positive means they are owed. */ export interface HomeBalance { groupId: string + /** The group's name; a one-on-one tab goes by the friend ('' if they are unknown). */ groupName: string + /** A one-on-one tab between two friends. */ + isDirect: boolean groupEmoji: string | null currency: string netCents: number @@ -183,7 +187,9 @@ export async function fetchHomeBalances( ): Promise { const [balances, groups] = await Promise.all([ client.from('group_balances').select('group_id, net_cents').eq('user_id', userId), - client.from('groups').select('id, name, emoji, currency, is_direct'), + client + .from('groups') + .select('id, name, emoji, currency, is_direct, group_members (profiles (id, display_name))'), ]) if (balances.error) fail(balances.error) if (groups.error) fail(groups.error) @@ -192,9 +198,16 @@ export async function fetchHomeBalances( for (const row of balances.data) { const group = row.group_id ? byId.get(row.group_id) : undefined if (!group) continue + const members = (group.group_members ?? []).flatMap((m) => (m.profiles ? [m.profiles] : [])) out.push({ groupId: group.id, - groupName: group.name, + groupName: groupDisplayName( + { name: group.name, isDirect: group.is_direct, memberIds: members.map((p) => p.id) }, + userId, + (id) => members.find((p) => p.id === id)?.display_name, + '', + ), + isDirect: group.is_direct, groupEmoji: group.emoji, currency: group.currency, netCents: Number(row.net_cents ?? 0), diff --git a/packages/i18n/src/de/plans.ts b/packages/i18n/src/de/plans.ts index f0c5b148..a4cba2ae 100644 --- a/packages/i18n/src/de/plans.ts +++ b/packages/i18n/src/de/plans.ts @@ -307,7 +307,9 @@ export const home = { settledHint: 'Niemand schuldet irgendwem etwas. Genieß es, solange es hält.', youOwe: 'Du schuldest', owedToYou: 'Du bekommst', - otherCurrencies: 'Dazu Salden in {currencies}', + otherTotals: 'Dazu: {list}', + otherOwed: 'du bekommst {amount}', + otherOwe: 'du schuldest {amount}', acrossGroups_one: 'in {count} Gruppe', acrossGroups_other: 'in {count} Gruppen', }, diff --git a/packages/i18n/src/en/plans.ts b/packages/i18n/src/en/plans.ts index 66674d00..c52ac698 100644 --- a/packages/i18n/src/en/plans.ts +++ b/packages/i18n/src/en/plans.ts @@ -414,7 +414,10 @@ export const home = { settledHint: 'Nobody owes anybody. Enjoy it while it lasts.', youOwe: 'You owe', owedToYou: 'Owed to you', - otherCurrencies: 'Plus balances in {currencies}', + /** Totals in other currencies, never converted: "Plus you are owed 12.00 EUR and you owe 5.00 GBP". */ + otherTotals: 'Plus {list}', + otherOwed: 'you are owed {amount}', + otherOwe: 'you owe {amount}', acrossGroups_one: 'across {count} group', acrossGroups_other: 'across {count} groups', }, diff --git a/packages/i18n/src/es/plans.ts b/packages/i18n/src/es/plans.ts index 144d331e..4fdb2f67 100644 --- a/packages/i18n/src/es/plans.ts +++ b/packages/i18n/src/es/plans.ts @@ -305,7 +305,9 @@ export const home = { settledHint: 'Nadie le debe nada a nadie. Disfrútalo mientras dure.', youOwe: 'Debes', owedToYou: 'Te deben', - otherCurrencies: 'Más saldos en {currencies}', + otherTotals: 'Además: {list}', + otherOwed: 'te deben {amount}', + otherOwe: 'debes {amount}', acrossGroups_one: 'en {count} grupo', acrossGroups_other: 'en {count} grupos', }, diff --git a/packages/i18n/src/fr/plans.ts b/packages/i18n/src/fr/plans.ts index dc2ee010..17ec3f56 100644 --- a/packages/i18n/src/fr/plans.ts +++ b/packages/i18n/src/fr/plans.ts @@ -306,7 +306,9 @@ export const home = { settledHint: 'Personne ne doit rien à personne. Profites-en tant que ça dure.', youOwe: 'Tu dois', owedToYou: 'On te doit', - otherCurrencies: 'Plus des soldes en {currencies}', + otherTotals: 'En plus : {list}', + otherOwed: 'on te doit {amount}', + otherOwe: 'tu dois {amount}', acrossGroups_one: 'sur {count} groupe', acrossGroups_other: 'sur {count} groupes', }, diff --git a/packages/i18n/src/pt/plans.ts b/packages/i18n/src/pt/plans.ts index 1dc16a74..225f8a05 100644 --- a/packages/i18n/src/pt/plans.ts +++ b/packages/i18n/src/pt/plans.ts @@ -306,7 +306,9 @@ export const home = { settledHint: 'Ninguém deve nada a ninguém. Aproveite enquanto dura.', youOwe: 'Você deve', owedToYou: 'Devem a você', - otherCurrencies: 'Mais saldos em {currencies}', + otherTotals: 'Além disso: {list}', + otherOwed: 'devem a você {amount}', + otherOwe: 'você deve {amount}', acrossGroups_one: 'em {count} grupo', acrossGroups_other: 'em {count} grupos', }, From fe48a7e4da871d32b8b5a6959936d46e40a5ad53 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:24:40 -0500 Subject: [PATCH 6/9] can_split_with: ask before offering a new expense with someone Answers whether a new expense with a person would go in, by the same rules the writes apply (an existing tab refuses only the blocked side; a new one needs a contact and no block either way). A plain no, like not_a_friend, so a block is never named. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- docs/security.md | 36 ++++----- packages/api/src/database.types.ts | 1 + packages/api/src/people.test.ts | 12 +++ packages/api/src/people.ts | 15 ++++ .../20261114060521_can_split_with.sql | 42 ++++++++++ supabase/schema/current.sql | 44 ++++++++++ supabase/tests/01_schema.test.sql | 3 +- supabase/tests/30_can_split_with.test.sql | 80 +++++++++++++++++++ 8 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 supabase/migrations/20261114060521_can_split_with.sql create mode 100644 supabase/tests/30_can_split_with.test.sql diff --git a/docs/security.md b/docs/security.md index ef19a39d..df5050b0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -14,24 +14,24 @@ How Potluck protects its data, what it stores, and where the known weak spots ar ### Who we defend against -| Actor | Can | Stopped by | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Anyone on the internet | call the API with the publishable key | grants: `anon` can read `app_config`, call `invite_preview`, `invite_preview_v2`, `friend_invite_preview` (each needs a link code), `email_unsubscribe` (needs a signed token) and the two guest link RPCs (need a guest link code), and read the public buckets. Nothing else | -| A signed-in stranger | call every RPC, query every table | RLS on every table, `require_member()` in every group RPC | -| A group member overreaching | edit others' expenses, promote themselves, delete the group | role checks inside RPCs and policies, the role matrix in [architecture.md](architecture.md#roles) | -| A removed or departed member | keep reading the group | membership is checked on every query. Realtime re-applies RLS per change | -| Someone holding an invite link | see the group name, member count, first names; join | expiry, max uses, revocation, optional email binding | -| Someone holding a guest link | see one plan's title, window, the link creator's first name and how many people are free per slot (once it has a time, only guests who answered see it); add or edit one answer per browser | expiry (24 hours to 30 days), revocation, a guest cap per link, plan and group state checked on every call, rate limits, the token kept as a sha256 hash, removed answers blocked | -| Someone holding a calendar feed URL | read the owner's scheduled events | 32-character random token, rotatable with `calendar_feed_token(true)` | -| A client with a tampered bundle | send any payload to any RPC | the server derives splits, FX rates, payer of a settlement and `created_by` itself | -| Someone calling edge functions directly | trigger push or FX sync | `x-potluck-secret` shared secret, constant-time compare | -| Someone guessing unsubscribe links | turn off another person's emails | each link carries an HMAC of user id and category; the key stays in `private.signing_keys`. A preview shows the address masked | -| Someone calling `email_outbox_report` | mark emails sent or failed | executable by the service role only | -| Someone who learned a password | sign in as that person | two-step sign in when it is on: the database refuses the half signed in session ([below](#sign-in)) | -| Someone taking a seat that is not theirs | claim another person's placeholder and its history | a claim link bound to an email only works for an account with that email, confirmed (`email_not_verified`); automatic claiming needs a confirmed matching email; an unbound link is single use and only its holder has it; nobody already in the group can claim ([ADR 19](adr/0019-placeholder-members-and-claiming.md)) | -| A member spamming addresses | make Potluck email strangers by adding placeholders | one invite plus one reminder per placeholder, at most 3 addresses per placeholder and 20 emailed invites a day per person; every email has one-click unsubscribe | -| Someone holding a friend link | see the inviter's first name and face; become their friend | email links are single use and bound to a confirmed email; the personal link can be rotated; blocks win and read like a dead link (no name shown) | -| Someone who was blocked | find out, or keep reaching the person who blocked them | every refusal reads like a revoked link or a stranger (`invite_revoked`, `not_a_friend`, previews with no name); notifications whose actor the recipient blocked are dropped, shared groups included; a seat's invite and reminder are not sent to an account with a block with the sender; in a 1:1 tab the blocked side can no longer change money | +| Actor | Can | Stopped by | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Anyone on the internet | call the API with the publishable key | grants: `anon` can read `app_config`, call `invite_preview`, `invite_preview_v2`, `friend_invite_preview` (each needs a link code), `email_unsubscribe` (needs a signed token) and the two guest link RPCs (need a guest link code), and read the public buckets. Nothing else | +| A signed-in stranger | call every RPC, query every table | RLS on every table, `require_member()` in every group RPC | +| A group member overreaching | edit others' expenses, promote themselves, delete the group | role checks inside RPCs and policies, the role matrix in [architecture.md](architecture.md#roles) | +| A removed or departed member | keep reading the group | membership is checked on every query. Realtime re-applies RLS per change | +| Someone holding an invite link | see the group name, member count, first names; join | expiry, max uses, revocation, optional email binding | +| Someone holding a guest link | see one plan's title, window, the link creator's first name and how many people are free per slot (once it has a time, only guests who answered see it); add or edit one answer per browser | expiry (24 hours to 30 days), revocation, a guest cap per link, plan and group state checked on every call, rate limits, the token kept as a sha256 hash, removed answers blocked | +| Someone holding a calendar feed URL | read the owner's scheduled events | 32-character random token, rotatable with `calendar_feed_token(true)` | +| A client with a tampered bundle | send any payload to any RPC | the server derives splits, FX rates, payer of a settlement and `created_by` itself | +| Someone calling edge functions directly | trigger push or FX sync | `x-potluck-secret` shared secret, constant-time compare | +| Someone guessing unsubscribe links | turn off another person's emails | each link carries an HMAC of user id and category; the key stays in `private.signing_keys`. A preview shows the address masked | +| Someone calling `email_outbox_report` | mark emails sent or failed | executable by the service role only | +| Someone who learned a password | sign in as that person | two-step sign in when it is on: the database refuses the half signed in session ([below](#sign-in)) | +| Someone taking a seat that is not theirs | claim another person's placeholder and its history | a claim link bound to an email only works for an account with that email, confirmed (`email_not_verified`); automatic claiming needs a confirmed matching email; an unbound link is single use and only its holder has it; nobody already in the group can claim ([ADR 19](adr/0019-placeholder-members-and-claiming.md)) | +| A member spamming addresses | make Potluck email strangers by adding placeholders | one invite plus one reminder per placeholder, at most 3 addresses per placeholder and 20 emailed invites a day per person; every email has one-click unsubscribe | +| Someone holding a friend link | see the inviter's first name and face; become their friend | email links are single use and bound to a confirmed email; the personal link can be rotated; blocks win and read like a dead link (no name shown) | +| Someone who was blocked | find out, or keep reaching the person who blocked them | every refusal reads like a revoked link or a stranger (`invite_revoked`, `not_a_friend`, a plain no from `can_split_with`, previews with no name); notifications whose actor the recipient blocked are dropped, shared groups included; a seat's invite and reminder are not sent to an account with a block with the sender; in a 1:1 tab the blocked side can no longer change money | Out of scope: a compromised Supabase or Cloudflare account, a malicious maintainer, and a stolen unlocked phone with an active session. diff --git a/packages/api/src/database.types.ts b/packages/api/src/database.types.ts index f55e6e1e..d6c458cc 100644 --- a/packages/api/src/database.types.ts +++ b/packages/api/src/database.types.ts @@ -1856,6 +1856,7 @@ export type Database = { } block_user: { Args: { user_id: string }; Returns: undefined } calendar_feed_token: { Args: { rotate?: boolean }; Returns: string } + can_split_with: { Args: { person: string }; Returns: boolean } cancel_event: { Args: { event_id: string } Returns: { diff --git a/packages/api/src/people.test.ts b/packages/api/src/people.test.ts index 846c6dcf..baab06e5 100644 --- a/packages/api/src/people.test.ts +++ b/packages/api/src/people.test.ts @@ -4,6 +4,8 @@ import { addFriend, addPlaceholderMember, blockUser, + canSplitWith, + canSplitWithQueryOptions, createFriendInvite, fetchFriendInvitePreview, fetchGroupPlaceholders, @@ -215,6 +217,16 @@ describe('blocks and reports', () => { expect(myBlocksQueryOptions(fake.client, 'me').queryKey).toEqual(peopleKeys.blocks('me')) }) + it('asks whether a new expense with someone would go in', async () => { + const fake = fakeSupabase().respond('rpc:can_split_with', { data: true }, { data: false }) + expect(await canSplitWith(fake.client, 'u2')).toBe(true) + expect(await canSplitWith(fake.client, 'u3')).toBe(false) + expect(fake.rpcArgs('can_split_with')).toEqual([{ person: 'u2' }, { person: 'u3' }]) + expect(canSplitWithQueryOptions(fake.client, 'u2').queryKey).toEqual(peopleKeys.canSplit('u2')) + const failing = fakeSupabase().respond('rpc:can_split_with', { error: boom }) + await expect(canSplitWith(failing.client, 'u2')).rejects.toBe(boom) + }) + it('files a report with a trimmed reason', async () => { const fake = fakeSupabase() await reportPerson(fake.client, { userId: 'u2', reason: ' spam ' }) diff --git a/packages/api/src/people.ts b/packages/api/src/people.ts index d99eba09..6e5587af 100644 --- a/packages/api/src/people.ts +++ b/packages/api/src/people.ts @@ -18,6 +18,7 @@ export const peopleKeys = { friendInvite: (code: string, viewerId: string | null = null) => ['potluck', 'people', 'friend-invite', code, viewerId ?? 'anon'] as const, blocks: (userId: string) => ['potluck', 'people', 'blocks', userId] as const, + canSplit: (personId: string) => ['potluck', 'people', 'can-split', personId] as const, } // Placeholders ------------------------------------------------------------------------------- @@ -211,6 +212,20 @@ export const myBlocksQueryOptions = (client: PotluckClient, userId: string) => queryFn: () => fetchMyBlocks(client), }) +/** + * Whether a new expense with this person would go in (`can_split_with`): false for someone you + * do not know, a seat, a closed account, or across a block (never saying which). + */ +export async function canSplitWith(client: PotluckClient, personId: string): Promise { + return unwrap(await client.rpc('can_split_with', { person: personId })) === true +} + +export const canSplitWithQueryOptions = (client: PotluckClient, personId: string) => + queryOptions({ + queryKey: peopleKeys.canSplit(personId), + queryFn: () => canSplitWith(client, personId), + }) + /** Tells the Potluck team about a person (or a group). */ export async function reportPerson( client: PotluckClient, diff --git a/supabase/migrations/20261114060521_can_split_with.sql b/supabase/migrations/20261114060521_can_split_with.sql new file mode 100644 index 00000000..000bc6e2 --- /dev/null +++ b/supabase/migrations/20261114060521_can_split_with.sql @@ -0,0 +1,42 @@ +-- can_split_with(person): whether a new expense with this person can go in, so the friend page +-- can hide "Add expense" instead of letting it fail. Same rules the writes apply: +-- - a one-on-one tab that already exists takes writes unless the person blocked the caller +-- (private.freeze_blocked_direct_money); +-- - otherwise get_or_create_direct_group needs a contact who is not a seat or a closed account, +-- and no block either way. +-- The answer is a plain no, like the `not_a_friend` those writes raise, so a block is never named. + +create or replace function public.can_split_with(person uuid) returns boolean +language plpgsql stable security definer set search_path = '' as $$ +declare + uid uuid := private.require_user(); +begin + if person is null or person = uid then + return false; + end if; + + if exists ( + select 1 from public.groups g + where g.is_direct + and g.deleted_at is null + and exists (select 1 from public.group_members m where m.group_id = g.id and m.user_id = uid) + and exists (select 1 from public.group_members m where m.group_id = g.id and m.user_id = person) + ) then + return not exists ( + select 1 from public.blocks b where b.blocker_id = person and b.blocked_id = uid + ); + end if; + + return exists ( + select 1 from public.profiles p + where p.id = person + and p.deleted_at is null + and not p.is_placeholder + and p.id in (select private.my_contact_ids()) + ) + and not private.has_block(uid, person); +end; +$$; + +revoke execute on function public.can_split_with(uuid) from public, anon; +grant execute on function public.can_split_with(uuid) to authenticated, service_role; diff --git a/supabase/schema/current.sql b/supabase/schema/current.sql index bd313b82..0d9a6995 100644 --- a/supabase/schema/current.sql +++ b/supabase/schema/current.sql @@ -5739,6 +5739,44 @@ $$; ALTER FUNCTION "public"."calendar_feed_token"("rotate" boolean) OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "public"."can_split_with"("person" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +declare + uid uuid := private.require_user(); +begin + if person is null or person = uid then + return false; + end if; + + if exists ( + select 1 from public.groups g + where g.is_direct + and g.deleted_at is null + and exists (select 1 from public.group_members m where m.group_id = g.id and m.user_id = uid) + and exists (select 1 from public.group_members m where m.group_id = g.id and m.user_id = person) + ) then + return not exists ( + select 1 from public.blocks b where b.blocker_id = person and b.blocked_id = uid + ); + end if; + + return exists ( + select 1 from public.profiles p + where p.id = person + and p.deleted_at is null + and not p.is_placeholder + and p.id in (select private.my_contact_ids()) + ) + and not private.has_block(uid, person); +end; +$$; + + +ALTER FUNCTION "public"."can_split_with"("person" "uuid") OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "public"."cancel_event"("event_id" "uuid") RETURNS "public"."events" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -13656,6 +13694,12 @@ GRANT ALL ON FUNCTION "public"."calendar_feed_token"("rotate" boolean) TO "servi +REVOKE ALL ON FUNCTION "public"."can_split_with"("person" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."can_split_with"("person" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."can_split_with"("person" "uuid") TO "service_role"; + + + REVOKE ALL ON FUNCTION "public"."cancel_event"("event_id" "uuid") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."cancel_event"("event_id" "uuid") TO "authenticated"; GRANT ALL ON FUNCTION "public"."cancel_event"("event_id" "uuid") TO "service_role"; diff --git a/supabase/tests/01_schema.test.sql b/supabase/tests/01_schema.test.sql index 90fac531..d528dc6f 100644 --- a/supabase/tests/01_schema.test.sql +++ b/supabase/tests/01_schema.test.sql @@ -10,7 +10,7 @@ -- new cron job -> a cron.job check -- Count the assertions and update plan(N); finish() fails when it is off. begin; -select plan(130); +select plan(131); select tables_are('public', array[ 'profiles', 'friendships', 'blocks', 'reports', @@ -124,6 +124,7 @@ select has_function('public', 'add_friend', array['uuid'], 'add_friend(user_id)' select has_function('public', 'remove_friend', array['uuid'], 'remove_friend(friend_id)'); select has_function('public', 'block_user', array['uuid'], 'block_user(user_id)'); select has_function('public', 'my_blocks', array[]::text[], 'my_blocks()'); +select has_function('public', 'can_split_with', array['uuid'], 'can_split_with(person)'); select ok( not has_function_privilege('authenticated', 'public.email_outbox_report(uuid, boolean, text, uuid[])', 'execute'), 'only the service role reports email deliveries' diff --git a/supabase/tests/30_can_split_with.test.sql b/supabase/tests/30_can_split_with.test.sql new file mode 100644 index 00000000..f4b70f23 --- /dev/null +++ b/supabase/tests/30_can_split_with.test.sql @@ -0,0 +1,80 @@ +-- can_split_with(person) (20261114060521_can_split_with): the friend page asks it before offering +-- "Add expense". It says yes exactly when the write would go in (get_or_create_direct_group for a +-- new tab, private.freeze_blocked_direct_money in an existing one) and never says why it is no. +begin; +select plan(14); + +select tests.create_user('ava'); +select tests.create_user('ben'); +select tests.create_user('cleo'); +select tests.create_user('dan'); +select tests.create_user('stranger'); + +select tests.remember('g', tests.group_with('ava', array['ben', 'cleo', 'dan'])); + +select tests.login('ava'); +select public.add_placeholder_member(tests.id('g'), 'Sam'); +reset role; +select tests.remember('seat', ( + select p.id from public.profiles p + join public.group_members m on m.user_id = p.id and m.group_id = tests.id('g') + where p.is_placeholder +)); + +-- Who you can split with ----------------------------------------------------------------------- + +select tests.login('ava'); +select is(public.can_split_with(tests.uid('ben')), true, 'someone you share a group with'); +select is(public.can_split_with(tests.uid('stranger')), false, 'not a stranger'); +select is(public.can_split_with(tests.uid('ava')), false, 'not yourself'); +select is(public.can_split_with(null), false, 'not nobody'); +select is(public.can_split_with(tests.id('seat')), false, 'not a seat nobody has taken'); + +reset role; +update public.profiles set deleted_at = now() where id = tests.uid('dan'); +select tests.login('ava'); +select is(public.can_split_with(tests.uid('dan')), false, 'not a closed account'); + +select tests.login_anon(); +select throws_ok( + format($$ select public.can_split_with(%L) $$, tests.uid('ben')), + '42501', null, 'anon cannot ask' +); + +-- Blocks, before any tab exists: no either way, as get_or_create_direct_group refuses ---------- + +reset role; +insert into public.blocks (blocker_id, blocked_id) values (tests.uid('ben'), tests.uid('ava')); +select tests.login('ava'); +select is(public.can_split_with(tests.uid('ben')), false, 'the blocked side is told no'); +select throws_ok( + format($$ select public.get_or_create_direct_group(%L) $$, tests.uid('ben')), + '42501', 'not_a_friend', '...which is what opening the tab says' +); +select tests.login('ben'); +select is(public.can_split_with(tests.uid('ava')), false, 'and so is the side who blocked'); + +-- A tab that already exists: the blocked side is frozen, the blocker still writes --------------- + +reset role; +delete from public.blocks where blocker_id = tests.uid('ben'); +select tests.login('ava'); +select tests.remember('tab', public.get_or_create_direct_group(tests.uid('cleo'))); +select is(public.can_split_with(tests.uid('cleo')), true, 'an open tab with no block'); + +reset role; +insert into public.blocks (blocker_id, blocked_id) values (tests.uid('cleo'), tests.uid('ava')); +select tests.login('ava'); +select is(public.can_split_with(tests.uid('cleo')), false, 'the blocked side of a tab cannot add'); +select throws_ok( + format( + $$ select public.settle_up_v2(%L, %L, %L, 100) $$, + tests.id('tab'), tests.uid('ava'), tests.uid('cleo') + ), + '42501', 'not_a_friend', '...as the tab itself refuses their money' +); +select tests.login('cleo'); +select is(public.can_split_with(tests.uid('ava')), true, 'the blocker still can, as the tab allows'); + +select * from finish(); +rollback; From acda99f4192c84d0089e71fa275ddd469ed249bf Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:24:42 -0500 Subject: [PATCH 7/9] friend page: rows show what is between you two Each expense row on a friend's page showed the viewer's whole lead in the expense ("Concert, you lent $200" when the friend owes $160). Rows now show expenseBetween: what that expense left between the two of you, or "nothing between you" when you both owe someone else. With simplify off they add up to the group's standing; with it on the standing is the simplified debt and the rows are the history behind it. The menu also exports the one-friend spreadsheet (the same file as on Me), and Add expense is off with a short reason when you blocked them or can_split_with says no. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- apps/web/e2e/people.spec.ts | 53 +++++- .../web/src/features/expenses/expense-row.tsx | 21 ++- .../src/features/friends/friend-page.test.tsx | 148 +++++++++++++++- apps/web/src/features/friends/friend-page.tsx | 78 ++++++++- docs/agent-map.json | 158 ++++++++++-------- docs/architecture.md | 8 +- docs/development.md | 2 + docs/testing.md | 2 +- packages/i18n/src/de/people.ts | 4 + packages/i18n/src/en/people.ts | 4 + packages/i18n/src/es/people.ts | 4 + packages/i18n/src/fr/people.ts | 4 + packages/i18n/src/pt/people.ts | 4 + 13 files changed, 400 insertions(+), 90 deletions(-) diff --git a/apps/web/e2e/people.spec.ts b/apps/web/e2e/people.spec.ts index 141b0f91..1f178578 100644 --- a/apps/web/e2e/people.spec.ts +++ b/apps/web/e2e/people.spec.ts @@ -1,11 +1,14 @@ +import { readFile } from 'node:fs/promises' import { + addFriend, addPlaceholderMember, + blockUser, createExpense, fetchMyFriendLink, removePlaceholder, } from '@potluck/api' import { formatMoney, t } from '@potluck/i18n' -import { groupWith } from './support/api' +import { addEqualExpense, groupWith } from './support/api' import { expect, PASSWORD, test, unique } from './support/fixtures' test('add Sam by name, split with him, and Sam signs up through his invite to find it all', async ({ @@ -162,3 +165,51 @@ test('Ava records that Pat, not on Potluck yet, paid her, then removes the settl await page.goto(`/groups/${group.id}/members`) await expect(page.getByRole('listitem').filter({ hasText: patName })).toHaveCount(0) }) + +test("a friend's page shows only what is between you two, exports it, and a block stops new expenses", async ({ + newUser, + openAs, +}) => { + const [ava, jules, owen] = await Promise.all([ + newUser({ name: unique('Ava') }), + newUser({ name: unique('Jules') }), + newUser({ name: unique('Owen') }), + ]) + const group = await groupWith(ava, [jules, owen]) + await addFriend(ava.client, jules.id) + // Ava paid 90 for the three of them: she is up 60, but only 30 of it is Jules's. + const dinner = unique('Dinner') + await addEqualExpense(ava, group, [ava, jules, owen], 9000, dinner) + // Owen paid 60 for the three: Ava and Jules each owe Owen, not each other. + const taxi = unique('Taxi') + await addEqualExpense(owen, group, [ava, jules, owen], 6000, taxi) + + const page = await openAs(ava) + await page.goto(`/friends/${jules.id}`) + const history = page.getByRole('region', { name: t('people.friendPage.history') }) + await expect( + history + .getByRole('link', { name: new RegExp(dinner) }) + .getByText(t('expenses.youLent', { amount: formatMoney(3000, 'USD') })), + ).toBeAttached() + await expect( + history + .getByRole('link', { name: new RegExp(taxi) }) + .getByText(t('people.friendPage.nothingBetween')), + ).toBeVisible() + + await page + .getByRole('button', { name: t('people.friendPage.menu', { name: jules.name }) }) + .click() + const download = page.waitForEvent('download') + await page.getByRole('button', { name: new RegExp(t('people.friendPage.export')) }).click() + const csv = await readFile((await (await download).path())!, 'utf8') + expect(csv).toContain(t('transfer.csv.between', { name: jules.name })) + expect(csv).toContain(dinner) + + await blockUser(jules.client, ava.id) + await page.reload() + const add = page.getByRole('button', { name: t('people.friendPage.addExpense') }) + await expect(add).toBeDisabled() + await expect(page.getByText(t('errors.codes.not_a_friend'))).toBeVisible() +}) diff --git a/apps/web/src/features/expenses/expense-row.tsx b/apps/web/src/features/expenses/expense-row.tsx index 2fd0b9ab..85f7bbaf 100644 --- a/apps/web/src/features/expenses/expense-row.tsx +++ b/apps/web/src/features/expenses/expense-row.tsx @@ -5,7 +5,13 @@ import { Money } from './money' import { Link } from '@tanstack/react-router' import { ArrowRightLeft, Repeat } from 'lucide-react' import type { ReactNode } from 'react' -import { paidSummary, settlementText, viewerLine, type NameOf } from './expense-math' +import { + paidSummary, + settlementText, + viewerLine, + type NameOf, + type ViewerLine, +} from './expense-math' import { categoryEmoji } from './format' export function CategoryTile({ @@ -67,6 +73,8 @@ export function ExpenseRow({ nameOf, groupCurrency, locale, + line: given, + noneLabel, }: { expense: ExpenseWithSplits viewerId: string @@ -75,8 +83,15 @@ export function ExpenseRow({ /** The group's currency: the right side is the viewer's change in the group balance. */ groupCurrency: string locale?: string + /** + * The right side instead of the viewer's change in the group balance, in the group currency: + * the friend page shows what the expense left between the viewer and that friend. + */ + line?: ViewerLine + /** Shown on the right when `line` moves nothing; "not involved" by default. */ + noneLabel?: string }) { - const line = viewerLine(expense, viewerId, groupCurrency) + const line = given ?? viewerLine(expense, viewerId, groupCurrency) const subtitle = paidSummary(expense, viewerId, nameOf, locale) const refund = expense.is_refund const lineAmount = line.kind === 'none' ? '' : formatMoney(line.cents, groupCurrency, { locale }) @@ -109,7 +124,7 @@ export function ExpenseRow({ subtitle={subtitle} right={ line.kind === 'none' ? ( - {t('expenses.notInvolved')} + {noneLabel ?? t('expenses.notInvolved')} ) : ( <> import('@/test/supabase')) +vi.mock('@/features/export-import/download', async (importOriginal) => ({ + ...(await importOriginal()), + saveFile: vi.fn(), +})) vi.mock('@/lib/auth', async (importOriginal) => ({ ...(await importOriginal()), useUserId: () => VIEWER, @@ -154,6 +160,144 @@ describe('FriendPage', () => { ).toHaveLength(2) }) + it('shows on each row only what the expense left between the two of you', async () => { + const payer = (user_id: string, paid_cents: number) => [{ user_id, paid_cents }] + resetDb({ + tables: { + ...groupTables(group({ simplify_debts: false })), + profiles: [ + person(VIEWER, 'Ava Chen'), + person(BEN, 'Ben Ortiz'), + person('u-cleo', 'Cleo Park'), + ], + friendships: [{ user_id: VIEWER, friend: person(BEN, 'Ben Ortiz') }], + expenses: [ + // Ava paid 30 for Ben (20) and Cleo (10): Ava is up 30, but only 20 is Ben's. + expense({ + id: 'e-concert', + group_id: 'g-hike', + description: 'Concert', + amount_cents: 3000, + paid_by: VIEWER, + spent_at: '2026-11-03', + expense_payers: payer(VIEWER, 3000), + expense_splits: [split(BEN, 2000), split('u-cleo', 1000)], + }), + // Cleo paid rent for all three: Ava and Ben both owe Cleo, not each other. + expense({ + id: 'e-rent', + group_id: 'g-hike', + description: 'Rent', + amount_cents: 900, + paid_by: 'u-cleo', + spent_at: '2026-11-04', + expense_payers: payer('u-cleo', 900), + expense_splits: [split(VIEWER, 300), split(BEN, 300), split('u-cleo', 300)], + }), + // Ben paid a taxi for Ava, Cleo and himself: Ava owes Ben her 3. + expense({ + id: 'e-taxi', + group_id: 'g-hike', + description: 'Taxi', + amount_cents: 900, + paid_by: BEN, + spent_at: '2026-11-05', + expense_payers: payer(BEN, 900), + expense_splits: [split(VIEWER, 300), split(BEN, 300), split('u-cleo', 300)], + }), + ].map((row) => ({ ...row })), + settlements: [], + }, + }).respond('rpc:friend_balances', { + // With simplify off this is the sum of the rows: 20 - 3. + data: [{ friend_id: BEN, group_id: 'g-hike', currency: 'USD', net_cents: 1700 }], + }) + renderPage() + const history = await screen.findByRole('region', { name: t('people.friendPage.history') }) + const row = (name: string) => within(history).getByRole('link', { name: new RegExp(name) }) + + const concert = row('Concert') + expect(within(concert).getByText(t('expenses.youLentLabel'))).toBeInTheDocument() + expect( + within(concert).getByText(t('expenses.youLent', { amount: '$20.00' })), + ).toBeInTheDocument() + expect(concert.textContent).not.toContain(t('expenses.youLent', { amount: '$30.00' })) + + const rent = row('Rent') + expect(within(rent).getByText(t('people.friendPage.nothingBetween'))).toBeInTheDocument() + expect(rent.textContent).not.toContain(t('expenses.youBorrowedLabel')) + + const taxi = row('Taxi') + expect(within(taxi).getByText(t('expenses.youBorrowedLabel'))).toBeInTheDocument() + expect(taxi.textContent).toContain('$3.00') + + // The header total and the group's standing (both from friend_balances). + expect( + screen.getAllByText(t('people.friendPage.owesYou', { name: 'Ben Ortiz', amount: '$17.00' })), + ).toHaveLength(2) + }) + + it('exports what you share with this friend from the menu', async () => { + vi.mocked(saveFile).mockClear() + db().respond('rpc:export_my_data', { + data: { + format: 'potluck-backup', + version: 1, + exported_at: '2026-11-07T10:00:00Z', + profile: { + id: '00000000-0000-4000-8000-000000000001', + display_name: 'Ava Chen', + email: 'ava@example.com', + avatar_path: null, + timezone: 'UTC', + default_currency: 'USD', + locale: 'en', + created_at: '2026-11-07T10:00:00Z', + }, + people: [], + payment_methods: [], + friends: [], + groups: [], + }, + }) + renderPage() + fireEvent.click( + await screen.findByRole('button', { + name: t('people.friendPage.menu', { name: 'Ben Ortiz' }), + }), + ) + fireEvent.click( + await screen.findByRole('button', { name: new RegExp(t('people.friendPage.export')) }), + ) + await waitFor(() => expect(saveFile).toHaveBeenCalledTimes(1)) + const [name, content] = vi.mocked(saveFile).mock.calls[0]! + expect(name).toMatch(/^ben-ortiz-/) + // The one-friend export: a between column for the two of you. + expect(content.split('\r\n')[0]).toContain(t('transfer.csv.between', { name: '' }).trim()) + }) + + it('keeps Add expense off, with a reason, across a block', async () => { + db().respond('rpc:my_blocks', { + data: [{ user_id: BEN, display_name: 'Ben Ortiz', avatar_path: null, blocked_at: 't' }], + }) + renderPage() + const add = await screen.findByRole('button', { name: t('people.friendPage.addExpense') }) + await waitFor(() => expect(add).toBeDisabled()) + expect(add).toHaveAccessibleDescription( + t('people.friendPage.youBlocked', { name: 'Ben Ortiz' }), + ) + }) + + it('keeps Add expense off when the server says no, without saying why', async () => { + db().respond('rpc:can_split_with', { data: false }) + renderPage() + const add = await screen.findByRole('button', { name: t('people.friendPage.addExpense') }) + await waitFor(() => expect(add).toBeDisabled()) + expect(add).toHaveAccessibleDescription(t('errors.codes.not_a_friend')) + fireEvent.click(add) + expect(db().rpcArgs('get_or_create_direct_group')).toEqual([]) + }) + it('settles across every shared group from the same sheet as the friends list', async () => { renderPage() fireEvent.click(await screen.findByRole('button', { name: t('people.friendPage.settleUp') })) @@ -240,7 +384,9 @@ describe('FriendPage', () => { expenses: [], settlements: [], }, - }).respond('rpc:get_or_create_direct_group', { data: 'g-new' }) + }) + .respond('rpc:get_or_create_direct_group', { data: 'g-new' }) + .respond('rpc:can_split_with', { data: true }) const { router } = renderPage() fireEvent.click(await screen.findByRole('button', { name: t('people.friendPage.addExpense') })) await waitFor(() => expect(router.state.location.pathname).toBe('/new/expense')) diff --git a/apps/web/src/features/friends/friend-page.tsx b/apps/web/src/features/friends/friend-page.tsx index 6d8d4378..bddbe9e7 100644 --- a/apps/web/src/features/friends/friend-page.tsx +++ b/apps/web/src/features/friends/friend-page.tsx @@ -1,9 +1,11 @@ import { blockUser, + canSplitWithQueryOptions, friendsQueryOptions, getOrCreateDirectGroup, groupKeys, moneyKeys, + myBlocksQueryOptions, paymentKeys, paymentQueries, peopleKeys, @@ -14,7 +16,7 @@ import { type GroupDetail, type Settlement, } from '@potluck/api' -import { dayKey, expenseInvolvesBoth, friendTotals, settlementBetween } from '@potluck/core' +import { dayKey, expenseBetween, friendTotals, settlementBetween } from '@potluck/core' import { formatMoney, intlLocale, t } from '@potluck/i18n' import { Amount, @@ -36,6 +38,7 @@ import { Link, useNavigate } from '@tanstack/react-router' import { ArrowRightLeft, ChevronLeft, + FileSpreadsheet, Flag, MoreHorizontal, Plus, @@ -44,9 +47,10 @@ import { } from 'lucide-react' import { useMemo, useState, type FormEvent, type ReactNode } from 'react' import { ExpenseRow, SettlementRow } from '@/features/expenses/expense-row' -import { toBalanceExpense } from '@/features/expenses/expense-math' +import { toBalanceExpense, type ViewerLine } from '@/features/expenses/expense-math' import { dayLabel, groupByDay } from '@/features/expenses/format' import { useNameOf, useTimeZone } from '@/features/expenses/money-data' +import { useAccountExports } from '@/features/export-import/use-exports' import { GroupTile } from '@/features/groups/group-bits' import { useMyGroups, useMyGroupsRealtime } from '@/features/groups/use-groups' import { toBalanceSettlement, useLedgers } from './friend-ledger' @@ -57,7 +61,15 @@ import { avatarSrc } from '@/lib/queries' import { supabase } from '@/lib/supabase' type HistoryItem = - | { kind: 'expense'; day: string; at: string; group: GroupDetail; expense: ExpenseWithSplits } + | { + kind: 'expense' + day: string + at: string + group: GroupDetail + expense: ExpenseWithSplits + /** What this expense left between the viewer and the friend (`expenseBetween`). */ + line: ViewerLine + } | { kind: 'settlement'; day: string; at: string; group: GroupDetail; settlement: Settlement } interface Standing { @@ -70,6 +82,12 @@ interface Standing { * Everything the viewer and a friend share: where they stand in each group, from the same * `friend_balances` rows as the friends list (so the two always agree), and every expense and * payment between them. + * + * Each expense row shows only what it left between the two of them (core `expenseBetween`, the + * per expense step of `pairwiseDebts` and the one-friend export's between column), never the + * viewer's whole lead in it. In a group that does not simplify debts the rows and payments add + * up to the standing there; in one that simplifies, the standing is the simplified debt, which + * can move money between people, so the rows show the history behind it rather than its sum. */ function useSharedLedger(friendId: string) { const me = useUserId() @@ -95,9 +113,23 @@ function useSharedLedger(friendId: string) { for (const group of shared) { const ledger = byGroup.get(group.id)! for (const e of ledger.expenses) { - if (expenseInvolvesBoth(toBalanceExpense(e), me, friendId)) { - items.push({ kind: 'expense', day: e.spent_at, at: e.created_at, group, expense: e }) - } + const between = expenseBetween(toBalanceExpense(e), me, friendId, { + groupCurrency: group.currency, + }) + if (between === null) continue + items.push({ + kind: 'expense', + day: e.spent_at, + at: e.created_at, + group, + expense: e, + line: + between > 0 + ? { kind: 'lent', cents: between } + : between < 0 + ? { kind: 'borrowed', cents: -between } + : { kind: 'none' }, + }) } for (const s of ledger.settlements) { if (!s.deleted_at && settlementBetween(toBalanceSettlement(s), me, friendId)) { @@ -266,6 +298,9 @@ export function FriendPage({ friendId }: { friendId: string }) { const profile = useQuery(profilesByIdsQueryOptions(supabase, [friendId])) const friends = useQuery(friendsQueryOptions(supabase, me)) const ledger = useSharedLedger(friendId) + const blocks = useQuery(myBlocksQueryOptions(supabase, me)) + const canSplit = useQuery(canSplitWithQueryOptions(supabase, friendId)) + const exports = useAccountExports() const [menuOpen, setMenuOpen] = useState(false) const [pending, setPending] = useState(null) const [reporting, setReporting] = useState(false) @@ -283,6 +318,13 @@ export function FriendPage({ friendId }: { friendId: string }) { const overall = (ledger.total?.byCurrency ?? []).map((c) => [c.currency, c.netCents] as const) const friend = friends.data?.find((f) => f.id === friendId) const [settling, setSettling] = useState(false) + // A block either way stops new expenses (the server refuses them), so say so up front. Which + // side blocked is only told to the person who did it. + const splitNote = blocks.data?.some((b) => b.user_id === friendId) + ? t('people.friendPage.youBlocked', { name }) + : canSplit.data === false + ? t('errors.codes.not_a_friend') + : null if (profile.isPending || ledger.pending) return if (!person || person.id === me || person.is_placeholder) { @@ -368,7 +410,13 @@ export function FriendPage({ friendId }: { friendId: string }) { )}
      - {friend && ledger.total && ledger.total.settleable.length > 0 && ( @@ -387,6 +435,11 @@ export function FriendPage({ friendId }: { friendId: string }) { onClick={() => setMenuOpen(true)} />
      + {splitNote && ( +

      + {splitNote} +

      + )} {ledger.standings.length > 0 && ( @@ -463,6 +516,8 @@ export function FriendPage({ friendId }: { friendId: string }) { nameOf={nameOf} groupCurrency={item.group.currency} locale={intlLocale()} + line={item.line} + noneLabel={t('people.friendPage.nothingBetween')} /> ) : (
      + } + label={t('people.friendPage.export')} + hint={t('transfer.yourData.friendHint')} + onClick={() => { + setMenuOpen(false) + void exports.friend(person.id, person.display_name) + }} + /> {isFriend && ( } diff --git a/docs/agent-map.json b/docs/agent-map.json index 3dbe6b73..fdb4025d 100644 --- a/docs/agent-map.json +++ b/docs/agent-map.json @@ -337,6 +337,16 @@ "snapshotLine": 5713, "definedIn": "supabase/migrations/20261105160000_edge_function_hooks.sql" }, + { + "name": "public.can_split_with", + "args": "person uuid", + "returns": "boolean", + "securityDefiner": true, + "execute": ["authenticated", "service_role"], + "raises": [], + "snapshotLine": 5742, + "definedIn": "supabase/migrations/20261114060521_can_split_with.sql" + }, { "name": "public.cancel_event", "args": "event_id uuid", @@ -344,7 +354,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_done"], - "snapshotLine": 5742, + "snapshotLine": 5780, "definedIn": "supabase/migrations/20261105071521_rpc_events_timeline.sql" }, { @@ -354,7 +364,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5769, + "snapshotLine": 5807, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -364,7 +374,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5792, + "snapshotLine": 5830, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -374,7 +384,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5806, + "snapshotLine": 5844, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -384,7 +394,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5817, + "snapshotLine": 5855, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -394,7 +404,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_email", "invalid_friend", "too_many_invites"], - "snapshotLine": 5859, + "snapshotLine": 5897, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -404,7 +414,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_name_required", "invalid_currency"], - "snapshotLine": 5942, + "snapshotLine": 5980, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -414,7 +424,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_name_required", "invalid_currency", "trip_dates_invalid"], - "snapshotLine": 5980, + "snapshotLine": 6018, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -429,7 +439,7 @@ "invalid_guest_limit", "invalid_guest_link_ttl" ], - "snapshotLine": 6048, + "snapshotLine": 6086, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -446,7 +456,7 @@ "invalid_role", "not_allowed" ], - "snapshotLine": 6123, + "snapshotLine": 6161, "definedIn": "supabase/migrations/20261105071508_rpc_invites.sql" }, { @@ -456,7 +466,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["mfa_required"], - "snapshotLine": 6177, + "snapshotLine": 6215, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -466,7 +476,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["split_member_not_in_group"], - "snapshotLine": 6214, + "snapshotLine": 6252, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -476,7 +486,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["direct_group_not_deletable", "group_has_balances", "not_allowed"], - "snapshotLine": 6252, + "snapshotLine": 6290, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -486,7 +496,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_authenticated"], - "snapshotLine": 6303, + "snapshotLine": 6341, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -496,7 +506,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_in_group", "not_allowed", "settlement_not_found"], - "snapshotLine": 6407, + "snapshotLine": 6445, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -506,7 +516,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor"], - "snapshotLine": 6452, + "snapshotLine": 6490, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -516,7 +526,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 6543, + "snapshotLine": 6581, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -526,7 +536,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invalid_payload", "invalid_unsubscribe_link"], - "snapshotLine": 6578, + "snapshotLine": 6616, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -536,7 +546,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 6668, + "snapshotLine": 6706, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -546,7 +556,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 6746, + "snapshotLine": 6784, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -556,7 +566,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor", "invalid_friend"], - "snapshotLine": 6757, + "snapshotLine": 6795, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -566,7 +576,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 6862, + "snapshotLine": 6900, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -576,7 +586,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 6900, + "snapshotLine": 6938, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -586,7 +596,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_friend", "not_a_friend"], - "snapshotLine": 6949, + "snapshotLine": 6987, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -596,7 +606,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["guest_link_not_found"], - "snapshotLine": 7010, + "snapshotLine": 7048, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -606,7 +616,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["import_too_large", "invalid_import", "not_a_friend"], - "snapshotLine": 7120, + "snapshotLine": 7158, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -616,7 +626,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["import_too_large", "invalid_import"], - "snapshotLine": 7456, + "snapshotLine": 7494, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -626,7 +636,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 7517, + "snapshotLine": 7555, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -636,7 +646,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 7576, + "snapshotLine": 7614, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -646,7 +656,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_has_balance", "owner_must_transfer"], - "snapshotLine": 7668, + "snapshotLine": 7706, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -663,7 +673,7 @@ "not_allowed", "split_member_not_in_group" ], - "snapshotLine": 7723, + "snapshotLine": 7761, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -673,7 +683,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 7840, + "snapshotLine": 7878, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -683,7 +693,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7854, + "snapshotLine": 7892, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -693,7 +703,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7865, + "snapshotLine": 7903, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -703,7 +713,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7886, + "snapshotLine": 7924, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -713,7 +723,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7904, + "snapshotLine": 7942, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -723,7 +733,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7937, + "snapshotLine": 7975, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -733,7 +743,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7963, + "snapshotLine": 8001, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -743,7 +753,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 8018, + "snapshotLine": 8056, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -753,7 +763,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 8031, + "snapshotLine": 8069, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -763,7 +773,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 8048, + "snapshotLine": 8086, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -779,7 +789,7 @@ "note_too_long", "nothing_owed" ], - "snapshotLine": 8095, + "snapshotLine": 8133, "definedIn": "supabase/migrations/20261113115303_debt_reminders.sql" }, { @@ -789,7 +799,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_found", "placeholder_already_reminded", "placeholder_no_email"], - "snapshotLine": 8159, + "snapshotLine": 8197, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -799,7 +809,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["friend_has_balance"], - "snapshotLine": 8202, + "snapshotLine": 8240, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -809,7 +819,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["guest_not_found"], - "snapshotLine": 8229, + "snapshotLine": 8267, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -825,7 +835,7 @@ "not_allowed", "use_leave_group" ], - "snapshotLine": 8261, + "snapshotLine": 8299, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -835,7 +845,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_has_balance", "member_not_found", "not_allowed"], - "snapshotLine": 8308, + "snapshotLine": 8346, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -845,7 +855,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_not_polling", "member_not_found"], - "snapshotLine": 8346, + "snapshotLine": 8384, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -855,7 +865,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["split_member_not_in_group"], - "snapshotLine": 8415, + "snapshotLine": 8453, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -865,7 +875,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_not_found", "not_a_member"], - "snapshotLine": 8454, + "snapshotLine": 8492, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -875,7 +885,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_in_group", "not_allowed", "settlement_not_found"], - "snapshotLine": 8501, + "snapshotLine": 8539, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -885,7 +895,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["guest_link_not_found", "not_allowed"], - "snapshotLine": 8546, + "snapshotLine": 8584, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -895,7 +905,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["whiteboard_conflict", "whiteboard_too_long"], - "snapshotLine": 8597, + "snapshotLine": 8635, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -905,7 +915,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_not_schedulable", "starts_at_required"], - "snapshotLine": 8658, + "snapshotLine": 8696, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -915,7 +925,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor", "invalid_date_range", "invalid_query"], - "snapshotLine": 8692, + "snapshotLine": 8730, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -925,7 +935,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["duplicate_slot", "event_not_polling", "invalid_slot", "invalid_slots"], - "snapshotLine": 8954, + "snapshotLine": 8992, "definedIn": "supabase/migrations/20261106020000_availability_lock.sql" }, { @@ -935,7 +945,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_splits", "split_member_not_in_group"], - "snapshotLine": 9043, + "snapshotLine": 9081, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -945,7 +955,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9126, + "snapshotLine": 9164, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -966,7 +976,7 @@ "guest_slots_required", "invalid_guest_name" ], - "snapshotLine": 9143, + "snapshotLine": 9181, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -981,7 +991,7 @@ "not_allowed", "use_transfer_ownership" ], - "snapshotLine": 9233, + "snapshotLine": 9271, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -991,7 +1001,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_notification_category", "notification_setting_required"], - "snapshotLine": 9273, + "snapshotLine": 9311, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -1001,7 +1011,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9310, + "snapshotLine": 9348, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -1017,7 +1027,7 @@ "invalid_recipient", "member_not_found" ], - "snapshotLine": 9349, + "snapshotLine": 9387, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1027,7 +1037,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9407, + "snapshotLine": 9445, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -1037,7 +1047,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_amount", "invalid_parties", "invalid_payload", "nothing_owed"], - "snapshotLine": 9444, + "snapshotLine": 9482, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1047,7 +1057,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9528, + "snapshotLine": 9566, "definedIn": "supabase/migrations/20261105210000_query_performance.sql" }, { @@ -1057,7 +1067,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_allowed", "recurring_not_found"], - "snapshotLine": 9588, + "snapshotLine": 9626, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1067,7 +1077,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_currency", "invalid_date_range"], - "snapshotLine": 9637, + "snapshotLine": 9675, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -1077,7 +1087,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["already_owner", "member_not_found", "not_allowed"], - "snapshotLine": 9782, + "snapshotLine": 9820, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -1087,7 +1097,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["expense_not_found"], - "snapshotLine": 9820, + "snapshotLine": 9858, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1097,7 +1107,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["expense_not_found"], - "snapshotLine": 9839, + "snapshotLine": 9877, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1113,7 +1123,7 @@ "placeholder_exists", "too_many_invites" ], - "snapshotLine": 9855, + "snapshotLine": 9893, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1138,7 +1148,7 @@ "split_member_not_in_group", "splits_sum_mismatch" ], - "snapshotLine": 9958, + "snapshotLine": 9996, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1154,7 +1164,7 @@ "settlement_deleted", "settlement_not_found" ], - "snapshotLine": 10215, + "snapshotLine": 10253, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" } ], diff --git a/docs/architecture.md b/docs/architecture.md index 6564df90..5c5727df 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -599,6 +599,7 @@ A placeholder is always a plain member and can never be made admin or owner (`pl | `friend_invite_preview(code)` → inviter first name, avatar, status | callable by `anon`, for `/friend/` | | `accept_friend_invite(code)` → friend id / `add_friend(user_id)` / `remove_friend(friend_id)` | friendships both ways; adding needs a shared group; removing needs a settled 1:1 tab | | `block_user(user_id)` / `my_blocks()` | block someone you know and drop the friendship both ways; the blocked list with names. Unblock is a direct delete on `blocks` | +| `can_split_with(person)` → boolean | whether a new expense with that person would go in (the friend page turns "Add expense" off); never says why not | | `accept_invite(code)` → group_id | validates expiry/uses/revoked, adds member, creates friendships | | `set_member_role(group_id, user_id, role)` / `remove_member` / `leave_group` / `transfer_ownership` | role checks per table above | | `get_or_create_direct_group(friend_id)` → group_id | 1:1 expenses | @@ -713,7 +714,8 @@ Codes: - `restore_group(group_id)`: any member, within 30 days of the delete (after that `group_not_found`, even before the purge runs); reopens the invite links `delete_group` closed (not ones revoked before) and logs `group.restored`. A live group comes back unchanged. The `purge-deleted-groups` job removes groups deleted more than 30 days ago (`private.purge_deleted_groups`, which checks `deleted_at` again as it deletes, so a group restored meanwhile stays), with everything in them. - `create_group_v2(...)`: as `create_group`, plus `type` (default `other`) and trip dates; `trip_dates_invalid` when a trip ends before it starts; dates on any other type are dropped. - `save_group_whiteboard(group_id, body, base_version)`: members of an open group (`group_archived` otherwise). Raises `whiteboard_conflict` (23514) when the stored `version` is not `base_version`; saving the same text changes nothing; otherwise bumps `version`. One `group.notes_updated` activity line per person per sitting: a save within ten minutes of that person's last one updates the line (`data`: `version`, `excerpt`, the first 140 characters). -- `get_or_create_direct_group(friend_id)`: the friend must be a friend or share a group, and neither may have blocked the other; a block, a closed account and a placeholder all raise `not_a_friend`. Creates a group named `Direct` (caller owner, friend admin, `members_can_invite = false`). +- `get_or_create_direct_group(friend_id)`: the friend must be a friend or share a group, and neither may have blocked the other; a block, a closed account and a placeholder all raise `not_a_friend`. Creates a group named `Direct` (caller owner, friend admin, `members_can_invite = false`). That name is never shown: screens, notifications, activity and exports call the tab after the other person (core `groupDisplayName`, web `groupTitle`, "your tab with Jules" inside a notification sentence), as `private.monthly_summary` does, and `send_email_notifications` leaves `group_name` out for a tab. +- `can_split_with(person)` → boolean: whether a new expense with that person would go in, so the friend page can turn "Add expense" off instead of failing. In an existing 1:1 tab: yes unless the person blocked the caller (`freeze_blocked_direct_money`); otherwise the `get_or_create_direct_group` rules. A plain no, like `not_a_friend`, never says a block is the reason. - `create_expense(payload)` / `update_expense(id, payload)` / `delete_expense(id)` / `restore_expense(id)` return the `expenses` row. - Payload keys: read `private.save_expense` in `supabase/schema/current.sql` (the live version; the comment at the top of the first expenses migration is older). - Extra payload key `recurrence` (`weekly` | `biweekly` | `monthly` | `yearly`, create only) also creates a `recurring_expenses` template whose first run is one period after `spent_at` (`private.recurring_step`); a backdated one catches up. A by-item bill's items, tax, tip and discount are stored on the template too. @@ -744,7 +746,7 @@ Codes: - `settle_up_v2(group_id, from_user, to_user, amount_cents, currency?, note?, settled_on?)` returns the settlement. Members record payments they are part of (either direction); owners and admins also between two others (`not_a_party`). Both people must be current members (`member_not_in_group`). `settled_on` is a calendar day in the caller's time zone (from their profile): the payment is stored at noon of that day there, so it never lands on a neighbouring day, and its rate comes from that day. No day means now. Checks shared with `update_settlement` live in `private.check_payment`, mirrored by `paymentProblem` in `packages/core`. - `update_settlement(id, payload)`: keys `from_user`, `to_user`, `amount_cents`, `currency`, `note` (null clears), `settled_on`. Same people as delete. A deleted payment raises `settlement_deleted`. The rate is kept unless the currency or the day changes. Changing people, amount, currency or rate of a payment with someone who left raises `member_not_in_group`; the note, and a day that keeps the rate, stay editable. - `delete_settlement(id)`: soft delete by payer, recipient, recorder or admin. Refused with `member_not_in_group` when either side has left the group. `restore_settlement(id)` undoes it, same people, same refusal. -- `settle_all(group_id, settled_on?, note?)` and `settle_with_friend(friend_id, settled_on?, note?, amounts?)` record payments in the group currency (rate 1) for the debts `private.group_debts` reports: `simplified_debts` when the group simplifies, `private.pairwise_debts` otherwise. `private.pairwise_debts` mirrors `pairwiseDebts` in core: each expense's effect per person as in `group_balances` (payers credited, refunds flipped), and inside an expense the people behind pay the people ahead, each debtor spread over the creditors by what they are still owed. `settle_with_friend` skips archived groups and groups either person has left; `amounts` (`{group_id: cents}`) picks groups and pays part (`invalid_amount` above what is owed, `nothing_owed` for a group with nothing to settle). Both return the payments made. +- `settle_all(group_id, settled_on?, note?)` and `settle_with_friend(friend_id, settled_on?, note?, amounts?)` record payments in the group currency (rate 1) for the debts `private.group_debts` reports: `simplified_debts` when the group simplifies, `private.pairwise_debts` otherwise. `private.pairwise_debts` mirrors `pairwiseDebts` in core: each expense's effect per person as in `group_balances` (payers credited, refunds flipped), and inside an expense the people behind pay the people ahead, each debtor spread over the creditors by what they are still owed. One expense's part of that between two people is core `expenseBetween` (null when one of them is not in it, 0 when both are but nothing passes between them, say both owe whoever paid): the friend page shows it on each row and the one-friend export in its between column. Summed with the payments between them it is their `pairwise_debts` row, so with simplify off a friend page's rows add up to the group's standing; with simplify on the standing is the simplified debt (`friend_balances` reads `private.group_debts`), which can move money between people, so the rows are the history behind it, not its sum. `settle_with_friend` skips archived groups and groups either person has left; `amounts` (`{group_id: cents}`) picks groups and pays part (`invalid_amount` above what is owed, `nothing_owed` for a group with nothing to settle). Both return the payments made. - `friend_balances()` returns one row per group and person the caller has a debt with, using the same `private.group_debts`, while both are still members of that group (a departed member's debts cannot move); archived groups are flagged. - `remind_debt(group_id, user_id, note?)`: the caller must be owed by `user_id` in that group (`nothing_owed`); one per UTC day per pair, whichever group (`already_reminded`). Uses `require_group_open`. Sends `debt.reminder` with the note. - `simplified_debts(group_id)`: greedy largest creditor vs largest debtor, ties by user id. Always simplifies, whatever `groups.simplify_debts` says (the client decides what to show). @@ -833,7 +835,7 @@ These rules exist in `packages/core` and in SQL. Change both sides in the same P | `money/splitItemized` (tax, tip, discount) | `private.itemized_splits` | | `money/checkPayers`, `primaryPayer` | payers checks in `private.save_expense` | | `balances/expenseEffect`, `computeBalances` | view `public.group_balances` (payers credited, refunds flipped) | -| `balances/pairwiseDebts` | `private.pairwise_debts` (settle all, friend balances) | +| `balances/pairwiseDebts`, `expenseBetween` | `private.pairwise_debts` (settle all, friend balances) | | `payments/paymentProblem`, `settleAllPayments` | `private.check_payment`, `public.settle_all` | | `payments/isValidHandle` | `private.valid_payment_handle` (table `payment_methods`) | | `balances/simplifyDebts` | `public.simplified_debts` | diff --git a/docs/development.md b/docs/development.md index 4325e1db..63ed06b9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -306,4 +306,6 @@ Notes for changing particular screens in `apps/web`. The general rules are in `a - Repeating expenses (`features/recurring/`, [ADR 14](adr/0014-editable-recurring-templates.md)): a template opens in the expense form (`ExpenseForm mode="recurring"`, route `/recurring/$recurringId`) through `templateAsExpense()`, so payers, adjustments and items work as for an expense. Skip, pause and stop live in `useTemplateActions`; the expense page shows `RepeatCard`. - Language: `src/lib/language.ts` starts the app in the remembered (or browser) language before the first render; the root keys its outlet by language, so a switch redraws every screen. The profile's `locale` wins once it loads (`features/account/use-profile-language.ts`). Amounts and dates follow it: give components that take a `locale` `intlLocale()`, never `DEFAULT_LOCALE` (landing only). - Two-step sign in: signed-in routes check `needsSecondFactor(session)` (`src/lib/auth.ts`) and send the person to `/two-factor`; the database refuses that session anyway ([ADR 13](adr/0013-two-step-sign-in-enforced-in-the-database.md)). Sign in and security sections live in `src/features/account/`, mounted on Me with one line each. +- Friends (`src/features/friends`): a friend page row shows what the expense left between the two people (core `expenseBetween`), not the viewer's whole lead; the standing per group comes from `friend_balances`. "Add expense" is off with a reason when the viewer blocked the friend (`my_blocks`) or `can_split_with` says no. The menu exports the one-friend spreadsheet (`useAccountExports().friend`, the same file as on Me). +- Naming groups: a one-on-one tab (`is_direct`) is stored as "Direct"; name any group on screen or in a file with `groupTitle` (`features/expenses/format.ts`, core `groupDisplayName`), and inside a notification sentence with `notificationGroupName`. Balance rows that put a name before "is owed" / "owes" use `netLabel` (`features/balances/net-label.ts`) so the viewer reads "You are owed", never "You is owed". - Payments live in `src/features/settle`: the settle form (`PaymentForm`, also used to edit at `/payments/$paymentId/edit`), the payment page `/payments/$paymentId` (restore, comments through `CommentThread` from `features/expenses/comments.tsx`), pay links, reminders and "Settle everything". Cross-group settle is on the Friends page. Pay links never move money ([ADR 15](adr/0015-payment-links-and-cross-group-settle.md)). diff --git a/docs/testing.md b/docs/testing.md index ecc554cc..ea553232 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -157,7 +157,7 @@ Every test creates its own users and groups with unique names, so tests run in p | `groups-invites.spec.ts` | create a group, create an invite link, a signed out friend opens `/join/`, signs in and joins, the owner sees them | | `expenses.spec.ts` | equal, exact, percent, shares and itemized splits with each person's share on the detail page, editing the amount, delete with Undo, a EUR expense in a USD group showing the converted amount | | `expense-entry.spec.ts` | two payers agree across row, detail, balances and CSV; payers that do not add up are refused; adjustment split and edit history; a refund; the two-person shortcut; by item reopening its items; default split and last currency | -| `people.spec.ts` | add Sam by name and email, split with him, Sam opens his claim link signed out, signs up with that email and finds the group, expense and balance; a friend link makes friends, blocking from the friend page, Unblock on Me | +| `people.spec.ts` | add Sam by name and email, split with him, Sam opens his claim link signed out, signs up with that email and finds the group, expense and balance; a friend link makes friends, blocking from the friend page, Unblock on Me; a friend page row shows only what is between the two (not the viewer's whole lead), the menu exports the one-friend spreadsheet, and a block turns Add expense off | | `balances.spec.ts` | balances follow an expense, settle up reaches all square ($0.00), activity lists the payment | | `settle-up.spec.ts` | "Ben paid me" on a past day and Ben's notification, comment, delete and restore a payment, a saved PayPal.me handle as a prefilled pay button, Remind with a note twice in a day, settling with a friend across three groups | | `plans.spec.ts` | create a plan, a second user paints availability with the keyboard, the host's heatmap updates live (realtime), best slot listed, lock it in, RSVP, download the `.ics` | diff --git a/packages/i18n/src/de/people.ts b/packages/i18n/src/de/people.ts index 6788923f..828d7f33 100644 --- a/packages/i18n/src/de/people.ts +++ b/packages/i18n/src/de/people.ts @@ -132,6 +132,10 @@ export const people = { blocked: '{name} ist blockiert.', report: '{name} melden', reportHint: 'Sag uns, wenn etwas nicht stimmt.', + nothingBetween: 'nichts zwischen euch', + export: 'Als Tabelle exportieren', + youBlocked: + 'Du hast {name} blockiert, also kommt zwischen euch nichts Neues dazu. Heb die Blockierung unter Ich auf, um wieder zu teilen.', }, report: { title: '{name} melden', diff --git a/packages/i18n/src/en/people.ts b/packages/i18n/src/en/people.ts index 6225a981..e4a7f91e 100644 --- a/packages/i18n/src/en/people.ts +++ b/packages/i18n/src/en/people.ts @@ -128,6 +128,10 @@ export const people = { blocked: '{name} is blocked.', report: 'Report {name}', reportHint: 'Tell us if something is wrong.', + nothingBetween: 'nothing between you', + export: 'Export as a spreadsheet', + youBlocked: + 'You blocked {name}, so nothing new goes in between you. Unblock them in Me to split again.', }, report: { title: 'Report {name}', diff --git a/packages/i18n/src/es/people.ts b/packages/i18n/src/es/people.ts index c4ecc27a..f32f65d5 100644 --- a/packages/i18n/src/es/people.ts +++ b/packages/i18n/src/es/people.ts @@ -128,6 +128,10 @@ export const people = { blocked: '{name} está bloqueado.', report: 'Denunciar a {name}', reportHint: 'Cuéntanos si algo va mal.', + nothingBetween: 'nada entre ustedes', + export: 'Exportar como hoja de cálculo', + youBlocked: + 'Bloqueaste a {name}, así que no entra nada nuevo entre ustedes. Desbloquéalo en Yo para volver a dividir.', }, report: { title: 'Denunciar a {name}', diff --git a/packages/i18n/src/fr/people.ts b/packages/i18n/src/fr/people.ts index dad0dcbd..0e9327c7 100644 --- a/packages/i18n/src/fr/people.ts +++ b/packages/i18n/src/fr/people.ts @@ -128,6 +128,10 @@ export const people = { blocked: '{name} est bloqué.', report: 'Signaler {name}', reportHint: 'Dis-nous si quelque chose ne va pas.', + nothingBetween: 'rien entre vous', + export: 'Exporter en tableur', + youBlocked: + "Tu as bloqué {name}, donc rien de nouveau n'entre entre vous. Débloque cette personne dans Moi pour partager à nouveau.", }, report: { title: 'Signaler {name}', diff --git a/packages/i18n/src/pt/people.ts b/packages/i18n/src/pt/people.ts index 144da39c..e0003a04 100644 --- a/packages/i18n/src/pt/people.ts +++ b/packages/i18n/src/pt/people.ts @@ -127,6 +127,10 @@ export const people = { blocked: '{name} está bloqueado.', report: 'Denunciar {name}', reportHint: 'Conte para a gente se algo estiver errado.', + nothingBetween: 'nada entre vocês', + export: 'Exportar como planilha', + youBlocked: + 'Você bloqueou {name}, então nada novo entra entre vocês. Desbloqueie em Eu para dividir de novo.', }, report: { title: 'Denunciar {name}', From 623c9c5bd31bf8767bfc075cb2e7258bd3f2731c Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:40:33 -0500 Subject: [PATCH 8/9] refresh can-split on block changes; hide only tab-opening joins --- .../features/activity/activity-feed.test.tsx | 13 +++++++-- .../src/features/activity/activity-feed.tsx | 4 +-- .../src/features/friends/friend-page.test.tsx | 3 ++ apps/web/src/features/friends/friend-page.tsx | 1 + .../src/features/me/blocked-people.test.tsx | 28 +++++++++++++++++-- apps/web/src/features/me/blocked-people.tsx | 1 + 6 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/activity/activity-feed.test.tsx b/apps/web/src/features/activity/activity-feed.test.tsx index d6618b23..78ceddee 100644 --- a/apps/web/src/features/activity/activity-feed.test.tsx +++ b/apps/web/src/features/activity/activity-feed.test.tsx @@ -61,6 +61,12 @@ describe('ActivityFeed', () => { data: {}, group: direct, }), + activity('a5', '2026-11-03T09:00:00Z', { + verb: 'member.left', + actor_id: BEN, + data: { user_id: BEN }, + group: direct, + }), ]) const tacos = await screen.findByRole('link', { name: /Ben added "Tacos"/ }) expect(tacos).toHaveAttribute('href', '/expenses/x-a1') @@ -70,8 +76,11 @@ describe('ActivityFeed', () => { expect(paid).toHaveTextContent('Ben Okafor') expect(paid).not.toHaveTextContent('Direct') expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(2) - // Opening a hidden one-to-one tab, and joining it, is not news. - expect(screen.getAllByRole('listitem')).toHaveLength(2) + // Opening a hidden one-to-one tab, and joining it, is not news; leaving it is. + const left = screen.getByText(t('activity.member.left', { actor: 'Ben' })).closest('li') + expect(left).toHaveTextContent('Ben Okafor') + expect(left).not.toHaveTextContent('Direct') + expect(screen.getAllByRole('listitem')).toHaveLength(3) }) it('loads older pages on request', async () => { diff --git a/apps/web/src/features/activity/activity-feed.tsx b/apps/web/src/features/activity/activity-feed.tsx index 246ac87f..d656fe6f 100644 --- a/apps/web/src/features/activity/activity-feed.tsx +++ b/apps/web/src/features/activity/activity-feed.tsx @@ -66,12 +66,12 @@ export function ActivityFeed({ groupId, person }: { groupId?: string; person?: s const sections = useMemo(() => { // The hidden 1:1 groups are an implementation detail: opening one (the group and its two - // members) is not news. + // members joining) is not news. Anything later, like someone leaving, still is. const items = (feed.data?.pages.flat() ?? []).filter( (item) => !( item.group?.is_direct && - (item.verb === 'group.created' || item.verb.startsWith('member.')) + (item.verb === 'group.created' || item.verb === 'member.joined') ), ) return groupByDay(items, (item) => dayKey(item.created_at, timeZone)) diff --git a/apps/web/src/features/friends/friend-page.test.tsx b/apps/web/src/features/friends/friend-page.test.tsx index 76bd8b86..f0ce7dff 100644 --- a/apps/web/src/features/friends/friend-page.test.tsx +++ b/apps/web/src/features/friends/friend-page.test.tsx @@ -324,6 +324,7 @@ describe('FriendPage', () => { it('blocks someone, who is not told', async () => { const { router } = renderPage() + await waitFor(() => expect(db().rpcArgs('can_split_with')).toHaveLength(1)) fireEvent.click( await screen.findByRole('button', { name: t('people.friendPage.menu', { name: 'Ben Ortiz' }), @@ -340,6 +341,8 @@ describe('FriendPage', () => { fireEvent.click(within(dialog).getByRole('button', { name: t('members.block') })) await waitFor(() => expect(router.state.location.pathname).toBe('/friends')) expect(db().rpcArgs('block_user')).toEqual([{ user_id: BEN }]) + // Whether you can split with them is asked again, not read from before the block. + expect(db().rpcArgs('can_split_with')).toHaveLength(2) }) it('reports someone', async () => { diff --git a/apps/web/src/features/friends/friend-page.tsx b/apps/web/src/features/friends/friend-page.tsx index bddbe9e7..5ea85524 100644 --- a/apps/web/src/features/friends/friend-page.tsx +++ b/apps/web/src/features/friends/friend-page.tsx @@ -368,6 +368,7 @@ export function FriendPage({ friendId }: { friendId: string }) { await Promise.all([ queryClient.invalidateQueries({ queryKey: groupKeys.friends(me) }), queryClient.invalidateQueries({ queryKey: peopleKeys.blocks(me) }), + queryClient.invalidateQueries({ queryKey: peopleKeys.canSplit(friendId) }), ]) await navigate({ to: '/friends', replace: true }) } catch (error) { diff --git a/apps/web/src/features/me/blocked-people.test.tsx b/apps/web/src/features/me/blocked-people.test.tsx index 5f349969..2c0a204b 100644 --- a/apps/web/src/features/me/blocked-people.test.tsx +++ b/apps/web/src/features/me/blocked-people.test.tsx @@ -1,6 +1,8 @@ import type * as AuthModule from '@/lib/auth' +import { peopleKeys } from '@potluck/api' import { t } from '@potluck/i18n' import { Toaster } from '@potluck/ui' +import { QueryClient } from '@tanstack/react-query' import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { VIEWER } from '@/test/fixtures' @@ -14,13 +16,13 @@ vi.mock('@/lib/auth', async (importOriginal) => ({ useUserId: () => VIEWER, })) -function renderSection() { +function renderSection(client?: QueryClient) { return renderWithProviders( <> , - { path: '/me' }, + { path: '/me', client }, ) } @@ -54,6 +56,28 @@ describe('BlockedPeopleSection', () => { expect(unblock?.ops.map((op) => op.method)).toContain('delete') }) + it('forgets that you could not split with someone once they are unblocked', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: Infinity } }, + }) + client.setQueryData(peopleKeys.canSplit('u-dan'), false) + db().respond( + 'rpc:my_blocks', + { + data: [{ user_id: 'u-dan', display_name: 'Dan Ross', avatar_path: null, blocked_at: 't' }], + }, + { data: [] }, + ) + renderSection(client) + fireEvent.click( + await screen.findByRole('button', { + name: t('people.blocked.unblockName', { name: 'Dan Ross' }), + }), + ) + await screen.findByText(t('people.blocked.unblocked', { name: 'Dan Ross' })) + expect(client.getQueryState(peopleKeys.canSplit('u-dan'))?.isInvalidated).toBe(true) + }) + it('explains a failed unblock', async () => { db() .respond('rpc:my_blocks', { diff --git a/apps/web/src/features/me/blocked-people.tsx b/apps/web/src/features/me/blocked-people.tsx index 690068a5..94c79b9a 100644 --- a/apps/web/src/features/me/blocked-people.tsx +++ b/apps/web/src/features/me/blocked-people.tsx @@ -22,6 +22,7 @@ export function BlockedPeopleSection() { await Promise.all([ queryClient.invalidateQueries({ queryKey: peopleKeys.blocks(userId) }), queryClient.invalidateQueries({ queryKey: groupKeys.friends(userId) }), + queryClient.invalidateQueries({ queryKey: peopleKeys.canSplit(id) }), ]) toast.success(t('people.blocked.unblocked', { name })) } catch (error) { From 636fb364ea93f66bd090b61cef8af12fe4a24907 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:46:27 -0500 Subject: [PATCH 9/9] update parity note for home other-currency totals --- docs/splitwise-parity.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/splitwise-parity.md b/docs/splitwise-parity.md index f1b34890..dbbe4e15 100644 --- a/docs/splitwise-parity.md +++ b/docs/splitwise-parity.md @@ -82,19 +82,19 @@ What Splitwise Free holds back today: 4 new expenses per day, ads, and the Pro f ### Balances and settle up -| Splitwise feature | Tier | Potluck | Notes | -| ---------------------------------------------------------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Overall "you owe / you are owed" | Free (per currency) | partial | home hero sums one currency; groups in other currencies are listed but not converted into a total (Splitwise shows "$12.45 and £8.72") | -| Per-group balances and who owes whom | Free | exists | balances tab, simplified or every debt | -| Record a cash payment | Free | exists | either direction, between two others for owners and admins, any day (that day's rate) | -| Edit a payment | Free | exists | payment page and the list menu; people, amount, currency, day, note | -| Delete and restore a payment | Free | exists | Undo in the toast, Restore on the payment page and in the group's Recently deleted | -| Comments on a payment | Free | exists | on the payment page | -| Pay through Venmo / PayPal (send only) | Free (US) | exists | deep links from saved handles, no processor (ADR 15) | -| Paytm | Free (India) | exists | UPI deep link, offered for rupees | -| Splitwise Pay wallet, Pay by Bank (Tink), payment requests | Free (US; UK and parts of EU) | skip | needs a licensed payment processor | -| Balance reminder ("Remind...") | Free | exists | Remind on a debt owed to you, with an optional note: inbox, push and email, once a day per pair | -| Settle all balances at once | Free | exists | "Settle everything" on the Balances tab | +| Splitwise feature | Tier | Potluck | Notes | +| ---------------------------------------------------------- | ----------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Overall "you owe / you are owed" | Free (per currency) | exists | home hero sums one currency, then lists what you are owed and owe in each other currency, unconverted ("Plus you are owed €12.00 and you owe £5.00") | +| Per-group balances and who owes whom | Free | exists | balances tab, simplified or every debt | +| Record a cash payment | Free | exists | either direction, between two others for owners and admins, any day (that day's rate) | +| Edit a payment | Free | exists | payment page and the list menu; people, amount, currency, day, note | +| Delete and restore a payment | Free | exists | Undo in the toast, Restore on the payment page and in the group's Recently deleted | +| Comments on a payment | Free | exists | on the payment page | +| Pay through Venmo / PayPal (send only) | Free (US) | exists | deep links from saved handles, no processor (ADR 15) | +| Paytm | Free (India) | exists | UPI deep link, offered for rupees | +| Splitwise Pay wallet, Pay by Bank (Tink), payment requests | Free (US; UK and parts of EU) | skip | needs a licensed payment processor | +| Balance reminder ("Remind...") | Free | exists | Remind on a debt owed to you, with an optional note: inbox, push and email, once a day per pair | +| Settle all balances at once | Free | exists | "Settle everything" on the Balances tab | ### Activity, comments, notifications