From 7fd7e181589988c2fdf8a5a9aae8094a12fdd628 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:30:22 +0300 Subject: [PATCH 1/2] feat: split shared subscriptions and settle up who owes what --- app/frontend/src/components/SplitEditor.tsx | 111 ++++++++++++++++ .../src/components/database/DatabaseView.tsx | 3 +- .../src/components/database/PropertyField.tsx | 6 +- app/frontend/src/pages/NotePage.tsx | 4 +- .../src/pages/spaces/SpaceBalances.tsx | 119 +++++++++++++++++ .../src/pages/spaces/SpaceSubscriptions.tsx | 23 ++++ app/frontend/src/spaces.tsx | 5 + app/frontend/src/subscriptions.ts | 6 + app/frontend/test/subscriptions.spec.tsx | 61 +++++++++ .../migration.sql | 48 +++++++ app/server/prisma/schema.prisma | 46 +++++++ .../subscriptions/cost-splits.controller.ts | 46 +++++++ .../src/subscriptions/cost-splits.service.ts | 120 ++++++++++++++++++ .../src/subscriptions/dto/cost-splits.dto.ts | 50 ++++++++ .../src/subscriptions/subscriptions.module.ts | 6 +- .../subscriptions/subscriptions.service.ts | 4 +- .../test/integration/cost-splits.spec.ts | 80 ++++++++++++ 17 files changed, 727 insertions(+), 11 deletions(-) create mode 100644 app/frontend/src/components/SplitEditor.tsx create mode 100644 app/frontend/src/pages/spaces/SpaceBalances.tsx create mode 100644 app/server/prisma/migrations/20261005160000_cost_splitting/migration.sql create mode 100644 app/server/src/subscriptions/cost-splits.controller.ts create mode 100644 app/server/src/subscriptions/cost-splits.service.ts create mode 100644 app/server/src/subscriptions/dto/cost-splits.dto.ts create mode 100644 app/server/test/integration/cost-splits.spec.ts diff --git a/app/frontend/src/components/SplitEditor.tsx b/app/frontend/src/components/SplitEditor.tsx new file mode 100644 index 0000000..9f11f6a --- /dev/null +++ b/app/frontend/src/components/SplitEditor.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { useAuth } from '../auth'; +import type { Member } from '../spaces'; +import type { CostSplit, Subscription } from '../subscriptions'; +import { useAction } from '../useAction'; +import { FormDialog } from './FormDialog'; + +export function SplitEditor({ + members, + onClose, + onSaved, + subscription, +}: { + members: Member[]; + onClose: () => void; + onSaved: (split: CostSplit | null) => void; + subscription: Subscription; +}) { + const auth = useAuth(); + const saving = useAction(); + const current = subscription.split; + const [payerId, setPayerId] = useState(current?.payerId ?? auth.user?.id ?? members[0]?.userId ?? ''); + const [weights, setWeights] = useState>(() => + Object.fromEntries(current ? current.shares.map((share) => [share.userId, share.weight]) : members.map((member) => [member.userId, 1])), + ); + const shares = Object.entries(weights).map(([userId, weight]) => ({ userId, weight })); + const total = shares.reduce((sum, share) => sum + share.weight, 0); + + return ( + + void saving + .run(async () => { + if (shares.length < 2) throw new Error('Split between at least two members.'); + onSaved(await auth.request(`/subscriptions/${subscription.id}/split`, { method: 'PUT', body: JSON.stringify({ payerId, shares }) })); + return `${subscription.name} is split between ${shares.length} members.`; + }, 'Could not save the split') + .then((saved) => saved && onClose()) + } + submitLabel="Save split" + title={`Split ${subscription.name}`} + > + +
+ Shared by + {members.map((member) => { + const weight = weights[member.userId]; + return ( +
+ + {weight !== undefined && ( + <> + setWeights({ ...weights, [member.userId]: Math.min(100, Math.max(1, Number(event.currentTarget.value) || 1)) })} + type="number" + value={weight} + /> + {Math.round((weight / total) * 100)}% + + )} +
+ ); + })} +
+ {current && ( + + )} +
+ ); +} diff --git a/app/frontend/src/components/database/DatabaseView.tsx b/app/frontend/src/components/database/DatabaseView.tsx index 4ba9746..3978ac6 100644 --- a/app/frontend/src/components/database/DatabaseView.tsx +++ b/app/frontend/src/components/database/DatabaseView.tsx @@ -4,11 +4,12 @@ import { Icon, ListSkeleton } from '@project-graphite/ui'; import { addDays, dayKey, monthDays } from '../../agenda'; import { useAuth } from '../../auth'; import { databaseViews, noteTitle, type DatabaseRow, type Note, type NoteDatabase, type Property, type PropertyValues } from '../../notes'; +import type { Member } from '../../spaces'; import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; import { LoadError } from '../LoadError'; import { PropertiesDialog } from './PropertiesDialog'; -import { PropertyField, propertyDisplay, type Member } from './PropertyField'; +import { PropertyField, propertyDisplay } from './PropertyField'; const blank = (display: ReactNode) => display === null || (Array.isArray(display) && display.length === 0); diff --git a/app/frontend/src/components/database/PropertyField.tsx b/app/frontend/src/components/database/PropertyField.tsx index 52a750e..bcbd358 100644 --- a/app/frontend/src/components/database/PropertyField.tsx +++ b/app/frontend/src/components/database/PropertyField.tsx @@ -1,11 +1,7 @@ import type { ReactNode } from 'react'; import { Popover, TagChip } from '@project-graphite/ui'; import type { Property } from '../../notes'; - -export interface Member { - userId: string; - displayName: string; -} +import type { Member } from '../../spaces'; const optionsOf = (property: Property, ids: unknown) => (Array.isArray(ids) ? ids : [ids]).flatMap((id) => property.options?.filter((option) => option.id === id) ?? []); diff --git a/app/frontend/src/pages/NotePage.tsx b/app/frontend/src/pages/NotePage.tsx index 69961f2..322d470 100644 --- a/app/frontend/src/pages/NotePage.tsx +++ b/app/frontend/src/pages/NotePage.tsx @@ -3,11 +3,11 @@ import { Link, useNavigate, useParams } from 'react-router'; import { ConfirmDialog, EmptyState, Icon, LinesSkeleton, PageSkeleton } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { DatabaseView } from '../components/database/DatabaseView'; -import { PropertyField, type Member } from '../components/database/PropertyField'; +import { PropertyField } from '../components/database/PropertyField'; import { LoadError } from '../components/LoadError'; import { NoteHistory } from '../components/NoteHistory'; import { noteTitle, type Note, type NoteDetails, type PropertyValues } from '../notes'; -import { itemHref, useSpaces } from '../spaces'; +import { itemHref, useSpaces, type Member } from '../spaces'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; diff --git a/app/frontend/src/pages/spaces/SpaceBalances.tsx b/app/frontend/src/pages/spaces/SpaceBalances.tsx new file mode 100644 index 0000000..2f3b8c8 --- /dev/null +++ b/app/frontend/src/pages/spaces/SpaceBalances.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { ListSkeleton } from '@project-graphite/ui'; +import { useAuth } from '../../auth'; +import { LoadError } from '../../components/LoadError'; +import { money } from '../../subscriptions'; +import { useAction } from '../../useAction'; +import { useResource } from '../../useResource'; + +interface Debt { + fromUserId: string; + fromName: string; + toUserId: string; + toName: string; + amountMinor: number; + currency: string; +} + +interface Balances { + month: string; + charges: { itemId: string; name: string; payerName: string; amountMinor: number; currency: string; renewals: string[] }[]; + debts: Debt[]; + settlements: (Debt & { id: string })[]; +} + +const shiftMonth = (month: string, by: number) => { + const [year, number] = month.split('-').map(Number) as [number, number]; + const date = new Date(Date.UTC(year, number - 1 + by, 1)); + return date.toISOString().slice(0, 7); +}; + +export function SpaceBalances({ spaceId }: { spaceId: string }) { + const auth = useAuth(); + const me = auth.user?.id; + const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7)); + const balances = useResource(`/spaces/${spaceId}/balances?month=${month}`, true); + const action = useAction(); + + function settle(debt: Debt) { + void action.run(async () => { + await auth.request(`/spaces/${spaceId}/settlements`, { + method: 'POST', + body: JSON.stringify({ fromUserId: debt.fromUserId, toUserId: debt.toUserId, amountMinor: debt.amountMinor, currency: debt.currency, month }), + }); + balances.reload(); + return `Recorded ${money(debt.amountMinor, debt.currency)} from ${debt.fromName} to ${debt.toName}.`; + }, 'Could not record the payment'); + } + + function undo(id: string) { + void action.run(async () => { + await auth.request(`/settlements/${id}`, { method: 'DELETE' }); + balances.reload(); + return 'The payment was removed.'; + }, 'Could not remove the payment'); + } + + const label = new Date(`${month}-01T00:00:00`).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + + return ( +
+
+

Split costs · {label}

+
+ + +
+
+ {balances.error ? ( + + ) : !balances.data ? ( + + ) : balances.data.charges.length === 0 && balances.data.settlements.length === 0 ? ( +

No split subscriptions renew this month.

+ ) : ( + <> + {balances.data.debts.length === 0 ? ( +

Everyone is square.

+ ) : ( +
    + {balances.data.debts.map((debt) => ( +
  • + + {debt.fromName} owes {debt.toName} {money(debt.amountMinor, debt.currency)} + + {(me === debt.fromUserId || me === debt.toUserId) && ( + + )} +
  • + ))} +
+ )} +

+ {balances.data.charges.map((charge) => `${charge.name} ${money(charge.amountMinor, charge.currency)}, paid by ${charge.payerName}`).join(' · ')} +

+ {balances.data.settlements.length > 0 && ( +
    + {balances.data.settlements.map((payment) => ( +
  • + {payment.fromName} paid {payment.toName} {money(payment.amountMinor, payment.currency)} + {(me === payment.fromUserId || me === payment.toUserId) && ( + + )} +
  • + ))} +
+ )} + + )} +
+ ); +} diff --git a/app/frontend/src/pages/spaces/SpaceSubscriptions.tsx b/app/frontend/src/pages/spaces/SpaceSubscriptions.tsx index 40c67a7..29ba79f 100644 --- a/app/frontend/src/pages/spaces/SpaceSubscriptions.tsx +++ b/app/frontend/src/pages/spaces/SpaceSubscriptions.tsx @@ -2,10 +2,13 @@ import { useState } from 'react'; import { Link, useSearchParams } from 'react-router'; import { EmptyState, ListSkeleton } from '@project-graphite/ui'; import { useAuth } from '../../auth'; +import { SplitEditor } from '../../components/SplitEditor'; import { SubscriptionEditor } from '../../components/SubscriptionEditor'; +import type { Member } from '../../spaces'; import { cycleLabel, money, shortDate, type Subscription } from '../../subscriptions'; import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; +import { SpaceBalances } from './SpaceBalances'; import { useSpace } from './SpaceLayout'; import { LoadError } from '../../components/LoadError'; @@ -21,7 +24,12 @@ export function SpaceSubscriptions() { ); const action = useAction(); const [editing, setEditing] = useState(); + const [splitting, setSplitting] = useState(); const editable = space.role !== 'viewer'; + const shared = space.kind === 'shared'; + const members = useResource(shared ? `/spaces/${space.id}/members` : null, true); + const memberName = (userId: string) => members.data?.find((member) => member.userId === userId)?.displayName ?? 'a former member'; + const splitVersion = (subscriptions.data ?? []).map((subscription) => JSON.stringify(subscription.split ?? null)).join(); function act(subscription: Subscription, verb: 'used' | 'cancel' | 'resume', message: string) { void action.run(async () => { @@ -50,6 +58,7 @@ export function SpaceSubscriptions() { )} + {shared && !cancelled && } {subscriptions.data.length === 0 ? ( ) : ( @@ -67,6 +76,7 @@ export function SpaceSubscriptions() { cancelled ? `cancelled ${shortDate(subscription.cancelledOn!)}` : `renews ${shortDate(subscription.nextRenewal)}`, subscription.trialEndsOn && !cancelled && `trial ends ${shortDate(subscription.trialEndsOn)}`, subscription.paymentLabel, + subscription.split && `split · paid by ${memberName(subscription.split.payerId)}`, ] .filter(Boolean) .join(' · ')} @@ -83,6 +93,11 @@ export function SpaceSubscriptions() { + {shared && members.data && members.data.length > 1 && ( + + )} @@ -102,6 +117,14 @@ export function SpaceSubscriptions() { ))} )} + {splitting && members.data && ( + setSplitting(undefined)} + onSaved={(split) => subscriptions.mutate((current) => current.map((shown) => (shown.id === splitting.id ? { ...shown, split } : shown)))} + subscription={splitting} + /> + )} {editing && ( setEditing(undefined)} diff --git a/app/frontend/src/spaces.tsx b/app/frontend/src/spaces.tsx index e95026c..b0d6764 100644 --- a/app/frontend/src/spaces.tsx +++ b/app/frontend/src/spaces.tsx @@ -14,6 +14,11 @@ export interface Space { createdAt: string; } +export interface Member { + userId: string; + displayName: string; +} + export interface Tag { id: string; spaceId: string; diff --git a/app/frontend/src/subscriptions.ts b/app/frontend/src/subscriptions.ts index eb9d509..21462b8 100644 --- a/app/frontend/src/subscriptions.ts +++ b/app/frontend/src/subscriptions.ts @@ -1,3 +1,8 @@ +export interface CostSplit { + payerId: string; + shares: { userId: string; weight: number }[]; +} + export interface Subscription { id: string; spaceId: string; @@ -18,6 +23,7 @@ export interface Subscription { reminderDays: number | null; lastUsedOn: string | null; cancelledOn: string | null; + split: CostSplit | null; } export const categories = [ diff --git a/app/frontend/test/subscriptions.spec.tsx b/app/frontend/test/subscriptions.spec.tsx index 3850567..9d070d3 100644 --- a/app/frontend/test/subscriptions.spec.tsx +++ b/app/frontend/test/subscriptions.spec.tsx @@ -237,4 +237,65 @@ describe('Recurring', () => { ]); expect(container.textContent).toContain('Added 1 subscription.'); }); + + it('splits a shared subscription and settles up what is owed', async () => { + const flat = { id: 'flat', name: 'Flat', color: 'teal', kind: 'shared', role: 'editor', createdAt: '2026-10-01T00:00:00Z' }; + const internet = { ...music, id: 'internet', spaceId: 'flat', name: 'Internet', split: null }; + const owes = { fromUserId: 'sam', fromName: 'Sam', toUserId: 'me', toName: 'Amr', amountMinor: 2000, currency: 'EUR' }; + let settled = false; + const sent: unknown[] = []; + vi.stubGlobal( + 'fetch', + vi.fn((input: string, init?: RequestInit) => { + const path = input.replace('/api/v1', ''); + if (path === '/auth/refresh') return Promise.resolve(json({ accessToken: 'token', user })); + if (path === '/spaces') return Promise.resolve(json([flat])); + if (path === '/spaces/flat') return Promise.resolve(json(flat)); + if (path === '/me/inbox/summary') return Promise.resolve(json({ unread: 0 })); + if (path === '/spaces/flat/subscriptions') return Promise.resolve(json([internet])); + if (path === '/spaces/flat/members') { + return Promise.resolve(json([{ userId: 'me', displayName: 'Amr', role: 'owner' }, { userId: 'sam', displayName: 'Sam', role: 'editor' }])); + } + if (path === '/subscriptions/internet/split') { + sent.push(JSON.parse(String(init?.body))); + return Promise.resolve(json({ payerId: 'me', shares: [{ userId: 'me', weight: 1 }, { userId: 'sam', weight: 2 }] })); + } + if (path.startsWith('/spaces/flat/balances?month=')) { + return Promise.resolve( + json({ + month: path.slice(-7), + charges: [{ itemId: 'internet', name: 'Internet', payerName: 'Amr', amountMinor: 3000, currency: 'EUR', renewals: [] }], + debts: settled ? [] : [owes], + settlements: settled ? [{ ...owes, id: 'paid' }] : [], + }), + ); + } + if (path === '/spaces/flat/settlements') { + sent.push(JSON.parse(String(init?.body))); + settled = true; + return Promise.resolve(json({ id: 'paid' }, 201)); + } + return Promise.resolve(new Response(null, { status: 404 })); + }), + ); + await render('/spaces/flat/recurring'); + + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'Split')!.click()); + const dialog = document.querySelector('dialog')!; + const samShares = dialog.querySelector('input[aria-label="Shares for Sam"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(samShares, '2'); + samShares.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(dialog.textContent).toContain('67%'); + await submit(dialog.querySelector('form')!); + expect(sent[0]).toEqual({ payerId: 'me', shares: [{ userId: 'me', weight: 1 }, { userId: 'sam', weight: 2 }] }); + expect(container.textContent).toContain('split · paid by Amr'); + + const balances = container.querySelector('section[aria-label="Split costs"]')!; + expect(balances.textContent).toContain(`Sam owes Amr ${euros(2000)}`); + await act(async () => [...balances.querySelectorAll('button')].find((button) => button.textContent === 'Settle up')!.click()); + expect(sent[1]).toEqual({ fromUserId: 'sam', toUserId: 'me', amountMinor: 2000, currency: 'EUR', month: new Date().toISOString().slice(0, 7) }); + expect(container.querySelector('section[aria-label="Split costs"]')!.textContent).toContain('Everyone is square.'); + }); }); diff --git a/app/server/prisma/migrations/20261005160000_cost_splitting/migration.sql b/app/server/prisma/migrations/20261005160000_cost_splitting/migration.sql new file mode 100644 index 0000000..a7df7a2 --- /dev/null +++ b/app/server/prisma/migrations/20261005160000_cost_splitting/migration.sql @@ -0,0 +1,48 @@ +CREATE TABLE "cost_splits" ( + "item_id" UUID NOT NULL, + "payer_id" UUID NOT NULL, + + CONSTRAINT "cost_splits_pkey" PRIMARY KEY ("item_id") +); + +CREATE TABLE "cost_shares" ( + "item_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "weight" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "cost_shares_pkey" PRIMARY KEY ("item_id","user_id") +); + +CREATE TABLE "settlements" ( + "id" UUID NOT NULL, + "space_id" UUID NOT NULL, + "from_user_id" UUID NOT NULL, + "to_user_id" UUID NOT NULL, + "amount_minor" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "month" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "settlements_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "cost_splits_payer_id_idx" ON "cost_splits"("payer_id"); + +CREATE INDEX "cost_shares_user_id_idx" ON "cost_shares"("user_id"); + +CREATE INDEX "settlements_space_id_month_idx" ON "settlements"("space_id", "month"); + +ALTER TABLE "cost_splits" ADD CONSTRAINT "cost_splits_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "cost_splits" ADD CONSTRAINT "cost_splits_payer_id_fkey" FOREIGN KEY ("payer_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "cost_shares" ADD CONSTRAINT "cost_shares_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cost_splits"("item_id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "cost_shares" ADD CONSTRAINT "cost_shares_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "settlements" ADD CONSTRAINT "settlements_space_id_fkey" FOREIGN KEY ("space_id") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "settlements" ADD CONSTRAINT "settlements_from_user_id_fkey" FOREIGN KEY ("from_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "settlements" ADD CONSTRAINT "settlements_to_user_id_fkey" FOREIGN KEY ("to_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/app/server/prisma/schema.prisma b/app/server/prisma/schema.prisma index 63ec6ca..2b8c944 100644 --- a/app/server/prisma/schema.prisma +++ b/app/server/prisma/schema.prisma @@ -106,6 +106,10 @@ model User { tidyRules TidyRule[] noteVersions NoteVersion[] habitCheckIns HabitCheckIn[] + paidSplits CostSplit[] + costShares CostShare[] + settlementsPaid Settlement[] @relation("SettlementFrom") + settlementsReceived Settlement[] @relation("SettlementTo") @@map("users") } @@ -205,6 +209,7 @@ model Space { activity ActivityEvent[] tidyRules TidyRule[] habits Habit[] + settlements Settlement[] @@map("spaces") } @@ -257,6 +262,7 @@ model Item { databaseSchema DatabaseSchema? properties NoteProperty[] tidyChanges TidyChange[] + costSplit CostSplit? @@index([spaceId, updatedAt]) @@map("items") @@ -742,3 +748,43 @@ model HabitCheckIn { @@index([userId, day]) @@map("habit_check_ins") } + +model CostSplit { + itemId String @id @map("item_id") @db.Uuid + payerId String @map("payer_id") @db.Uuid + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + payer User @relation(fields: [payerId], references: [id], onDelete: Cascade) + shares CostShare[] + + @@index([payerId]) + @@map("cost_splits") +} + +model CostShare { + itemId String @map("item_id") @db.Uuid + userId String @map("user_id") @db.Uuid + weight Int @default(1) + split CostSplit @relation(fields: [itemId], references: [itemId], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([itemId, userId]) + @@index([userId]) + @@map("cost_shares") +} + +model Settlement { + id String @id @default(uuid()) @db.Uuid + spaceId String @map("space_id") @db.Uuid + fromUserId String @map("from_user_id") @db.Uuid + toUserId String @map("to_user_id") @db.Uuid + amountMinor Int @map("amount_minor") + currency String + month String + createdAt DateTime @default(now()) @map("created_at") + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + fromUser User @relation("SettlementFrom", fields: [fromUserId], references: [id], onDelete: Cascade) + toUser User @relation("SettlementTo", fields: [toUserId], references: [id], onDelete: Cascade) + + @@index([spaceId, month]) + @@map("settlements") +} diff --git a/app/server/src/subscriptions/cost-splits.controller.ts b/app/server/src/subscriptions/cost-splits.controller.ts new file mode 100644 index 0000000..1afeb66 --- /dev/null +++ b/app/server/src/subscriptions/cost-splits.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Scope } from '../access-tokens/scopes'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { UuidPipe } from '../validation/uuid.pipe'; +import { CostSplitsService } from './cost-splits.service'; +import { BalancesQueryDto, CreateSettlementDto, SaveCostSplitDto } from './dto/cost-splits.dto'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class CostSplitsController { + constructor(private readonly splits: CostSplitsService) {} + + @Put('subscriptions/:id/split') + @Scope('items:write') + save(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Body() input: SaveCostSplitDto) { + return this.splits.save(user.id, id, input); + } + + @Delete('subscriptions/:id/split') + @Scope('items:write') + @HttpCode(204) + async remove(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { + await this.splits.remove(user.id, id); + } + + @Get('spaces/:spaceId/balances') + @Scope('items:read') + balances(@CurrentUser() user: AuthenticatedUser, @Param('spaceId', UuidPipe) spaceId: string, @Query() query: BalancesQueryDto) { + return this.splits.balances(user.id, spaceId, query); + } + + @Post('spaces/:spaceId/settlements') + @Scope('items:write') + settle(@CurrentUser() user: AuthenticatedUser, @Param('spaceId', UuidPipe) spaceId: string, @Body() input: CreateSettlementDto) { + return this.splits.settle(user.id, spaceId, input); + } + + @Delete('settlements/:id') + @Scope('items:write') + @HttpCode(204) + async undo(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { + await this.splits.undoSettlement(user.id, id); + } +} diff --git a/app/server/src/subscriptions/cost-splits.service.ts b/app/server/src/subscriptions/cost-splits.service.ts new file mode 100644 index 0000000..d078f4c --- /dev/null +++ b/app/server/src/subscriptions/cost-splits.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { ItemKind } from '@prisma/client'; +import { AccessService } from '../access/access.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { dateText } from '../recurrence/dates'; +import { BalancesQueryDto, CreateSettlementDto, SaveCostSplitDto } from './dto/cost-splits.dto'; +import { renewalsBetween } from './subscriptions.service'; + +function nextMonth(month: string) { + const [year, number] = month.split('-').map(Number) as [number, number]; + return number === 12 ? `${year + 1}-01` : `${year}-${String(number + 1).padStart(2, '0')}`; +} + +@Injectable() +export class CostSplitsService { + constructor( + private readonly prisma: PrismaService, + private readonly access: AccessService, + ) {} + + async save(userId: string, itemId: string, input: SaveCostSplitDto) { + const item = await this.access.assertItem(userId, itemId, 'edit'); + if (!(await this.prisma.subscription.count({ where: { itemId } }))) throw new NotFoundException('That item is not a subscription'); + const people = new Set(input.shares.map((share) => share.userId)); + if (people.size !== input.shares.length) throw new BadRequestException('Each member can have one share'); + const members = await this.members(item.spaceId); + if (![...people, input.payerId].every((id) => members.has(id))) throw new BadRequestException('Split only between members of this space'); + await this.prisma.$transaction([ + this.prisma.costSplit.deleteMany({ where: { itemId } }), + this.prisma.costSplit.create({ data: { itemId, payerId: input.payerId, shares: { createMany: { data: input.shares } } } }), + ]); + return this.prisma.costSplit.findUniqueOrThrow({ + where: { itemId }, + select: { payerId: true, shares: { select: { userId: true, weight: true }, orderBy: { userId: 'asc' } } }, + }); + } + + async remove(userId: string, itemId: string) { + await this.access.assertItem(userId, itemId, 'edit'); + await this.prisma.costSplit.deleteMany({ where: { itemId } }); + } + + async balances(userId: string, spaceId: string, { month }: BalancesQueryDto) { + await this.access.assertSpace(userId, spaceId, 'read'); + const from = `${month}-01`; + const to = `${nextMonth(month)}-01`; + const [splits, settlements, members] = await Promise.all([ + this.prisma.costSplit.findMany({ + where: { item: { spaceId, kind: ItemKind.SUBSCRIPTION, trashedAt: null } }, + orderBy: { item: { title: 'asc' } }, + select: { itemId: true, payerId: true, shares: { select: { userId: true, weight: true } }, item: { select: { title: true, subscription: true } } }, + }), + this.prisma.settlement.findMany({ where: { spaceId, month }, orderBy: { createdAt: 'asc' } }), + this.prisma.spaceMember.findMany({ where: { spaceId }, select: { userId: true, user: { select: { displayName: true } } } }), + ]); + const names = new Map(members.map((member) => [member.userId, member.user.displayName])); + const name = (id: string) => names.get(id) ?? 'Former member'; + const owed = new Map(); + const add = (fromId: string, toId: string, currency: string, amount: number) => { + const key = `${fromId}|${toId}|${currency}`; + owed.set(key, (owed.get(key) ?? 0) + amount); + }; + const charges = splits.flatMap((split) => { + const details = split.item.subscription!; + const cancelled = dateText(details.cancelledOn); + const renewals = renewalsBetween(details, from, to).filter((day) => !cancelled || day < cancelled); + if (renewals.length === 0) return []; + const total = renewals.length * details.amountMinor; + const weights = split.shares.reduce((sum, share) => sum + share.weight, 0); + for (const share of split.shares) { + if (share.userId !== split.payerId) add(share.userId, split.payerId, details.currency, Math.round((total * share.weight) / weights)); + } + return [{ itemId: split.itemId, name: split.item.title, payerId: split.payerId, payerName: name(split.payerId), amountMinor: total, currency: details.currency, renewals }]; + }); + for (const settlement of settlements) add(settlement.toUserId, settlement.fromUserId, settlement.currency, settlement.amountMinor); + const debts = [...owed.entries()].flatMap(([key, amount]) => { + const [fromId, toId, currency] = key.split('|') as [string, string, string]; + const net = amount - (owed.get(`${toId}|${fromId}|${currency}`) ?? 0); + return net > 0 ? [{ fromUserId: fromId, fromName: name(fromId), toUserId: toId, toName: name(toId), amountMinor: net, currency }] : []; + }); + return { + month, + charges, + debts, + settlements: settlements.map((settlement) => ({ + id: settlement.id, + fromUserId: settlement.fromUserId, + fromName: name(settlement.fromUserId), + toUserId: settlement.toUserId, + toName: name(settlement.toUserId), + amountMinor: settlement.amountMinor, + currency: settlement.currency, + createdAt: settlement.createdAt, + })), + }; + } + + async settle(userId: string, spaceId: string, input: CreateSettlementDto) { + await this.access.assertSpace(userId, spaceId, 'read'); + if (userId !== input.fromUserId && userId !== input.toUserId) throw new ForbiddenException('Record only payments you made or received'); + if (input.fromUserId === input.toUserId) throw new BadRequestException('Choose two different members'); + const members = await this.members(spaceId); + if (!members.has(input.fromUserId) || !members.has(input.toUserId)) throw new BadRequestException('Settle only between members of this space'); + return this.prisma.settlement.create({ data: { spaceId, ...input }, select: { id: true } }); + } + + async undoSettlement(userId: string, settlementId: string) { + const settlement = await this.prisma.settlement.findFirst({ + where: { id: settlementId, space: this.access.spacesOf(userId), OR: [{ fromUserId: userId }, { toUserId: userId }] }, + select: { id: true }, + }); + if (!settlement) throw new NotFoundException('Payment not found'); + await this.prisma.settlement.delete({ where: { id: settlementId } }); + } + + private async members(spaceId: string) { + const rows = await this.prisma.spaceMember.findMany({ where: { spaceId }, select: { userId: true } }); + return new Set(rows.map((row) => row.userId)); + } +} diff --git a/app/server/src/subscriptions/dto/cost-splits.dto.ts b/app/server/src/subscriptions/dto/cost-splits.dto.ts new file mode 100644 index 0000000..b29d28e --- /dev/null +++ b/app/server/src/subscriptions/dto/cost-splits.dto.ts @@ -0,0 +1,50 @@ +import { Type } from 'class-transformer'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsInt, IsUUID, Matches, Max, Min, ValidateNested } from 'class-validator'; + +const Month = () => Matches(/^\d{4}-(0[1-9]|1[0-2])$/, { message: 'Months look like 2026-10.' }); + +export class CostShareDto { + @IsUUID('all', { message: 'Choose members of this space.' }) + userId!: string; + + @IsInt() + @Min(1, { message: 'Shares are 1 to 100.' }) + @Max(100, { message: 'Shares are 1 to 100.' }) + weight!: number; +} + +export class SaveCostSplitDto { + @IsUUID('all', { message: 'Choose who pays from this space.' }) + payerId!: string; + + @IsArray() + @ArrayMinSize(2, { message: 'Split between at least two members.' }) + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => CostShareDto) + shares!: CostShareDto[]; +} + +export class BalancesQueryDto { + @Month() + month!: string; +} + +export class CreateSettlementDto { + @IsUUID('all', { message: 'Choose members of this space.' }) + fromUserId!: string; + + @IsUUID('all', { message: 'Choose members of this space.' }) + toUserId!: string; + + @IsInt() + @Min(1) + @Max(100_000_000, { message: 'That amount is too large.' }) + amountMinor!: number; + + @Matches(/^[A-Z]{3}$/, { message: 'Currencies are three-letter codes such as EUR or USD.' }) + currency!: string; + + @Month() + month!: string; +} diff --git a/app/server/src/subscriptions/subscriptions.module.ts b/app/server/src/subscriptions/subscriptions.module.ts index 23ce92e..d9c9399 100644 --- a/app/server/src/subscriptions/subscriptions.module.ts +++ b/app/server/src/subscriptions/subscriptions.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { ExchangeRatesModule } from '../exchange-rates/exchange-rates.module'; +import { CostSplitsController } from './cost-splits.controller'; +import { CostSplitsService } from './cost-splits.service'; import { SubscriptionsController } from './subscriptions.controller'; import { SubscriptionsService } from './subscriptions.service'; @Module({ imports: [ExchangeRatesModule], - controllers: [SubscriptionsController], - providers: [SubscriptionsService], + controllers: [SubscriptionsController, CostSplitsController], + providers: [SubscriptionsService, CostSplitsService], exports: [SubscriptionsService], }) export class SubscriptionsModule {} diff --git a/app/server/src/subscriptions/subscriptions.service.ts b/app/server/src/subscriptions/subscriptions.service.ts index 2e29de9..6fab7d6 100644 --- a/app/server/src/subscriptions/subscriptions.service.ts +++ b/app/server/src/subscriptions/subscriptions.service.ts @@ -23,6 +23,7 @@ export const subscriptionFields = { createdAt: true, updatedAt: true, subscription: true, + costSplit: { select: { payerId: true, shares: { select: { userId: true, weight: true }, orderBy: { userId: 'asc' } } } }, } satisfies Prisma.ItemSelect; type SubscriptionRow = Prisma.ItemGetPayload<{ select: typeof subscriptionFields }>; @@ -50,6 +51,7 @@ function present(row: SubscriptionRow) { reminderDays: details.reminderDays, lastUsedOn: dateText(details.lastUsedOn), cancelledOn: dateText(details.cancelledOn), + split: row.costSplit, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -69,7 +71,7 @@ function checkedRule(text: string) { } } -function renewalsBetween(details: Pick, from: string, to: string, limit = 400) { +export function renewalsBetween(details: Pick, from: string, to: string, limit = 400) { return occurrences( parseRule(details.repeatRule), { date: dateText(details.startDate), time: '00:00', timeZone: details.timeZone }, diff --git a/app/server/test/integration/cost-splits.spec.ts b/app/server/test/integration/cost-splits.spec.ts new file mode 100644 index 0000000..e923d58 --- /dev/null +++ b/app/server/test/integration/cost-splits.spec.ts @@ -0,0 +1,80 @@ +import { SpaceRole } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; +import { integrationApp } from './harness'; + +describe('Cost splitting against Postgres', () => { + const strata = integrationApp(); + const { member } = strata; + + it('splits renewals by weight, nets payments and lets only the two people record them', async () => { + const amr = await member('splitter'); + const sam = await member('sharer'); + const vic = await member('bystander'); + const outsider = await member('stranger'); + const flat = (await amr.call('POST', '/spaces', { name: 'Flat' })).body; + await strata.join(flat.id, sam, SpaceRole.EDITOR); + await strata.join(flat.id, vic, SpaceRole.VIEWER); + const internet = ( + await amr.call('POST', `/spaces/${flat.id}/subscriptions`, { + name: 'Internet', + amountMinor: 3000, + currency: 'EUR', + repeatRule: 'FREQ=MONTHLY', + startDate: '2026-09-10', + }) + ).body; + const weekly = ( + await amr.call('POST', `/spaces/${flat.id}/subscriptions`, { + name: 'Veg box', + amountMinor: 1000, + currency: 'EUR', + repeatRule: 'FREQ=WEEKLY', + startDate: '2026-10-01', + }) + ).body; + const split = { payerId: amr.id, shares: [{ userId: amr.id, weight: 1 }, { userId: sam.id, weight: 2 }] }; + + expect((await vic.call('PUT', `/subscriptions/${internet.id}/split`, split)).status).toBe(403); + expect((await amr.call('PUT', `/subscriptions/${internet.id}/split`, { ...split, shares: [...split.shares, { userId: outsider.id, weight: 1 }] })).status).toBe(400); + expect((await amr.call('PUT', `/subscriptions/${internet.id}/split`, { ...split, shares: [split.shares[0]] })).status).toBe(400); + expect((await amr.call('PUT', `/subscriptions/${internet.id}/split`, split)).body).toEqual({ + payerId: amr.id, + shares: expect.arrayContaining([{ userId: amr.id, weight: 1 }, { userId: sam.id, weight: 2 }]), + }); + await sam.call('PUT', `/subscriptions/${weekly.id}/split`, { payerId: sam.id, shares: [{ userId: amr.id, weight: 1 }, { userId: sam.id, weight: 1 }] }); + expect((await vic.call('GET', `/subscriptions/${internet.id}`)).body.split.payerId).toBe(amr.id); + + const october = await vic.call('GET', `/spaces/${flat.id}/balances?month=2026-10`); + expect(october.body.charges).toEqual([ + { itemId: internet.id, name: 'Internet', payerId: amr.id, payerName: 'splitter', amountMinor: 3000, currency: 'EUR', renewals: ['2026-10-10'] }, + { + itemId: weekly.id, + name: 'Veg box', + payerId: sam.id, + payerName: 'sharer', + amountMinor: 5000, + currency: 'EUR', + renewals: ['2026-10-01', '2026-10-08', '2026-10-15', '2026-10-22', '2026-10-29'], + }, + ]); + expect(october.body.debts).toEqual([{ fromUserId: amr.id, fromName: 'splitter', toUserId: sam.id, toName: 'sharer', amountMinor: 500, currency: 'EUR' }]); + expect((await amr.call('GET', `/spaces/${flat.id}/balances?month=2026-13`)).status).toBe(400); + + const payment = { fromUserId: amr.id, toUserId: sam.id, amountMinor: 300, currency: 'EUR', month: '2026-10' }; + expect((await vic.call('POST', `/spaces/${flat.id}/settlements`, payment)).status).toBe(403); + expect((await outsider.call('POST', `/spaces/${flat.id}/settlements`, payment)).status).toBe(404); + const settled = (await sam.call('POST', `/spaces/${flat.id}/settlements`, payment)).body; + const after = (await amr.call('GET', `/spaces/${flat.id}/balances?month=2026-10`)).body; + expect(after.debts).toEqual([expect.objectContaining({ fromUserId: amr.id, toUserId: sam.id, amountMinor: 200 })]); + expect(after.settlements).toEqual([expect.objectContaining({ id: settled.id, fromName: 'splitter', toName: 'sharer', amountMinor: 300 })]); + + expect((await vic.call('DELETE', `/settlements/${settled.id}`)).status).toBe(404); + expect((await amr.call('DELETE', `/settlements/${settled.id}`)).status).toBe(204); + expect((await amr.call('GET', `/spaces/${flat.id}/balances?month=2026-10`)).body.debts[0].amountMinor).toBe(500); + + await amr.call('POST', `/subscriptions/${weekly.id}/cancel`); + expect((await amr.call('DELETE', `/subscriptions/${internet.id}/split`)).status).toBe(204); + expect((await amr.call('GET', `/subscriptions/${internet.id}`)).body.split).toBeNull(); + expect((await amr.call('GET', `/spaces/${flat.id}/balances?month=2027-01`)).body).toMatchObject({ charges: [], debts: [] }); + }); +}); From b6859afd67afd07308814a6769018b82bc490e19 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:37:11 +0300 Subject: [PATCH 2/2] fix: let only the person paid record a settlement --- app/frontend/src/pages/spaces/SpaceBalances.tsx | 4 ++-- app/frontend/test/subscriptions.spec.tsx | 2 +- app/server/src/subscriptions/cost-splits.service.ts | 2 +- app/server/test/integration/cost-splits.spec.ts | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/frontend/src/pages/spaces/SpaceBalances.tsx b/app/frontend/src/pages/spaces/SpaceBalances.tsx index 2f3b8c8..e2a4f9b 100644 --- a/app/frontend/src/pages/spaces/SpaceBalances.tsx +++ b/app/frontend/src/pages/spaces/SpaceBalances.tsx @@ -86,9 +86,9 @@ export function SpaceBalances({ spaceId }: { spaceId: string }) { {debt.fromName} owes {debt.toName} {money(debt.amountMinor, debt.currency)} - {(me === debt.fromUserId || me === debt.toUserId) && ( + {me === debt.toUserId && ( )} diff --git a/app/frontend/test/subscriptions.spec.tsx b/app/frontend/test/subscriptions.spec.tsx index 9d070d3..cc2c65c 100644 --- a/app/frontend/test/subscriptions.spec.tsx +++ b/app/frontend/test/subscriptions.spec.tsx @@ -294,7 +294,7 @@ describe('Recurring', () => { const balances = container.querySelector('section[aria-label="Split costs"]')!; expect(balances.textContent).toContain(`Sam owes Amr ${euros(2000)}`); - await act(async () => [...balances.querySelectorAll('button')].find((button) => button.textContent === 'Settle up')!.click()); + await act(async () => [...balances.querySelectorAll('button')].find((button) => button.textContent === 'Mark as paid')!.click()); expect(sent[1]).toEqual({ fromUserId: 'sam', toUserId: 'me', amountMinor: 2000, currency: 'EUR', month: new Date().toISOString().slice(0, 7) }); expect(container.querySelector('section[aria-label="Split costs"]')!.textContent).toContain('Everyone is square.'); }); diff --git a/app/server/src/subscriptions/cost-splits.service.ts b/app/server/src/subscriptions/cost-splits.service.ts index d078f4c..4b50497 100644 --- a/app/server/src/subscriptions/cost-splits.service.ts +++ b/app/server/src/subscriptions/cost-splits.service.ts @@ -97,7 +97,7 @@ export class CostSplitsService { async settle(userId: string, spaceId: string, input: CreateSettlementDto) { await this.access.assertSpace(userId, spaceId, 'read'); - if (userId !== input.fromUserId && userId !== input.toUserId) throw new ForbiddenException('Record only payments you made or received'); + if (userId !== input.toUserId) throw new ForbiddenException('Only the person who was paid can record a payment'); if (input.fromUserId === input.toUserId) throw new BadRequestException('Choose two different members'); const members = await this.members(spaceId); if (!members.has(input.fromUserId) || !members.has(input.toUserId)) throw new BadRequestException('Settle only between members of this space'); diff --git a/app/server/test/integration/cost-splits.spec.ts b/app/server/test/integration/cost-splits.spec.ts index e923d58..f62f328 100644 --- a/app/server/test/integration/cost-splits.spec.ts +++ b/app/server/test/integration/cost-splits.spec.ts @@ -62,6 +62,7 @@ describe('Cost splitting against Postgres', () => { const payment = { fromUserId: amr.id, toUserId: sam.id, amountMinor: 300, currency: 'EUR', month: '2026-10' }; expect((await vic.call('POST', `/spaces/${flat.id}/settlements`, payment)).status).toBe(403); + expect((await amr.call('POST', `/spaces/${flat.id}/settlements`, payment)).status).toBe(403); expect((await outsider.call('POST', `/spaces/${flat.id}/settlements`, payment)).status).toBe(404); const settled = (await sam.call('POST', `/spaces/${flat.id}/settlements`, payment)).body; const after = (await amr.call('GET', `/spaces/${flat.id}/balances?month=2026-10`)).body;