Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/src/splits/dto/pay-split.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 4 additions & 3 deletions backend/src/splits/split.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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()
Expand All @@ -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')
Expand Down
49 changes: 43 additions & 6 deletions backend/src/splits/split.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -52,6 +54,8 @@ describe('SplitService', () => {
let transfersService: jest.Mocked<TransfersService>;
let notificationService: jest.Mocked<NotificationService>;
let emailService: jest.Mocked<EmailService>;
let pushService: jest.Mocked<PushService>;
let pinService: jest.Mocked<PinService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -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();
Expand All @@ -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 ────────────────────────────────────────────────────────────────
Expand All @@ -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: [
Expand All @@ -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' }],
Expand All @@ -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 ───────────────────────────────────────────────────────────────────
Expand All @@ -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',
Expand All @@ -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,
Expand All @@ -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,
);
});
Expand Down
50 changes: 37 additions & 13 deletions backend/src/splits/split.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<SplitRequest> {
// 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<SplitRequest> {
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 };
Expand Down Expand Up @@ -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,
Expand All @@ -111,14 +111,25 @@ 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;
}

// ── Pay ───────────────────────────────────────────────────────────────────

async pay(splitRequestId: string, payerId: string, payerUsername: string): Promise<SplitParticipant> {
async pay(
splitRequestId: string,
payerId: string,
payerUsername: string,
pin: string,
): Promise<SplitParticipant> {
const split = await this.findActiveOrFail(splitRequestId);

if (split.initiatorId === payerId) {
Expand All @@ -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);

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -326,4 +346,8 @@ export class SplitService {
}
return split;
}

private toUsdcString(amount: string): string {
return parseFloat(amount).toFixed(6);
}
}
4 changes: 4 additions & 0 deletions backend/src/splits/splits.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -20,6 +22,8 @@ import { EmailModule } from '../email/email.module';
TransfersModule,
NotificationsModule,
EmailModule,
PushModule,
PinModule,
],
providers: [SplitService, SplitProcessor],
controllers: [SplitController],
Expand Down
Loading