Skip to content

Commit cc41932

Browse files
Fix/issues 7 8 9 expense ledger improvements (#10)
* feat(expenses): add categories and saved group split defaults * feat(web): add group ledger search and filters
1 parent 02c0232 commit cc41932

15 files changed

Lines changed: 559 additions & 99 deletions

File tree

apps/backend/prisma/schema.prisma

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ model Group {
5757
name String
5858
createdBy String
5959
currency String
60+
defaultSplitType String?
61+
defaultSplitConfig String?
6062
createdAt DateTime @default(now())
6163
creator User @relation("group_creator", fields: [createdBy], references: [id])
6264
members GroupMember[]
@@ -93,6 +95,7 @@ model Expense {
9395
description String
9496
totalAmountCents BigInt
9597
currency String
98+
category String?
9699
createdAt DateTime @default(now())
97100
group Group @relation(fields: [groupId], references: [id])
98101
payer User @relation("expense_payer", fields: [payerId], references: [id])
@@ -235,3 +238,5 @@ model Payment {
235238
@@index([receiverId])
236239
@@map("payments")
237240
}
241+
242+

apps/backend/src/expenses/dto/create-expense.dto.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { IsArray, IsIn, IsString, MinLength, ValidateNested } from 'class-validator';
1+
import { IsArray, IsIn, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator';
22
import { Transform, Type } from 'class-transformer';
3-
import { CreateExpenseRequestDto, CreateExpenseSplitDto } from '@fairshare/shared-types';
3+
import { CreateExpenseRequestDto, CreateExpenseSplitDto, EXPENSE_CATEGORIES } from '@fairshare/shared-types';
44
import { sanitizeText } from '../../common/utils/sanitize.util';
55

66
export class CreateExpenseSplitInputDto implements CreateExpenseSplitDto {
@@ -30,6 +30,11 @@ export class CreateExpenseDto implements CreateExpenseRequestDto {
3030
@IsIn(['USD', 'EUR', 'INR'])
3131
currency!: 'USD' | 'EUR' | 'INR';
3232

33+
@IsOptional()
34+
@IsString()
35+
@IsIn([...EXPENSE_CATEGORIES])
36+
category?: (typeof EXPENSE_CATEGORIES)[number];
37+
3338
@IsArray()
3439
@ValidateNested({ each: true })
3540
@Type(() => CreateExpenseSplitInputDto)
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { IsOptional, IsString, MinLength } from 'class-validator';
2-
import { UpdateExpenseRequestDto } from '@fairshare/shared-types';
1+
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
2+
import { EXPENSE_CATEGORIES, UpdateExpenseRequestDto } from '@fairshare/shared-types';
33

44
export class UpdateExpenseDto implements UpdateExpenseRequestDto {
55
@IsOptional()
66
@IsString()
77
@MinLength(2)
88
description?: string;
9+
10+
@IsOptional()
11+
@IsString()
12+
@IsIn([...EXPENSE_CATEGORIES])
13+
category?: (typeof EXPENSE_CATEGORIES)[number] | null;
914
}

apps/backend/src/expenses/expenses.service.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export class ExpensesService {
7373
description: dto.description,
7474
totalAmountCents: totalAmount,
7575
currency: dto.currency,
76+
category: dto.category ?? null,
7677
},
7778
});
7879

@@ -105,6 +106,7 @@ export class ExpensesService {
105106
metadata: {
106107
payerId: dto.payerId,
107108
totalAmountCents: totalAmount.toString(),
109+
category: dto.category ?? null,
108110
},
109111
},
110112
});
@@ -131,6 +133,7 @@ export class ExpensesService {
131133
expenseId: expense.id,
132134
payerId: expense.payerId,
133135
totalAmountCents: expense.totalAmountCents.toString(),
136+
category: expense.category as ExpenseDto['category'],
134137
});
135138
incrementExpenseCreated(groupId);
136139

@@ -180,6 +183,7 @@ export class ExpensesService {
180183
where: { id },
181184
data: {
182185
description: dto.description,
186+
category: dto.category,
183187
},
184188
include: { splits: true },
185189
});
@@ -189,7 +193,7 @@ export class ExpensesService {
189193
actorUserId,
190194
type: 'expense_updated',
191195
entityId: expense.id,
192-
metadata: { description: dto.description ?? null },
196+
metadata: { description: dto.description ?? null, category: dto.category ?? null },
193197
});
194198

