Skip to content
Open
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
36 changes: 18 additions & 18 deletions apps/backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions apps/backend/src/modules/escrow/controllers/escrow.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,32 @@ export class EscrowController {
return this.escrowService.findEvents(userId, query, id);
}

@Post(':id/parties/:partyId/accept')
@UseGuards(EscrowAccessGuard)
@ApiOperation({ summary: 'Accept an invitation to join an escrow' })
async acceptParty(
@Param('id') id: string,
@Param('partyId') partyId: string,
@Request() req: AuthenticatedRequest,
) {
const userId = this.getAuthenticatedUserId(req);
const ipAddress = req.ip || req.socket?.remoteAddress;
return this.escrowService.acceptParty(id, partyId, userId, ipAddress);
}

@Post(':id/parties/:partyId/reject')
@UseGuards(EscrowAccessGuard)
@ApiOperation({ summary: 'Reject an invitation to join an escrow' })
async rejectParty(
@Param('id') id: string,
@Param('partyId') partyId: string,
@Request() req: AuthenticatedRequest,
) {
const userId = this.getAuthenticatedUserId(req);
const ipAddress = req.ip || req.socket?.remoteAddress;
return this.escrowService.rejectParty(id, partyId, userId, ipAddress);
}

@Post(':id/fund')
@UseGuards(EscrowAccessGuard)
async fund(
Expand Down
42 changes: 42 additions & 0 deletions apps/backend/src/modules/escrow/services/escrow.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe('EscrowService', () => {
const mockPartyRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
};

const mockConditionRepo = {
Expand All @@ -128,6 +129,7 @@ describe('EscrowService', () => {
const mockEventRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
};

const mockDisputeRepo = {
Expand Down Expand Up @@ -249,5 +251,45 @@ describe('EscrowService', () => {
expect(service).toBeDefined();
});

describe('party acceptance flow', () => {
it('should accept an invitation', async () => {
escrowRepository.findOne.mockResolvedValue({ ...mockEscrow, status: EscrowStatus.PENDING } as any);
partyRepository.findOne.mockResolvedValue({ ...mockParty, id: 'party-1', userId: 'user-2', status: PartyStatus.PENDING } as any);
partyRepository.save.mockResolvedValue({} as any);

const result = await service.acceptParty('escrow-1', 'party-1', 'user-2');
expect(result.status).toBe(PartyStatus.ACCEPTED);
expect(partyRepository.save).toHaveBeenCalled();
});

it('should reject an invitation', async () => {
escrowRepository.findOne.mockResolvedValue({ ...mockEscrow, status: EscrowStatus.PENDING } as any);
partyRepository.findOne.mockResolvedValue({ ...mockParty, id: 'party-1', userId: 'user-2', status: PartyStatus.PENDING } as any);
partyRepository.save.mockResolvedValue({} as any);

const result = await service.rejectParty('escrow-1', 'party-1', 'user-2');
expect(result.status).toBe(PartyStatus.REJECTED);
expect(partyRepository.save).toHaveBeenCalled();
});

it('should fail to accept if not user', async () => {
escrowRepository.findOne.mockResolvedValue({ ...mockEscrow, status: EscrowStatus.PENDING } as any);
partyRepository.findOne.mockResolvedValue({ ...mockParty, id: 'party-1', userId: 'user-2' } as any);

await expect(service.acceptParty('escrow-1', 'party-1', 'user-3')).rejects.toThrow(ForbiddenException);
});

it('should fail to fund if parties not accepted', async () => {
escrowRepository.findOne.mockResolvedValue({
...mockEscrow,
creatorId: 'user-1',
status: EscrowStatus.PENDING,
parties: [{ status: PartyStatus.PENDING } as any]
} as any);

await expect(service.fund('escrow-1', { amount: 100 } as any, 'user-1', 'addr-1')).rejects.toThrow(BadRequestException);
});
});

// βœ… KEEP ALL YOUR EXISTING TESTS BELOW UNCHANGED
});
74 changes: 73 additions & 1 deletion apps/backend/src/modules/escrow/services/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
LessThan,
} from 'typeorm';
import { Escrow, EscrowStatus } from '../entities/escrow.entity';
import { Party, PartyRole } from '../entities/party.entity';
import { Party, PartyRole, PartyStatus } from '../entities/party.entity';
import { Condition } from '../entities/condition.entity';
import { EscrowEvent, EscrowEventType } from '../entities/escrow-event.entity';
import {
Expand Down Expand Up @@ -237,6 +237,7 @@ export class EscrowService {
escrowId: savedEscrow.id,
userId: partyDto.userId,
role: partyDto.role,
status: partyDto.userId === creatorId ? PartyStatus.ACCEPTED : PartyStatus.PENDING,
}),
);
await this.partyRepository.save(parties);
Expand Down Expand Up @@ -633,6 +634,11 @@ export class EscrowService {
throw new BadRequestException('Escrow is already funded');
}

const unacceptedParties = escrow.parties?.filter(p => p.status !== PartyStatus.ACCEPTED) || [];
if (unacceptedParties.length > 0) {
throw new BadRequestException('All parties must accept the invitation before the escrow can be funded');
}

const escrowAmount = Number(escrow.amount);
if (Number(dto.amount) !== escrowAmount) {
throw new BadRequestException('Amount must match the escrow amount');
Expand Down Expand Up @@ -671,6 +677,72 @@ export class EscrowService {
return this.findOne(id);
}

async acceptParty(escrowId: string, partyId: string, userId: string, ipAddress?: string): Promise<Party> {
const escrow = await this.findOne(escrowId);
if (escrow.status !== EscrowStatus.PENDING) {
throw new BadRequestException('Can only accept invitations for pending escrows');
}

const party = await this.partyRepository.findOne({ where: { id: partyId, escrowId } });
if (!party) {
throw new NotFoundException('Party not found');
}

if (party.userId !== userId) {
throw new ForbiddenException('You can only accept your own invitation');
}

if (party.status === PartyStatus.ACCEPTED) {
throw new ConflictException('Invitation already accepted');
}

party.status = PartyStatus.ACCEPTED;
await this.partyRepository.save(party);

await this.logEvent(
escrowId,
EscrowEventType.UPDATED,
userId,
{ action: 'PARTY_ACCEPTED', partyId, role: party.role },
ipAddress,
);

return party;
}

async rejectParty(escrowId: string, partyId: string, userId: string, ipAddress?: string): Promise<Party> {
const escrow = await this.findOne(escrowId);
if (escrow.status !== EscrowStatus.PENDING) {
throw new BadRequestException('Can only reject invitations for pending escrows');
}

const party = await this.partyRepository.findOne({ where: { id: partyId, escrowId } });
if (!party) {
throw new NotFoundException('Party not found');
}

if (party.userId !== userId) {
throw new ForbiddenException('You can only reject your own invitation');
}

if (party.status === PartyStatus.REJECTED) {
throw new ConflictException('Invitation already rejected');
}

party.status = PartyStatus.REJECTED;
await this.partyRepository.save(party);

await this.logEvent(
escrowId,
EscrowEventType.UPDATED,
userId,
{ action: 'PARTY_REJECTED', partyId, role: party.role },
ipAddress,
);

return party;
}

async isUserPartyToEscrow(
escrowId: string,
userId: string,
Expand Down
Loading
Loading