From 927fb75afac2893e508a64871c85e99fbee0611b Mon Sep 17 00:00:00 2001 From: Mexes Date: Mon, 30 Mar 2026 11:15:13 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Split=20payments=20=E2=80=94=20request?= =?UTF-8?q?=20money=20from=20multiple=20people?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/splits/dto/pay-split.dto.ts | 9 +++++ backend/src/splits/split.controller.ts | 7 ++-- backend/src/splits/split.service.spec.ts | 49 ++++++++++++++++++++--- backend/src/splits/split.service.ts | 50 ++++++++++++++++++------ backend/src/splits/splits.module.ts | 4 ++ 5 files changed, 97 insertions(+), 22 deletions(-) create mode 100644 backend/src/splits/dto/pay-split.dto.ts diff --git a/backend/src/splits/dto/pay-split.dto.ts b/backend/src/splits/dto/pay-split.dto.ts new file mode 100644 index 00000000..61fe8531 --- /dev/null +++ b/backend/src/splits/dto/pay-split.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, Matches } from 'class-validator'; + +export class PaySplitDto { + @ApiProperty({ example: '1234' }) + @IsNotEmpty() + @Matches(/^[0-9]{4}$/) + pin!: string; +} diff --git a/backend/src/splits/split.controller.ts b/backend/src/splits/split.controller.ts index 6a1f6975..468bc727 100644 --- a/backend/src/splits/split.controller.ts +++ b/backend/src/splits/split.controller.ts @@ -17,6 +17,7 @@ import { User } from '../users/entities/user.entity'; import { SplitService } from './split.service'; import { CreateSplitDto } from './dto/create-split.dto'; import { QuerySplitsDto } from './dto/query-splits.dto'; +import { PaySplitDto } from './dto/pay-split.dto'; type AuthReq = Request & { user: User }; @@ -30,7 +31,7 @@ export class SplitController { @Post() @ApiOperation({ summary: 'Create a split payment request' }) create(@Req() req: AuthReq, @Body() dto: CreateSplitDto) { - return this.splitService.create(req.user.id, req.user.username, dto); + return this.splitService.create(req.user.id, dto); } @Get() @@ -47,8 +48,8 @@ export class SplitController { @Post(':id/pay') @ApiOperation({ summary: 'Pay your share of a split' }) - pay(@Param('id', ParseUUIDPipe) id: string, @Req() req: AuthReq) { - return this.splitService.pay(id, req.user.id, req.user.username); + pay(@Param('id', ParseUUIDPipe) id: string, @Req() req: AuthReq, @Body() dto: PaySplitDto) { + return this.splitService.pay(id, req.user.id, req.user.username, dto.pin); } @Post(':id/decline') diff --git a/backend/src/splits/split.service.spec.ts b/backend/src/splits/split.service.spec.ts index f8149ecd..63842d08 100644 --- a/backend/src/splits/split.service.spec.ts +++ b/backend/src/splits/split.service.spec.ts @@ -9,6 +9,8 @@ import { UsersService } from '../users/users.service'; import { TransfersService } from '../transfers/transfers.service'; import { NotificationService } from '../notifications/notifications.service'; import { EmailService } from '../email/email.service'; +import { PushService } from '../push/push.service'; +import { PinService } from '../pin/pin.service'; const mockUser = (id: string, username: string) => ({ id, @@ -52,6 +54,8 @@ describe('SplitService', () => { let transfersService: jest.Mocked; let notificationService: jest.Mocked; let emailService: jest.Mocked; + let pushService: jest.Mocked; + let pinService: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -87,6 +91,8 @@ describe('SplitService', () => { { provide: TransfersService, useValue: { create: jest.fn() } }, { provide: NotificationService, useValue: { create: jest.fn() } }, { provide: EmailService, useValue: { queue: jest.fn() } }, + { provide: PushService, useValue: { send: jest.fn() } }, + { provide: PinService, useValue: { verifyPin: jest.fn() } }, { provide: getQueueToken(SPLIT_QUEUE), useValue: { add: jest.fn() } }, ], }).compile(); @@ -98,6 +104,8 @@ describe('SplitService', () => { transfersService = module.get(TransfersService); notificationService = module.get(NotificationService); emailService = module.get(EmailService); + pushService = module.get(PushService); + pinService = module.get(PinService); }); // ── create ──────────────────────────────────────────────────────────────── @@ -110,7 +118,8 @@ describe('SplitService', () => { splitRepo.save.mockResolvedValue(mockSplit()); participantRepo.save.mockResolvedValue([mockParticipant()]); - await service.create('user-initiator', 'initiator', { + usersService.findById.mockResolvedValue(mockUser('user-initiator', 'initiator') as any); + await service.create('user-initiator', { title: 'Dinner', expiresInHours: 24, participants: [ @@ -122,11 +131,13 @@ describe('SplitService', () => { expect(splitRepo.save).toHaveBeenCalled(); expect(participantRepo.save).toHaveBeenCalled(); expect(notificationService.create).toHaveBeenCalled(); + expect(pushService.send).toHaveBeenCalled(); }); it('throws if initiator is listed as participant', async () => { + usersService.findById.mockResolvedValue(mockUser('user-initiator', 'initiator') as any); await expect( - service.create('user-initiator', 'initiator', { + service.create('user-initiator', { title: 'Dinner', expiresInHours: 24, participants: [{ username: 'initiator', amountUsdc: '10.00' }], @@ -135,16 +146,39 @@ describe('SplitService', () => { }); it('throws NotFoundException for unknown participant username', async () => { + usersService.findById.mockResolvedValue(mockUser('user-initiator', 'initiator') as any); usersService.findByUsername.mockResolvedValue(null); await expect( - service.create('user-initiator', 'initiator', { + service.create('user-initiator', { title: 'Dinner', expiresInHours: 24, participants: [{ username: 'ghost', amountUsdc: '10.00' }], }), ).rejects.toThrow(NotFoundException); }); + + it('stores totalAmountUsdc as the participant sum', async () => { + usersService.findById.mockResolvedValue(mockUser('user-initiator', 'initiator') as any); + usersService.findByUsername + .mockResolvedValueOnce(mockUser('user-alice', 'alice') as any) + .mockResolvedValueOnce(mockUser('user-bob', 'bob') as any); + splitRepo.save.mockResolvedValue(mockSplit({ totalAmountUsdc: '30.000000' })); + participantRepo.save.mockResolvedValue([mockParticipant()]); + + await service.create('user-initiator', { + title: 'Dinner', + expiresInHours: 24, + participants: [ + { username: 'alice', amountUsdc: '10.00' }, + { username: 'bob', amountUsdc: '20.00' }, + ], + }); + + expect(splitRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ totalAmountUsdc: '30.000000' }), + ); + }); }); // ── pay ─────────────────────────────────────────────────────────────────── @@ -154,12 +188,14 @@ describe('SplitService', () => { splitRepo.findOne.mockResolvedValue(mockSplit()); participantRepo.findOne.mockResolvedValue(mockParticipant()); participantRepo.count.mockResolvedValue(0); // all paid after this + pinService.verifyPin.mockResolvedValue(undefined); usersService.findById.mockResolvedValue(mockUser('user-initiator', 'initiator') as any); transfersService.create.mockResolvedValue({ id: 'tx-1', txHash: 'HASH' } as any); participantRepo.save.mockResolvedValue(mockParticipant({ status: SplitParticipantStatus.PAID })); - await service.pay('split-1', 'user-alice', 'alice'); + await service.pay('split-1', 'user-alice', 'alice', '1234'); + expect(pinService.verifyPin).toHaveBeenCalledWith('user-alice', '1234'); expect(transfersService.create).toHaveBeenCalledWith( 'user-alice', 'alice', @@ -172,11 +208,12 @@ describe('SplitService', () => { splitRepo.findOne.mockResolvedValue(mockSplit()); participantRepo.findOne.mockResolvedValue(mockParticipant()); participantRepo.count.mockResolvedValue(0); // no more pending + pinService.verifyPin.mockResolvedValue(undefined); usersService.findById.mockResolvedValue(mockUser('user-initiator', 'initiator') as any); transfersService.create.mockResolvedValue({ id: 'tx-1' } as any); participantRepo.save.mockResolvedValue({}); - await service.pay('split-1', 'user-alice', 'alice'); + await service.pay('split-1', 'user-alice', 'alice', '1234'); expect(splitRepo.update).toHaveBeenCalledWith('split-1', { status: SplitRequestStatus.COMPLETED, @@ -186,7 +223,7 @@ describe('SplitService', () => { it('throws ForbiddenException if initiator tries to pay own split', async () => { splitRepo.findOne.mockResolvedValue(mockSplit({ initiatorId: 'user-initiator' })); - await expect(service.pay('split-1', 'user-initiator', 'initiator')).rejects.toThrow( + await expect(service.pay('split-1', 'user-initiator', 'initiator', '1234')).rejects.toThrow( ForbiddenException, ); }); diff --git a/backend/src/splits/split.service.ts b/backend/src/splits/split.service.ts index 07117846..4c76122a 100644 --- a/backend/src/splits/split.service.ts +++ b/backend/src/splits/split.service.ts @@ -17,6 +17,8 @@ import { UsersService } from '../users/users.service'; import { TransfersService } from '../transfers/transfers.service'; import { NotificationService } from '../notifications/notifications.service'; import { EmailService } from '../email/email.service'; +import { PushService } from '../push/push.service'; +import { PinService } from '../pin/pin.service'; import { NotificationType } from '../notifications/notifications.types'; export const SPLIT_QUEUE = 'split-payments'; @@ -35,29 +37,27 @@ export class SplitService { private readonly transfersService: TransfersService, private readonly notificationService: NotificationService, private readonly emailService: EmailService, + private readonly pushService: PushService, + private readonly pinService: PinService, @InjectQueue(SPLIT_QUEUE) private readonly splitQueue: Queue, ) {} // ── Create ──────────────────────────────────────────────────────────────── - async create(initiatorId: string, initiatorUsername: string, dto: CreateSplitDto): Promise { - // Validate amounts sum to total - const participantTotal = dto.participants - .reduce((sum, p) => sum + parseFloat(p.amountUsdc), 0) - .toFixed(6); - if (Math.abs(parseFloat(participantTotal) - parseFloat(dto.participants.reduce((s, p) => (parseFloat(s) + parseFloat(p.amountUsdc)).toFixed(6), '0'))) > 0.000001) { - // re-check with simple sum - } - const total = dto.participants.reduce((s, p) => s + parseFloat(p.amountUsdc), 0); - const declared = parseFloat(dto.participants.reduce((_, __) => _, dto.participants.reduce((s, p) => (s + parseFloat(p.amountUsdc)), 0).toFixed(6))); + async create(initiatorId: string, dto: CreateSplitDto): Promise { + const initiator = await this.usersService.findById(initiatorId); // Resolve all usernames → users (validates existence) const resolvedUsers = await Promise.all( dto.participants.map(async (p) => { - if (p.username === initiatorUsername) { + if (p.username.toLowerCase() === initiator.username.toLowerCase()) { throw new BadRequestException('Initiator cannot be a participant'); } + const amount = parseFloat(p.amountUsdc); + if (Number.isNaN(amount) || amount <= 0) { + throw new BadRequestException(`Invalid amount for @${p.username}`); + } const user = await this.usersService.findByUsername(p.username); if (!user) throw new NotFoundException(`User @${p.username} not found`); return { user, amountUsdc: p.amountUsdc }; @@ -96,7 +96,7 @@ export class SplitService { // Notify each participant for (const p of participants) { - const amount = p.amountOwedUsdc; + const amount = this.toUsdcString(p.amountOwedUsdc); await this.notificationService.create( p.userId, NotificationType.SYSTEM, @@ -111,6 +111,12 @@ export class SplitService { { title: split.title, amountUsdc: amount, splitId: split.id }, p.userId, ); + + await this.pushService.send(p.userId, { + title: 'Split payment request', + body: `You owe ${amount} USDC for "${split.title}"`, + data: { splitRequestId: split.id, type: 'split_request' }, + }); } return split; @@ -118,7 +124,12 @@ export class SplitService { // ── Pay ─────────────────────────────────────────────────────────────────── - async pay(splitRequestId: string, payerId: string, payerUsername: string): Promise { + async pay( + splitRequestId: string, + payerId: string, + payerUsername: string, + pin: string, + ): Promise { const split = await this.findActiveOrFail(splitRequestId); if (split.initiatorId === payerId) { @@ -133,6 +144,8 @@ export class SplitService { throw new BadRequestException(`Share already ${participant.status}`); } + await this.pinService.verifyPin(payerId, pin); + // Resolve initiator username const initiator = await this.usersService.findById(split.initiatorId); @@ -223,6 +236,13 @@ export class SplitService { { splitRequestId }, ); } + await this.notificationService.create( + split.initiatorId, + NotificationType.SYSTEM, + 'Split cancelled', + `You cancelled split "${split.title}"`, + { splitRequestId }, + ); return split; } @@ -326,4 +346,8 @@ export class SplitService { } return split; } + + private toUsdcString(amount: string): string { + return parseFloat(amount).toFixed(6); + } } diff --git a/backend/src/splits/splits.module.ts b/backend/src/splits/splits.module.ts index d12ba727..699aef21 100644 --- a/backend/src/splits/splits.module.ts +++ b/backend/src/splits/splits.module.ts @@ -11,6 +11,8 @@ import { UsersModule } from '../users/users.module'; import { TransfersModule } from '../transfers/transfers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { EmailModule } from '../email/email.module'; +import { PushModule } from '../push/push.module'; +import { PinModule } from '../pin/pin.module'; @Module({ imports: [ @@ -20,6 +22,8 @@ import { EmailModule } from '../email/email.module'; TransfersModule, NotificationsModule, EmailModule, + PushModule, + PinModule, ], providers: [SplitService, SplitProcessor], controllers: [SplitController],