195199
await this.redis.invalidateGroupCache(expense.groupId);
@@ -245,6 +249,7 @@ export class ExpensesService {
245249
description: string;
246250
totalAmountCents: bigint;
247251
currency: string;
252+
category: string | null;
248253
createdAt: Date;
249254
splits: Array<{
250255
id: string;
@@ -260,6 +265,7 @@ export class ExpensesService {
260265
description: expense.description,
261266
totalAmountCents: expense.totalAmountCents.toString(),
262267
currency: expense.currency as 'USD' | 'EUR' | 'INR',
268+
category: expense.category as ExpenseDto['category'],
263269
createdAt: expense.createdAt.toISOString(),
264270
splits: expense.splits.map((split) => ({
265271
id: split.id,
@@ -270,3 +276,4 @@ export class ExpensesService {
270276
};
271277
}
272278
}
279+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Type } from 'class-transformer';
2+
import { IsArray, IsIn, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
3+
import { EXPENSE_SPLIT_TYPES, GroupDefaultSplitDto, UpdateGroupDefaultSplitRequestDto } from '@fairshare/shared-types';
4+
5+
class GroupDefaultSplitPreferenceDto implements GroupDefaultSplitDto {
6+
@IsString()
7+
@IsIn([...EXPENSE_SPLIT_TYPES])
8+
splitType!: (typeof EXPENSE_SPLIT_TYPES)[number];
9+
10+
@IsArray()
11+
@IsString({ each: true })
12+
participantUserIds!: string[];
13+
14+
@IsOptional()
15+
@IsObject()
16+
exactAmountsCentsByUser?: Record<string, string>;
17+
18+
@IsOptional()
19+
@IsObject()
20+
percentagesByUser?: Record<string, string>;
21+
}
22+
23+
export class UpdateGroupDefaultSplitDto implements UpdateGroupDefaultSplitRequestDto {
24+
@IsOptional()
25+
@ValidateNested()
26+
@Type(() => GroupDefaultSplitPreferenceDto)
27+
defaultSplitPreference!: GroupDefaultSplitPreferenceDto | null;
28+
}

apps/backend/src/groups/groups.controller.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
1+
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
22
import { Throttle } from '@nestjs/throttler';
33
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
44
import { CurrentUser } from '../common/decorators/current-user.decorator';
55
import { JwtPayload } from '../auth/types/auth.types';
66
import { GroupsService } from './groups.service';
77
import { CreateGroupDto } from './dto/create-group.dto';
88
import { InviteMemberDto } from './dto/invite-member.dto';
9+
import { UpdateGroupDefaultSplitDto } from './dto/update-group-default-split.dto';
910

1011
@Controller('groups')
1112
@UseGuards(JwtAuthGuard)
@@ -42,9 +43,14 @@ export class GroupsController {
4243
return this.groupsService.summary(id, user.sub);
4344
}
4445

46+
@Patch(':id/default-split')
47+
updateDefaultSplit(@Param('id') id: string, @CurrentUser() user: JwtPayload, @Body() dto: UpdateGroupDefaultSplitDto) {
48+
return this.groupsService.updateDefaultSplit(id, user.sub, dto);
49+
}
50+
4551
@Post(':id/invite')
4652
@Throttle({ default: { limit: 10, ttl: 60_000 } })
4753
invite(@Param('id') id: string, @CurrentUser() user: JwtPayload, @Body() dto: InviteMemberDto) {
4854
return this.groupsService.invite(id, user.sub, dto);
4955
}
50-
}
56+
}

apps/backend/src/groups/groups.service.ts

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
2-
import { GroupDto, GroupMemberSummaryDto, GroupSummaryDto } from '@fairshare/shared-types';
2+
import { GroupDefaultSplitDto, GroupDto, GroupMemberSummaryDto, GroupSummaryDto } from '@fairshare/shared-types';
33
import { PrismaService } from '../common/prisma.service';
44
import { RedisService } from '../redis/redis.service';
55
import { NotificationsService } from '../notifications/notifications.service';
66
import { RealtimeService } from '../realtime/realtime.service';
77
import { CreateGroupDto } from './dto/create-group.dto';
88
import { InviteMemberDto } from './dto/invite-member.dto';
9+
import { UpdateGroupDefaultSplitDto } from './dto/update-group-default-split.dto';
910

1011
@Injectable()
1112
export class GroupsService {
@@ -53,6 +54,7 @@ export class GroupsService {
5354
currency: group.currency as 'USD' | 'EUR' | 'INR',
5455
createdBy: group.createdBy,
5556
createdAt: group.createdAt.toISOString(),
57+
defaultSplitPreference: null,
5658
};
5759
}
5860

@@ -74,6 +76,7 @@ export class GroupsService {
7476
currency: group.currency as 'USD' | 'EUR' | 'INR',
7577
createdBy: group.createdBy,
7678
createdAt: group.createdAt.toISOString(),
79+
defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig),
7780
}));
7881
}
7982

@@ -100,6 +103,7 @@ export class GroupsService {
100103
currency: group.currency as 'USD' | 'EUR' | 'INR',
101104
createdBy: group.createdBy,
102105
createdAt: group.createdAt.toISOString(),
106+
defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig),
103107
members: group.members.map((member) => ({
104108
id: member.id,
105109
userId: member.userId,
@@ -209,13 +213,79 @@ export class GroupsService {
209213
};
210214
}
211215

216+
async updateDefaultSplit(groupId: string, actorUserId: string, dto: UpdateGroupDefaultSplitDto): Promise<GroupDto> {
217+
await this.assertMembership(groupId, actorUserId);
218+
219+
let normalizedPreference: GroupDefaultSplitDto | null = null;
220+
if (dto.defaultSplitPreference) {
221+
const members = await this.prisma.groupMember.findMany({
222+
where: { groupId },
223+
select: { userId: true },
224+
});
225+
const memberIds = new Set(members.map((member) => member.userId));
226+
const participantUserIds = Array.from(new Set(dto.defaultSplitPreference.participantUserIds));
227+
228+
if (participantUserIds.length === 0) {
229+
throw new ForbiddenException('Default split must include at least one participant');
230+
}
231+
232+
const invalidParticipant = participantUserIds.find((userId) => !memberIds.has(userId));
233+
if (invalidParticipant) {
234+
throw new ForbiddenException('Default split participants must belong to the group');
235+
}
236+
237+
normalizedPreference = {
238+
splitType: dto.defaultSplitPreference.splitType,
239+
participantUserIds,
240+
exactAmountsCentsByUser: dto.defaultSplitPreference.exactAmountsCentsByUser,
241+
percentagesByUser: dto.defaultSplitPreference.percentagesByUser,
242+
};
243+
}
244+
245+
await this.prisma.group.update({
246+
where: { id: groupId },
247+
data: {
248+
defaultSplitType: normalizedPreference?.splitType ?? null,
249+
defaultSplitConfig: normalizedPreference
250+
? JSON.stringify({
251+
participantUserIds: normalizedPreference.participantUserIds,
252+
exactAmountsCentsByUser: normalizedPreference.exactAmountsCentsByUser ?? {},
253+
percentagesByUser: normalizedPreference.percentagesByUser ?? {},
254+
})
255+
: null,
256+
},
257+
});
258+
259+
const group = await this.prisma.group.findUniqueOrThrow({
260+
where: { id: groupId },
261+
include: { members: true },
262+
});
263+
264+
await this.redis.invalidateGroupCache(groupId);
265+
266+
return {
267+
id: group.id,
268+
name: group.name,
269+
currency: group.currency as 'USD' | 'EUR' | 'INR',
270+
createdBy: group.createdBy,
271+
createdAt: group.createdAt.toISOString(),
272+
defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig),
273+
members: group.members.map((member) => ({
274+
id: member.id,
275+
userId: member.userId,
276+
groupId: member.groupId,
277+
role: member.role,
278+
joinedAt: member.joinedAt.toISOString(),
279+
})),
280+
};
281+
}
282+
212283
async invite(groupId: string, actorUserId: string, dto: InviteMemberDto): Promise<{ success: true }> {
213284
await this.assertMembership(groupId, actorUserId);
214285
const email = dto.email.toLowerCase();
215286

216287
const user = await this.prisma.user.findUnique({ where: { email } });
217288
if (!user) {
218-
// Create or update pending invitation
219289
await this.prisma.groupInvite.upsert({
220290
where: {
221291
groupId_email: {
@@ -229,7 +299,7 @@ export class GroupsService {
229299
invitedBy: actorUserId,
230300
role: 'MEMBER',
231301
},
232-
update: {}, // Already invited, no-op
302+
update: {},
233303
});
234304
return { success: true };
235305
}
@@ -298,7 +368,6 @@ export class GroupsService {
298368

299369
await this.prisma.$transaction(async (tx) => {
300370
for (const invite of invites) {
301-
// Create membership
302371
await tx.groupMember.create({
303372
data: {
304373
groupId: invite.groupId,
@@ -307,7 +376,6 @@ export class GroupsService {
307376
},
308377
});
309378

310-
// Add activity
311379
await tx.activity.create({
312380
data: {
313381
groupId: invite.groupId,
@@ -318,17 +386,46 @@ export class GroupsService {
318386
},
319387
});
320388

321-
// Delete invite
322389
await tx.groupInvite.delete({
323390
where: { id: invite.id },
324391
});
325392

326-
// Invalidate cache for each group
327393
await this.redis.invalidateGroupCache(invite.groupId);
328394
}
329395
});
330396
}
331397

398+
private parseDefaultSplitPreference(splitType: string | null, splitConfig: string | null): GroupDefaultSplitDto | null {
399+
if (!splitType || !splitConfig) {
400+
return null;
401+
}
402+
403+
try {
404+
const config = JSON.parse(splitConfig) as {
405+
participantUserIds?: unknown;
406+
exactAmountsCentsByUser?: unknown;
407+
percentagesByUser?: unknown;
408+
};
409+
410+
return {
411+
splitType: splitType as GroupDefaultSplitDto['splitType'],
412+
participantUserIds: Array.isArray(config.participantUserIds)
413+
? config.participantUserIds.filter((value): value is string => typeof value === 'string')
414+
: [],
415+
exactAmountsCentsByUser:
416+
config.exactAmountsCentsByUser && typeof config.exactAmountsCentsByUser === 'object' && !Array.isArray(config.exactAmountsCentsByUser)
417+
? (config.exactAmountsCentsByUser as Record<string, string>)
418+
: undefined,
419+
percentagesByUser:
420+
config.percentagesByUser && typeof config.percentagesByUser === 'object' && !Array.isArray(config.percentagesByUser)
421+
? (config.percentagesByUser as Record<string, string>)
422+
: undefined,
423+
};
424+
} catch {
425+
return null;
426+
}
427+
}
428+
332429
private async assertMembership(groupId: string, userId: string): Promise<void> {
333430
const membership = await this.prisma.groupMember.findUnique({
334431
where: {

0 commit comments

Comments
 (0)