diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 0000000..5abf8ed --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-checkout "$@" diff --git a/.husky/post-commit b/.husky/post-commit new file mode 100755 index 0000000..b8b76c2 --- /dev/null +++ b/.husky/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-commit "$@" diff --git a/.husky/post-merge b/.husky/post-merge new file mode 100755 index 0000000..726f909 --- /dev/null +++ b/.husky/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-merge "$@" diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..5f26dc4 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs pre-push "$@" diff --git a/src/app.module.ts b/src/app.module.ts index d6aa1a5..9709aac 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,7 +15,7 @@ import { UsersModule } from './modules/users/users.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { OrdersModule } from './modules/orders/orders.module'; import { BuyerRequestsModule } from './modules/buyer-requests/buyer-requests.module'; -import { OffersModule } from './modules/offers/offers.module'; +import { OffersModule } from './src/modules/offers/offers.module'; import { SupabaseModule } from './modules/supabase/supabase.module'; // Entities @@ -36,6 +36,9 @@ import { CouponUsage } from './modules/coupons/entities/coupon-usage.entity'; import { BuyerRequest } from './modules/buyer-requests/entities/buyer-request.entity'; import { Offer } from './modules/offers/entities/offer.entity'; import { OfferAttachment } from './modules/offers/entities/offer-attachment.entity'; +import { Escrow } from './modules/escrows/entities/escrow.entity'; +import { Milestone } from './modules/escrows/entities/milestone.entity'; +import { EscrowsModule } from './modules/escrows/escrows.module'; @Module({ imports: [ @@ -63,6 +66,8 @@ import { OfferAttachment } from './modules/offers/entities/offer-attachment.enti BuyerRequest, Offer, OfferAttachment, + Escrow, + Milestone, ], synchronize: process.env.NODE_ENV !== 'production', logging: process.env.NODE_ENV === 'development', @@ -81,6 +86,7 @@ import { OfferAttachment } from './modules/offers/entities/offer-attachment.enti BuyerRequestsModule, OffersModule, SupabaseModule, + EscrowsModule, ], }) export class AppModule {} diff --git a/src/modules/escrows/controllers/escrow.controller.ts b/src/modules/escrows/controllers/escrow.controller.ts new file mode 100644 index 0000000..ca97672 --- /dev/null +++ b/src/modules/escrows/controllers/escrow.controller.ts @@ -0,0 +1,62 @@ +import { Controller, Patch, Param, UseGuards, Request, Body, Get, Query, Post } from '@nestjs/common'; +import { EscrowService } from '../services/escrow.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { Role } from '@/types/role'; +import { AuthRequest } from '@/modules/wishlist/common/types/auth-request.type'; +import { UpdateMilestoneStatusDto } from '../dto/update-milestone-status.dto'; +import { MilestoneStatus } from '../entities/milestone.entity'; +import { GetEscrowsQueryDto } from '../dto/get-escrows-query.dto'; +import { MultipleEscrowBalancesDto } from '../dto/multiple-balances.dto'; + +@Controller('escrows') +export class EscrowController { + constructor(private readonly escrowService: EscrowService) {} + + @Patch(':escrowId/milestones/:milestoneId/approve') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.BUYER) + approve( + @Param('escrowId') escrowId: string, + @Param('milestoneId') milestoneId: string, + @Request() req: AuthRequest + ) { + return this.escrowService.approveMilestone(escrowId, milestoneId, Number(req.user.id)); + } + + @Patch(':escrowId/milestones/:milestoneId/status') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.SELLER) + changeStatus( + @Param('escrowId') escrowId: string, + @Param('milestoneId') milestoneId: string, + @Body() body: UpdateMilestoneStatusDto, + @Request() req: AuthRequest + ) { + return this.escrowService.changeMilestoneStatus( + escrowId, + milestoneId, + Number(req.user.id), + body.status as MilestoneStatus + ); + } + + // GET /escrows?role=buyer|seller (defaults to both roles for signer) + @Get() + @UseGuards(JwtAuthGuard) + list(@Query() query: GetEscrowsQueryDto, @Request() req: AuthRequest) { + const userId = Number(req.user.id); + if (query.role) { + return this.escrowService.getEscrowsByRole(userId, query.role); + } + return this.escrowService.getEscrowsBySigner(userId); + } + + // POST /escrows/balances { ids: [...] } + @Post('balances') + @UseGuards(JwtAuthGuard) + balances(@Body() body: MultipleEscrowBalancesDto) { + return this.escrowService.getMultipleEscrowBalances(body.ids); + } +} diff --git a/src/modules/escrows/dto/approve-milestone.dto.ts b/src/modules/escrows/dto/approve-milestone.dto.ts new file mode 100644 index 0000000..a7ae9bb --- /dev/null +++ b/src/modules/escrows/dto/approve-milestone.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class ApproveMilestoneDto { + @IsOptional() + @IsString() + type?: string; // placeholder if future variations required +} diff --git a/src/modules/escrows/dto/get-escrows-query.dto.ts b/src/modules/escrows/dto/get-escrows-query.dto.ts new file mode 100644 index 0000000..02fae3a --- /dev/null +++ b/src/modules/escrows/dto/get-escrows-query.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsIn } from 'class-validator'; + +export class GetEscrowsQueryDto { + @IsOptional() + @IsIn(['buyer', 'seller']) + role?: 'buyer' | 'seller'; +} diff --git a/src/modules/escrows/dto/multiple-balances.dto.ts b/src/modules/escrows/dto/multiple-balances.dto.ts new file mode 100644 index 0000000..0cdfb55 --- /dev/null +++ b/src/modules/escrows/dto/multiple-balances.dto.ts @@ -0,0 +1,8 @@ +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class MultipleEscrowBalancesDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids: string[]; +} diff --git a/src/modules/escrows/dto/update-milestone-status.dto.ts b/src/modules/escrows/dto/update-milestone-status.dto.ts new file mode 100644 index 0000000..afe320b --- /dev/null +++ b/src/modules/escrows/dto/update-milestone-status.dto.ts @@ -0,0 +1,8 @@ +import { IsEnum } from 'class-validator'; +import { MilestoneStatus } from '../entities/milestone.entity'; + +// Seller-changeable statuses (not including APPROVED which is buyer action) +export class UpdateMilestoneStatusDto { + @IsEnum(MilestoneStatus, { message: 'Invalid milestone status' }) + status: MilestoneStatus; // Expect READY | IN_PROGRESS | DELIVERED +} diff --git a/src/modules/escrows/entities/escrow.entity.ts b/src/modules/escrows/entities/escrow.entity.ts new file mode 100644 index 0000000..fb464ff --- /dev/null +++ b/src/modules/escrows/entities/escrow.entity.ts @@ -0,0 +1,53 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn, JoinColumn, Check } from 'typeorm'; +import { Offer } from '../../offers/entities/offer.entity'; +import { User } from '../../users/entities/user.entity'; +import { Milestone } from './milestone.entity'; + +export enum EscrowStatus { + PENDING = 'pending', // Has milestones not yet approved + IN_PROGRESS = 'in_progress', // At least one milestone approved but not all + COMPLETED = 'completed', // All milestones approved +} + +@Entity('escrows') +@Check('"totalAmount" >= 0') +export class Escrow { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', name: 'offer_id' }) + offerId: string; + + @ManyToOne(() => Offer, { nullable: false }) + @JoinColumn({ name: 'offer_id' }) + offer: Offer; + + @Column({ name: 'buyer_id' }) + buyerId: number; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'buyer_id' }) + buyer: User; + + @Column({ name: 'seller_id' }) + sellerId: number; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'seller_id' }) + seller: User; + + @Column({ type: 'decimal', precision: 12, scale: 2, name: 'total_amount' }) + totalAmount: number; + + @Column({ type: 'enum', enum: EscrowStatus, default: EscrowStatus.PENDING }) + status: EscrowStatus; + + @OneToMany(() => Milestone, (m) => m.escrow, { cascade: true }) + milestones: Milestone[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/escrows/entities/milestone.entity.ts b/src/modules/escrows/entities/milestone.entity.ts new file mode 100644 index 0000000..67b48d8 --- /dev/null +++ b/src/modules/escrows/entities/milestone.entity.ts @@ -0,0 +1,48 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn, JoinColumn, Check } from 'typeorm'; +import { Escrow } from './escrow.entity'; + +export enum MilestoneStatus { + PENDING = 'pending', + APPROVED = 'approved', + READY = 'ready', // Seller marked as ready to start + IN_PROGRESS = 'in_progress', // Work is in progress + DELIVERED = 'delivered', // Seller delivered work for buyer approval +} + +@Entity('escrow_milestones') +@Check('"amount" >= 0') +export class Milestone { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', name: 'escrow_id' }) + escrowId: string; + + @ManyToOne(() => Escrow, (e) => e.milestones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'escrow_id' }) + escrow: Escrow; + + @Column({ type: 'int' }) + sequence: number; // order of milestone + + @Column({ length: 120 }) + title: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + amount: number; + + @Column({ type: 'enum', enum: MilestoneStatus, default: MilestoneStatus.PENDING }) + status: MilestoneStatus; + + @Column({ type: 'timestamp', name: 'approved_at', nullable: true }) + approvedAt?: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/escrows/escrows.module.ts b/src/modules/escrows/escrows.module.ts new file mode 100644 index 0000000..be61aef --- /dev/null +++ b/src/modules/escrows/escrows.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Escrow } from './entities/escrow.entity'; +import { Milestone } from './entities/milestone.entity'; +import { EscrowService } from './services/escrow.service'; +import { EscrowController } from './controllers/escrow.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Escrow, Milestone])], + controllers: [EscrowController], + providers: [EscrowService], + exports: [EscrowService], +}) +export class EscrowsModule {} diff --git a/src/modules/escrows/migrations/1752190000000-ExtendMilestoneStatusEnum.ts b/src/modules/escrows/migrations/1752190000000-ExtendMilestoneStatusEnum.ts new file mode 100644 index 0000000..101b689 --- /dev/null +++ b/src/modules/escrows/migrations/1752190000000-ExtendMilestoneStatusEnum.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ExtendMilestoneStatusEnum1752190000000 implements MigrationInterface { + name = 'ExtendMilestoneStatusEnum1752190000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Postgres enum alteration strategy: rename old type, create new, alter column, drop old + await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved','ready','in_progress','delivered')`); + await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`); + await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" SET DEFAULT 'pending'`); + await queryRunner.query(`DROP TYPE "public"."escrow_milestones_status_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved')`); + await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`); + await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" SET DEFAULT 'pending'`); + await queryRunner.query(`DROP TYPE "public"."escrow_milestones_status_enum_old"`); + } +} diff --git a/src/modules/escrows/services/escrow.service.spec.ts b/src/modules/escrows/services/escrow.service.spec.ts new file mode 100644 index 0000000..46b2ee0 --- /dev/null +++ b/src/modules/escrows/services/escrow.service.spec.ts @@ -0,0 +1,93 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EscrowService } from './escrow.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { Escrow } from '../entities/escrow.entity'; +import { Milestone, MilestoneStatus } from '../entities/milestone.entity'; +import { ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common'; + +// Simple in-memory mocks +class MockRepo { + private entities: T[] = []; + findOne = jest.fn(async (options: any) => { + if (options.where?.id) return this.entities.find(e => e.id === options.where.id) || null; + if (options.where?.id && options.relations) return this.entities.find(e => e.id === options.where.id) || null; + return null; + }); + find = jest.fn(async (options: any) => this.entities.filter(e => (options.where?.escrowId ? (e as any).escrowId === options.where.escrowId : true))); + save = jest.fn(async (entity: T) => { + const existingIndex = this.entities.findIndex(e => e.id === entity.id); + if (existingIndex >= 0) this.entities[existingIndex] = entity; + else this.entities.push(entity); + return entity; + }); + seed(data: T[]) { this.entities = data; } +} + +const mockTransaction = (cb: any) => cb({ + findOne: (entity: any, opts: any) => entity === Milestone ? milestoneRepo.findOne(opts) : escrowRepo.findOne(opts), + find: (entity: any, opts: any) => milestoneRepo.find(opts), + save: (entity: any) => Array.isArray(entity) ? Promise.all(entity.map(e => (e instanceof Milestone ? milestoneRepo.save(e) : escrowRepo.save(e)))) : (entity instanceof Milestone ? milestoneRepo.save(entity) : escrowRepo.save(entity)) +}); + +let escrowRepo: MockRepo; +let milestoneRepo: MockRepo; + +describe('EscrowService - changeMilestoneStatus', () => { + let service: EscrowService; + + beforeEach(async () => { + escrowRepo = new MockRepo(); + milestoneRepo = new MockRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EscrowService, + { provide: getRepositoryToken(Escrow), useValue: escrowRepo }, + { provide: getRepositoryToken(Milestone), useValue: milestoneRepo }, + { provide: DataSource, useValue: { transaction: jest.fn(mockTransaction) } }, + ], + }).compile(); + + service = module.get(EscrowService); + + // Seed data + escrowRepo.seed([{ id: 'escrow1', sellerId: 10, buyerId: 20 } as any]); + milestoneRepo.seed([ + { id: 'm1', escrowId: 'escrow1', status: MilestoneStatus.PENDING } as any, + ]); + }); + + it('should change status from pending to ready by seller', async () => { + const result = await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY); + expect(result.status).toBe(MilestoneStatus.READY); + }); + + it('should block non-seller from changing status', async () => { + await expect( + service.changeMilestoneStatus('escrow1', 'm1', 999, MilestoneStatus.READY) + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('should block backwards transition', async () => { + await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY); + await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.IN_PROGRESS); + await expect( + service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should be idempotent if same status provided', async () => { + await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY); + const result = await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY); + expect(result.status).toBe(MilestoneStatus.READY); + }); + + it('should not allow change after approval', async () => { + // Simulate approved milestone + milestoneRepo.seed([{ id: 'm1', escrowId: 'escrow1', status: MilestoneStatus.APPROVED } as any]); + await expect( + service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.DELIVERED) + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/src/modules/escrows/services/escrow.service.ts b/src/modules/escrows/services/escrow.service.ts new file mode 100644 index 0000000..872bb31 --- /dev/null +++ b/src/modules/escrows/services/escrow.service.ts @@ -0,0 +1,151 @@ +import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { Escrow, EscrowStatus } from '../entities/escrow.entity'; +import { Milestone, MilestoneStatus } from '../entities/milestone.entity'; + +@Injectable() +export class EscrowService { + constructor( + @InjectRepository(Escrow) private readonly escrowRepo: Repository, + @InjectRepository(Milestone) private readonly milestoneRepo: Repository, + private readonly dataSource: DataSource + ) {} + + /** + * Get escrows for a given signer (user) regardless of role (buyer or seller). + * Includes calculated remaining + released amounts derived from milestones. + */ + async getEscrowsBySigner(userId: number): Promise<(Escrow & { releasedAmount: number; remainingAmount: number })[]> { + const escrows = await this.escrowRepo.find({ + where: [{ buyerId: userId }, { sellerId: userId }], + relations: ['milestones'], + order: { createdAt: 'DESC' }, + }); + return escrows.map((e) => this.withComputedAmounts(e)); + } + + /** + * Get escrows by role (buyer | seller) for a given user id. + */ + async getEscrowsByRole(userId: number, role: 'buyer' | 'seller'): Promise<(Escrow & { releasedAmount: number; remainingAmount: number })[]> { + const where = role === 'buyer' ? { buyerId: userId } : { sellerId: userId }; + const escrows = await this.escrowRepo.find({ where, relations: ['milestones'], order: { createdAt: 'DESC' } }); + return escrows.map((e) => this.withComputedAmounts(e)); + } + + /** + * Get balances for multiple escrow ids (used for polling). Returns map keyed by escrow id. + */ + async getMultipleEscrowBalances(ids: string[]): Promise> { + if (!ids.length) return {}; + const escrows = await this.escrowRepo.find({ where: ids.map((id) => ({ id })), relations: ['milestones'] }); + return escrows.reduce((acc, e) => { + const computed = this.computeAmounts(e); + acc[e.id] = { ...computed, status: e.status }; + return acc; + }, {} as Record); + } + + private computeAmounts(escrow: Escrow) { + const total = Number(escrow.totalAmount) || 0; + const released = (escrow.milestones || []) + .filter((m) => m.status === MilestoneStatus.APPROVED) + .reduce((sum, m) => sum + Number(m.amount), 0); + const remaining = Math.max(total - released, 0); + return { releasedAmount: released, remainingAmount: remaining }; + } + + private withComputedAmounts(escrow: Escrow): Escrow & { releasedAmount: number; remainingAmount: number } { + return Object.assign(escrow, this.computeAmounts(escrow)); + } + + async approveMilestone(escrowId: string, milestoneId: string, userId: number): Promise { + return this.dataSource.transaction(async (manager) => { + const milestone = await manager.findOne(Milestone, { where: { id: milestoneId }, relations: ['escrow'] }); + if (!milestone) throw new NotFoundException('Milestone not found'); + if (milestone.escrowId !== escrowId) throw new BadRequestException('Milestone does not belong to escrow'); + + const escrow = await manager.findOne(Escrow, { where: { id: escrowId } }); + if (!escrow) throw new NotFoundException('Escrow not found'); + + if (escrow.buyerId !== userId) { + throw new ForbiddenException('Only the buyer can approve milestones'); + } + + if (milestone.status === MilestoneStatus.APPROVED) { + throw new BadRequestException('Milestone already approved'); + } + + milestone.status = MilestoneStatus.APPROVED; + milestone.approvedAt = new Date(); + await manager.save(milestone); + + // Update escrow status based on milestones + const milestones = await manager.find(Milestone, { where: { escrowId } }); + const approvedCount = milestones.filter((m) => m.status === MilestoneStatus.APPROVED).length; + if (approvedCount === milestones.length) { + escrow.status = EscrowStatus.COMPLETED; + } else if (approvedCount > 0) { + escrow.status = EscrowStatus.IN_PROGRESS; + } + await manager.save(escrow); + + return milestone; + }); + } + + /** + * Seller changes milestone execution status (ready -> in_progress -> delivered). + * Constraints: + * - Only seller of escrow can change + * - Cannot change if milestone already approved by buyer + * - Only allowed transitions among READY, IN_PROGRESS, DELIVERED (no skipping backwards) + */ + async changeMilestoneStatus( + escrowId: string, + milestoneId: string, + sellerId: number, + nextStatus: MilestoneStatus + ): Promise { + const allowed: MilestoneStatus[] = [ + MilestoneStatus.READY, + MilestoneStatus.IN_PROGRESS, + MilestoneStatus.DELIVERED, + ]; + if (!allowed.includes(nextStatus)) { + throw new BadRequestException('Status not changeable by seller'); + } + + return this.dataSource.transaction(async (manager) => { + const milestone = await manager.findOne(Milestone, { where: { id: milestoneId }, relations: ['escrow'] }); + if (!milestone) throw new NotFoundException('Milestone not found'); + if (milestone.escrowId !== escrowId) throw new BadRequestException('Milestone does not belong to escrow'); + const escrow = await manager.findOne(Escrow, { where: { id: escrowId } }); + if (!escrow) throw new NotFoundException('Escrow not found'); + if (escrow.sellerId !== sellerId) throw new ForbiddenException('Only the seller can change milestone status'); + if (milestone.status === MilestoneStatus.APPROVED) throw new BadRequestException('Milestone already approved'); + + // Prevent status regression (simple linear order) and disallow skipping forward beyond delivered + const order: Record = { + [MilestoneStatus.PENDING]: 0, + [MilestoneStatus.READY]: 1, + [MilestoneStatus.IN_PROGRESS]: 2, + [MilestoneStatus.DELIVERED]: 3, + [MilestoneStatus.APPROVED]: 4, + } as any; + const currentOrder = order[milestone.status]; + const nextOrder = order[nextStatus]; + if (nextOrder < currentOrder) { + throw new BadRequestException('Cannot move milestone status backwards'); + } + if (currentOrder === nextOrder) { + return milestone; // idempotent + } + + milestone.status = nextStatus; + await manager.save(milestone); + return milestone; + }); + } +} diff --git a/src/modules/files/entities/file.entity.ts b/src/modules/files/entities/file.entity.ts index ead6f5a..1cfc3ff 100644 --- a/src/modules/files/entities/file.entity.ts +++ b/src/modules/files/entities/file.entity.ts @@ -6,7 +6,7 @@ import { ManyToOne, JoinColumn, } from 'typeorm'; -import { User } from '../../../modules/users/entities/user.entity'; +import { User } from '../../users/entities/user.entity'; export enum FileType { IMAGE = 'IMAGE', diff --git a/src/dtos/ProductVariantAttributeDTO.ts b/src/src/dtos/ProductVariantAttributeDTO.ts similarity index 100% rename from src/dtos/ProductVariantAttributeDTO.ts rename to src/src/dtos/ProductVariantAttributeDTO.ts diff --git a/src/dtos/ProductVariantDTO.ts b/src/src/dtos/ProductVariantDTO.ts similarity index 100% rename from src/dtos/ProductVariantDTO.ts rename to src/src/dtos/ProductVariantDTO.ts diff --git a/src/dtos/UserDTO.ts b/src/src/dtos/UserDTO.ts similarity index 100% rename from src/dtos/UserDTO.ts rename to src/src/dtos/UserDTO.ts diff --git a/src/middleware/async-handler.ts b/src/src/middleware/async-handler.ts similarity index 100% rename from src/middleware/async-handler.ts rename to src/src/middleware/async-handler.ts diff --git a/src/middleware/async.middleware.ts b/src/src/middleware/async.middleware.ts similarity index 100% rename from src/middleware/async.middleware.ts rename to src/src/middleware/async.middleware.ts diff --git a/src/middleware/auth.middleware.ts b/src/src/middleware/auth.middleware.ts similarity index 100% rename from src/middleware/auth.middleware.ts rename to src/src/middleware/auth.middleware.ts diff --git a/src/middleware/error.classes.ts b/src/src/middleware/error.classes.ts similarity index 100% rename from src/middleware/error.classes.ts rename to src/src/middleware/error.classes.ts diff --git a/src/middleware/paramValidation.middleware.ts b/src/src/middleware/paramValidation.middleware.ts similarity index 100% rename from src/middleware/paramValidation.middleware.ts rename to src/src/middleware/paramValidation.middleware.ts diff --git a/src/middleware/ratelimiter.middleware.ts b/src/src/middleware/ratelimiter.middleware.ts similarity index 100% rename from src/middleware/ratelimiter.middleware.ts rename to src/src/middleware/ratelimiter.middleware.ts diff --git a/src/middleware/session.middleware.ts b/src/src/middleware/session.middleware.ts similarity index 100% rename from src/middleware/session.middleware.ts rename to src/src/middleware/session.middleware.ts diff --git a/src/middleware/userValidation.middleware.ts b/src/src/middleware/userValidation.middleware.ts similarity index 100% rename from src/middleware/userValidation.middleware.ts rename to src/src/middleware/userValidation.middleware.ts diff --git a/src/middleware/validateRequest.middleware.ts b/src/src/middleware/validateRequest.middleware.ts similarity index 100% rename from src/middleware/validateRequest.middleware.ts rename to src/src/middleware/validateRequest.middleware.ts diff --git a/src/middleware/validation.middleware.ts b/src/src/middleware/validation.middleware.ts similarity index 100% rename from src/middleware/validation.middleware.ts rename to src/src/middleware/validation.middleware.ts diff --git a/src/migrations/1734140974017-CreateUserTable.ts b/src/src/migrations/1734140974017-CreateUserTable.ts similarity index 100% rename from src/migrations/1734140974017-CreateUserTable.ts rename to src/src/migrations/1734140974017-CreateUserTable.ts diff --git a/src/migrations/1739140974017-CreateBuyerRequestTable.ts b/src/src/migrations/1739140974017-CreateBuyerRequestTable.ts similarity index 100% rename from src/migrations/1739140974017-CreateBuyerRequestTable.ts rename to src/src/migrations/1739140974017-CreateBuyerRequestTable.ts diff --git a/src/migrations/1746193420633-CreateOrders.ts b/src/src/migrations/1746193420633-CreateOrders.ts similarity index 100% rename from src/migrations/1746193420633-CreateOrders.ts rename to src/src/migrations/1746193420633-CreateOrders.ts diff --git a/src/migrations/1746193500000-AddBuyerRequestsSearchAndFilters.ts b/src/src/migrations/1746193500000-AddBuyerRequestsSearchAndFilters.ts similarity index 100% rename from src/migrations/1746193500000-AddBuyerRequestsSearchAndFilters.ts rename to src/src/migrations/1746193500000-AddBuyerRequestsSearchAndFilters.ts diff --git a/src/migrations/1746193600000-CreateOfferAttachments.ts b/src/src/migrations/1746193600000-CreateOfferAttachments.ts similarity index 100% rename from src/migrations/1746193600000-CreateOfferAttachments.ts rename to src/src/migrations/1746193600000-CreateOfferAttachments.ts diff --git a/src/migrations/1751199236860-CreateOfferTable.ts b/src/src/migrations/1751199236860-CreateOfferTable.ts similarity index 100% rename from src/migrations/1751199236860-CreateOfferTable.ts rename to src/src/migrations/1751199236860-CreateOfferTable.ts diff --git a/src/modules/attributes/attributes.module.ts b/src/src/modules/attributes/attributes.module.ts similarity index 100% rename from src/modules/attributes/attributes.module.ts rename to src/src/modules/attributes/attributes.module.ts diff --git a/src/modules/attributes/controllers/attributes.controller.spec.ts b/src/src/modules/attributes/controllers/attributes.controller.spec.ts similarity index 100% rename from src/modules/attributes/controllers/attributes.controller.spec.ts rename to src/src/modules/attributes/controllers/attributes.controller.spec.ts diff --git a/src/modules/attributes/controllers/attributes.controller.ts b/src/src/modules/attributes/controllers/attributes.controller.ts similarity index 100% rename from src/modules/attributes/controllers/attributes.controller.ts rename to src/src/modules/attributes/controllers/attributes.controller.ts diff --git a/src/modules/attributes/dto/attribute-response.dto.ts b/src/src/modules/attributes/dto/attribute-response.dto.ts similarity index 100% rename from src/modules/attributes/dto/attribute-response.dto.ts rename to src/src/modules/attributes/dto/attribute-response.dto.ts diff --git a/src/modules/attributes/dto/create-attribute.dto.ts b/src/src/modules/attributes/dto/create-attribute.dto.ts similarity index 100% rename from src/modules/attributes/dto/create-attribute.dto.ts rename to src/src/modules/attributes/dto/create-attribute.dto.ts diff --git a/src/modules/attributes/dto/get-attributes-query.dto.ts b/src/src/modules/attributes/dto/get-attributes-query.dto.ts similarity index 100% rename from src/modules/attributes/dto/get-attributes-query.dto.ts rename to src/src/modules/attributes/dto/get-attributes-query.dto.ts diff --git a/src/modules/attributes/dto/update-attribute.dto.ts b/src/src/modules/attributes/dto/update-attribute.dto.ts similarity index 100% rename from src/modules/attributes/dto/update-attribute.dto.ts rename to src/src/modules/attributes/dto/update-attribute.dto.ts diff --git a/src/modules/attributes/entities/attribute-value.entity.ts b/src/src/modules/attributes/entities/attribute-value.entity.ts similarity index 100% rename from src/modules/attributes/entities/attribute-value.entity.ts rename to src/src/modules/attributes/entities/attribute-value.entity.ts diff --git a/src/modules/attributes/entities/attribute.entity.ts b/src/src/modules/attributes/entities/attribute.entity.ts similarity index 100% rename from src/modules/attributes/entities/attribute.entity.ts rename to src/src/modules/attributes/entities/attribute.entity.ts diff --git a/src/modules/attributes/services/attributes.service.spec.ts b/src/src/modules/attributes/services/attributes.service.spec.ts similarity index 100% rename from src/modules/attributes/services/attributes.service.spec.ts rename to src/src/modules/attributes/services/attributes.service.spec.ts diff --git a/src/modules/attributes/services/attributes.service.ts b/src/src/modules/attributes/services/attributes.service.ts similarity index 100% rename from src/modules/attributes/services/attributes.service.ts rename to src/src/modules/attributes/services/attributes.service.ts diff --git a/src/modules/buyer-requests/buyer-requests.module.ts b/src/src/modules/buyer-requests/buyer-requests.module.ts similarity index 100% rename from src/modules/buyer-requests/buyer-requests.module.ts rename to src/src/modules/buyer-requests/buyer-requests.module.ts diff --git a/src/modules/buyer-requests/controllers/buyer-requests.controller.ts b/src/src/modules/buyer-requests/controllers/buyer-requests.controller.ts similarity index 100% rename from src/modules/buyer-requests/controllers/buyer-requests.controller.ts rename to src/src/modules/buyer-requests/controllers/buyer-requests.controller.ts diff --git a/src/modules/buyer-requests/dto/buyer-request-response.dto.ts b/src/src/modules/buyer-requests/dto/buyer-request-response.dto.ts similarity index 100% rename from src/modules/buyer-requests/dto/buyer-request-response.dto.ts rename to src/src/modules/buyer-requests/dto/buyer-request-response.dto.ts diff --git a/src/modules/buyer-requests/dto/create-buyer-request.dto.ts b/src/src/modules/buyer-requests/dto/create-buyer-request.dto.ts similarity index 100% rename from src/modules/buyer-requests/dto/create-buyer-request.dto.ts rename to src/src/modules/buyer-requests/dto/create-buyer-request.dto.ts diff --git a/src/modules/buyer-requests/dto/get-buyer-requests-query.dto.ts b/src/src/modules/buyer-requests/dto/get-buyer-requests-query.dto.ts similarity index 100% rename from src/modules/buyer-requests/dto/get-buyer-requests-query.dto.ts rename to src/src/modules/buyer-requests/dto/get-buyer-requests-query.dto.ts diff --git a/src/modules/buyer-requests/dto/update-buyer-request.dto.ts b/src/src/modules/buyer-requests/dto/update-buyer-request.dto.ts similarity index 100% rename from src/modules/buyer-requests/dto/update-buyer-request.dto.ts rename to src/src/modules/buyer-requests/dto/update-buyer-request.dto.ts diff --git a/src/modules/buyer-requests/entities/buyer-request.entity.ts b/src/src/modules/buyer-requests/entities/buyer-request.entity.ts similarity index 100% rename from src/modules/buyer-requests/entities/buyer-request.entity.ts rename to src/src/modules/buyer-requests/entities/buyer-request.entity.ts diff --git a/src/modules/buyer-requests/services/buyer-request-scheduler.service.ts b/src/src/modules/buyer-requests/services/buyer-request-scheduler.service.ts similarity index 100% rename from src/modules/buyer-requests/services/buyer-request-scheduler.service.ts rename to src/src/modules/buyer-requests/services/buyer-request-scheduler.service.ts diff --git a/src/modules/buyer-requests/services/buyer-requests.service.ts b/src/src/modules/buyer-requests/services/buyer-requests.service.ts similarity index 100% rename from src/modules/buyer-requests/services/buyer-requests.service.ts rename to src/src/modules/buyer-requests/services/buyer-requests.service.ts diff --git a/src/modules/buyer-requests/tests/buyer-request-scheduler.service.spec.ts b/src/src/modules/buyer-requests/tests/buyer-request-scheduler.service.spec.ts similarity index 100% rename from src/modules/buyer-requests/tests/buyer-request-scheduler.service.spec.ts rename to src/src/modules/buyer-requests/tests/buyer-request-scheduler.service.spec.ts diff --git a/src/modules/buyer-requests/tests/buyer-requests.controller.spec.ts b/src/src/modules/buyer-requests/tests/buyer-requests.controller.spec.ts similarity index 100% rename from src/modules/buyer-requests/tests/buyer-requests.controller.spec.ts rename to src/src/modules/buyer-requests/tests/buyer-requests.controller.spec.ts diff --git a/src/modules/buyer-requests/tests/buyer-requests.integration.spec.ts b/src/src/modules/buyer-requests/tests/buyer-requests.integration.spec.ts similarity index 100% rename from src/modules/buyer-requests/tests/buyer-requests.integration.spec.ts rename to src/src/modules/buyer-requests/tests/buyer-requests.integration.spec.ts diff --git a/src/modules/buyer-requests/tests/buyer-requests.service.spec.ts b/src/src/modules/buyer-requests/tests/buyer-requests.service.spec.ts similarity index 100% rename from src/modules/buyer-requests/tests/buyer-requests.service.spec.ts rename to src/src/modules/buyer-requests/tests/buyer-requests.service.spec.ts diff --git a/src/modules/cart/controllers/cart.controller.ts b/src/src/modules/cart/controllers/cart.controller.ts similarity index 100% rename from src/modules/cart/controllers/cart.controller.ts rename to src/src/modules/cart/controllers/cart.controller.ts diff --git a/src/modules/cart/dtos/cart.dto.ts b/src/src/modules/cart/dtos/cart.dto.ts similarity index 100% rename from src/modules/cart/dtos/cart.dto.ts rename to src/src/modules/cart/dtos/cart.dto.ts diff --git a/src/modules/cart/entities/cart-item.entity.ts b/src/src/modules/cart/entities/cart-item.entity.ts similarity index 100% rename from src/modules/cart/entities/cart-item.entity.ts rename to src/src/modules/cart/entities/cart-item.entity.ts diff --git a/src/modules/cart/entities/cart.entity.ts b/src/src/modules/cart/entities/cart.entity.ts similarity index 100% rename from src/modules/cart/entities/cart.entity.ts rename to src/src/modules/cart/entities/cart.entity.ts diff --git a/src/modules/cart/routes/cart.routes.ts b/src/src/modules/cart/routes/cart.routes.ts similarity index 100% rename from src/modules/cart/routes/cart.routes.ts rename to src/src/modules/cart/routes/cart.routes.ts diff --git a/src/modules/cart/services/cart.service.ts b/src/src/modules/cart/services/cart.service.ts similarity index 100% rename from src/modules/cart/services/cart.service.ts rename to src/src/modules/cart/services/cart.service.ts diff --git a/src/modules/category/category.entity.ts b/src/src/modules/category/category.entity.ts similarity index 100% rename from src/modules/category/category.entity.ts rename to src/src/modules/category/category.entity.ts diff --git a/src/modules/coupons/README.md b/src/src/modules/coupons/README.md similarity index 100% rename from src/modules/coupons/README.md rename to src/src/modules/coupons/README.md diff --git a/src/modules/coupons/controllers/coupon.controller.ts b/src/src/modules/coupons/controllers/coupon.controller.ts similarity index 100% rename from src/modules/coupons/controllers/coupon.controller.ts rename to src/src/modules/coupons/controllers/coupon.controller.ts diff --git a/src/modules/coupons/coupon.module.ts b/src/src/modules/coupons/coupon.module.ts similarity index 100% rename from src/modules/coupons/coupon.module.ts rename to src/src/modules/coupons/coupon.module.ts diff --git a/src/modules/coupons/dto/coupon.dto.ts b/src/src/modules/coupons/dto/coupon.dto.ts similarity index 100% rename from src/modules/coupons/dto/coupon.dto.ts rename to src/src/modules/coupons/dto/coupon.dto.ts diff --git a/src/modules/coupons/dtos/create-coupon.dto.ts b/src/src/modules/coupons/dtos/create-coupon.dto.ts similarity index 100% rename from src/modules/coupons/dtos/create-coupon.dto.ts rename to src/src/modules/coupons/dtos/create-coupon.dto.ts diff --git a/src/modules/coupons/dtos/update-coupon.dto.ts b/src/src/modules/coupons/dtos/update-coupon.dto.ts similarity index 100% rename from src/modules/coupons/dtos/update-coupon.dto.ts rename to src/src/modules/coupons/dtos/update-coupon.dto.ts diff --git a/src/modules/coupons/dtos/validate-coupon.dto.ts b/src/src/modules/coupons/dtos/validate-coupon.dto.ts similarity index 100% rename from src/modules/coupons/dtos/validate-coupon.dto.ts rename to src/src/modules/coupons/dtos/validate-coupon.dto.ts diff --git a/src/modules/coupons/entities/coupon-usage.entity.ts b/src/src/modules/coupons/entities/coupon-usage.entity.ts similarity index 100% rename from src/modules/coupons/entities/coupon-usage.entity.ts rename to src/src/modules/coupons/entities/coupon-usage.entity.ts diff --git a/src/modules/coupons/entities/coupon.entity.ts b/src/src/modules/coupons/entities/coupon.entity.ts similarity index 100% rename from src/modules/coupons/entities/coupon.entity.ts rename to src/src/modules/coupons/entities/coupon.entity.ts diff --git a/src/modules/coupons/services/coupon.service.ts b/src/src/modules/coupons/services/coupon.service.ts similarity index 100% rename from src/modules/coupons/services/coupon.service.ts rename to src/src/modules/coupons/services/coupon.service.ts diff --git a/src/modules/coupons/tests/coupon.controller.spec.ts b/src/src/modules/coupons/tests/coupon.controller.spec.ts similarity index 100% rename from src/modules/coupons/tests/coupon.controller.spec.ts rename to src/src/modules/coupons/tests/coupon.controller.spec.ts diff --git a/src/modules/coupons/tests/coupon.integration.spec.ts b/src/src/modules/coupons/tests/coupon.integration.spec.ts similarity index 100% rename from src/modules/coupons/tests/coupon.integration.spec.ts rename to src/src/modules/coupons/tests/coupon.integration.spec.ts diff --git a/src/modules/coupons/tests/coupon.service.spec.ts b/src/src/modules/coupons/tests/coupon.service.spec.ts similarity index 100% rename from src/modules/coupons/tests/coupon.service.spec.ts rename to src/src/modules/coupons/tests/coupon.service.spec.ts diff --git a/src/modules/notifications/config/pusher.config.ts b/src/src/modules/notifications/config/pusher.config.ts similarity index 100% rename from src/modules/notifications/config/pusher.config.ts rename to src/src/modules/notifications/config/pusher.config.ts diff --git a/src/modules/notifications/controllers/notification.controller.ts b/src/src/modules/notifications/controllers/notification.controller.ts similarity index 100% rename from src/modules/notifications/controllers/notification.controller.ts rename to src/src/modules/notifications/controllers/notification.controller.ts diff --git a/src/modules/notifications/dto/notification.dto.ts b/src/src/modules/notifications/dto/notification.dto.ts similarity index 100% rename from src/modules/notifications/dto/notification.dto.ts rename to src/src/modules/notifications/dto/notification.dto.ts diff --git a/src/modules/notifications/entities/notification.entity.ts b/src/src/modules/notifications/entities/notification.entity.ts similarity index 100% rename from src/modules/notifications/entities/notification.entity.ts rename to src/src/modules/notifications/entities/notification.entity.ts diff --git a/src/modules/notifications/notifications.module.ts b/src/src/modules/notifications/notifications.module.ts similarity index 100% rename from src/modules/notifications/notifications.module.ts rename to src/src/modules/notifications/notifications.module.ts diff --git a/src/modules/notifications/services/notification.service.ts b/src/src/modules/notifications/services/notification.service.ts similarity index 100% rename from src/modules/notifications/services/notification.service.ts rename to src/src/modules/notifications/services/notification.service.ts diff --git a/src/modules/notifications/tests/mocks/config.mock.ts b/src/src/modules/notifications/tests/mocks/config.mock.ts similarity index 100% rename from src/modules/notifications/tests/mocks/config.mock.ts rename to src/src/modules/notifications/tests/mocks/config.mock.ts diff --git a/src/modules/notifications/tests/mocks/pusher.mock.ts b/src/src/modules/notifications/tests/mocks/pusher.mock.ts similarity index 100% rename from src/modules/notifications/tests/mocks/pusher.mock.ts rename to src/src/modules/notifications/tests/mocks/pusher.mock.ts diff --git a/src/modules/notifications/tests/notification.service.spec.ts b/src/src/modules/notifications/tests/notification.service.spec.ts similarity index 100% rename from src/modules/notifications/tests/notification.service.spec.ts rename to src/src/modules/notifications/tests/notification.service.spec.ts diff --git a/src/modules/offers/controllers/offers.controller.ts b/src/src/modules/offers/controllers/offers.controller.ts similarity index 100% rename from src/modules/offers/controllers/offers.controller.ts rename to src/src/modules/offers/controllers/offers.controller.ts diff --git a/src/modules/offers/controllers/offersAdmin.controller.ts b/src/src/modules/offers/controllers/offersAdmin.controller.ts similarity index 100% rename from src/modules/offers/controllers/offersAdmin.controller.ts rename to src/src/modules/offers/controllers/offersAdmin.controller.ts diff --git a/src/modules/offers/dto/block-offer.dto.ts b/src/src/modules/offers/dto/block-offer.dto.ts similarity index 100% rename from src/modules/offers/dto/block-offer.dto.ts rename to src/src/modules/offers/dto/block-offer.dto.ts diff --git a/src/modules/offers/dto/create-offer.dto.ts b/src/src/modules/offers/dto/create-offer.dto.ts similarity index 100% rename from src/modules/offers/dto/create-offer.dto.ts rename to src/src/modules/offers/dto/create-offer.dto.ts diff --git a/src/modules/offers/dto/get-offers-query.dto.ts b/src/src/modules/offers/dto/get-offers-query.dto.ts similarity index 100% rename from src/modules/offers/dto/get-offers-query.dto.ts rename to src/src/modules/offers/dto/get-offers-query.dto.ts diff --git a/src/modules/offers/dto/offer-attachment-response.dto.ts b/src/src/modules/offers/dto/offer-attachment-response.dto.ts similarity index 100% rename from src/modules/offers/dto/offer-attachment-response.dto.ts rename to src/src/modules/offers/dto/offer-attachment-response.dto.ts diff --git a/src/modules/offers/dto/update-offer.dto.ts b/src/src/modules/offers/dto/update-offer.dto.ts similarity index 100% rename from src/modules/offers/dto/update-offer.dto.ts rename to src/src/modules/offers/dto/update-offer.dto.ts diff --git a/src/modules/offers/dto/upload-attachment.dto.ts b/src/src/modules/offers/dto/upload-attachment.dto.ts similarity index 100% rename from src/modules/offers/dto/upload-attachment.dto.ts rename to src/src/modules/offers/dto/upload-attachment.dto.ts diff --git a/src/modules/offers/entities/offer-attachment.entity.ts b/src/src/modules/offers/entities/offer-attachment.entity.ts similarity index 100% rename from src/modules/offers/entities/offer-attachment.entity.ts rename to src/src/modules/offers/entities/offer-attachment.entity.ts diff --git a/src/modules/offers/entities/offer.entity.ts b/src/src/modules/offers/entities/offer.entity.ts similarity index 100% rename from src/modules/offers/entities/offer.entity.ts rename to src/src/modules/offers/entities/offer.entity.ts diff --git a/src/modules/offers/enums/offer-status.enum.ts b/src/src/modules/offers/enums/offer-status.enum.ts similarity index 100% rename from src/modules/offers/enums/offer-status.enum.ts rename to src/src/modules/offers/enums/offer-status.enum.ts diff --git a/src/modules/offers/offer.module.ts b/src/src/modules/offers/offer.module.ts similarity index 100% rename from src/modules/offers/offer.module.ts rename to src/src/modules/offers/offer.module.ts diff --git a/src/modules/offers/offers.module.ts b/src/src/modules/offers/offers.module.ts similarity index 100% rename from src/modules/offers/offers.module.ts rename to src/src/modules/offers/offers.module.ts diff --git a/src/modules/offers/offfer.controller.ts b/src/src/modules/offers/offfer.controller.ts similarity index 100% rename from src/modules/offers/offfer.controller.ts rename to src/src/modules/offers/offfer.controller.ts diff --git a/src/modules/offers/services/offer-attachment.service.ts b/src/src/modules/offers/services/offer-attachment.service.ts similarity index 100% rename from src/modules/offers/services/offer-attachment.service.ts rename to src/src/modules/offers/services/offer-attachment.service.ts diff --git a/src/modules/offers/services/offer.service.ts b/src/src/modules/offers/services/offer.service.ts similarity index 100% rename from src/modules/offers/services/offer.service.ts rename to src/src/modules/offers/services/offer.service.ts diff --git a/src/modules/offers/services/offers.service.ts b/src/src/modules/offers/services/offers.service.ts similarity index 100% rename from src/modules/offers/services/offers.service.ts rename to src/src/modules/offers/services/offers.service.ts diff --git a/src/modules/offers/services/offersAdmin.service.ts b/src/src/modules/offers/services/offersAdmin.service.ts similarity index 100% rename from src/modules/offers/services/offersAdmin.service.ts rename to src/src/modules/offers/services/offersAdmin.service.ts diff --git a/src/modules/offers/tests/offer-attachment.service.spec.ts b/src/src/modules/offers/tests/offer-attachment.service.spec.ts similarity index 100% rename from src/modules/offers/tests/offer-attachment.service.spec.ts rename to src/src/modules/offers/tests/offer-attachment.service.spec.ts diff --git a/src/modules/offers/tests/offer.entity.spec.ts b/src/src/modules/offers/tests/offer.entity.spec.ts similarity index 100% rename from src/modules/offers/tests/offer.entity.spec.ts rename to src/src/modules/offers/tests/offer.entity.spec.ts diff --git a/src/modules/offers/tests/offer.service.spec.ts b/src/src/modules/offers/tests/offer.service.spec.ts similarity index 100% rename from src/modules/offers/tests/offer.service.spec.ts rename to src/src/modules/offers/tests/offer.service.spec.ts diff --git a/src/modules/offers/tests/offers.controller.spec.ts b/src/src/modules/offers/tests/offers.controller.spec.ts similarity index 100% rename from src/modules/offers/tests/offers.controller.spec.ts rename to src/src/modules/offers/tests/offers.controller.spec.ts diff --git a/src/modules/offers/tests/offers.integration.spec.ts b/src/src/modules/offers/tests/offers.integration.spec.ts similarity index 100% rename from src/modules/offers/tests/offers.integration.spec.ts rename to src/src/modules/offers/tests/offers.integration.spec.ts diff --git a/src/modules/orders/controllers/order.controller.ts b/src/src/modules/orders/controllers/order.controller.ts similarity index 100% rename from src/modules/orders/controllers/order.controller.ts rename to src/src/modules/orders/controllers/order.controller.ts diff --git a/src/modules/orders/dto/order.dto.ts b/src/src/modules/orders/dto/order.dto.ts similarity index 100% rename from src/modules/orders/dto/order.dto.ts rename to src/src/modules/orders/dto/order.dto.ts diff --git a/src/modules/orders/entities/order-item.entity.ts b/src/src/modules/orders/entities/order-item.entity.ts similarity index 100% rename from src/modules/orders/entities/order-item.entity.ts rename to src/src/modules/orders/entities/order-item.entity.ts diff --git a/src/modules/orders/entities/order.entity.ts b/src/src/modules/orders/entities/order.entity.ts similarity index 100% rename from src/modules/orders/entities/order.entity.ts rename to src/src/modules/orders/entities/order.entity.ts diff --git a/src/modules/orders/orders.module.ts b/src/src/modules/orders/orders.module.ts similarity index 100% rename from src/modules/orders/orders.module.ts rename to src/src/modules/orders/orders.module.ts diff --git a/src/modules/orders/routes/order.routes.ts b/src/src/modules/orders/routes/order.routes.ts similarity index 100% rename from src/modules/orders/routes/order.routes.ts rename to src/src/modules/orders/routes/order.routes.ts diff --git a/src/modules/orders/services/order.service.ts b/src/src/modules/orders/services/order.service.ts similarity index 100% rename from src/modules/orders/services/order.service.ts rename to src/src/modules/orders/services/order.service.ts diff --git a/src/modules/productVariants/controllers/productVariants.controller.ts b/src/src/modules/productVariants/controllers/productVariants.controller.ts similarity index 100% rename from src/modules/productVariants/controllers/productVariants.controller.ts rename to src/src/modules/productVariants/controllers/productVariants.controller.ts diff --git a/src/modules/productVariants/dto/productVariants.dto.ts b/src/src/modules/productVariants/dto/productVariants.dto.ts similarity index 100% rename from src/modules/productVariants/dto/productVariants.dto.ts rename to src/src/modules/productVariants/dto/productVariants.dto.ts diff --git a/src/modules/productVariants/entities/productVariants.entity.ts b/src/src/modules/productVariants/entities/productVariants.entity.ts similarity index 100% rename from src/modules/productVariants/entities/productVariants.entity.ts rename to src/src/modules/productVariants/entities/productVariants.entity.ts diff --git a/src/modules/productVariants/productVariants.module.ts b/src/src/modules/productVariants/productVariants.module.ts similarity index 100% rename from src/modules/productVariants/productVariants.module.ts rename to src/src/modules/productVariants/productVariants.module.ts diff --git a/src/modules/productVariants/services/productVariants.service.ts b/src/src/modules/productVariants/services/productVariants.service.ts similarity index 100% rename from src/modules/productVariants/services/productVariants.service.ts rename to src/src/modules/productVariants/services/productVariants.service.ts diff --git a/src/modules/products/controllers/product.controller.ts b/src/src/modules/products/controllers/product.controller.ts similarity index 100% rename from src/modules/products/controllers/product.controller.ts rename to src/src/modules/products/controllers/product.controller.ts diff --git a/src/modules/products/dto/product.dto.ts b/src/src/modules/products/dto/product.dto.ts similarity index 100% rename from src/modules/products/dto/product.dto.ts rename to src/src/modules/products/dto/product.dto.ts diff --git a/src/modules/products/entities/product.entity.ts b/src/src/modules/products/entities/product.entity.ts similarity index 100% rename from src/modules/products/entities/product.entity.ts rename to src/src/modules/products/entities/product.entity.ts diff --git a/src/modules/products/products.module.ts b/src/src/modules/products/products.module.ts similarity index 100% rename from src/modules/products/products.module.ts rename to src/src/modules/products/products.module.ts diff --git a/src/modules/products/services/product.service.ts b/src/src/modules/products/services/product.service.ts similarity index 100% rename from src/modules/products/services/product.service.ts rename to src/src/modules/products/services/product.service.ts diff --git a/src/modules/reviews/dto/review.dto.ts b/src/src/modules/reviews/dto/review.dto.ts similarity index 100% rename from src/modules/reviews/dto/review.dto.ts rename to src/src/modules/reviews/dto/review.dto.ts diff --git a/src/modules/reviews/entities/review.entity.ts b/src/src/modules/reviews/entities/review.entity.ts similarity index 100% rename from src/modules/reviews/entities/review.entity.ts rename to src/src/modules/reviews/entities/review.entity.ts diff --git a/src/modules/reviews/middlewares/review.middleware.ts b/src/src/modules/reviews/middlewares/review.middleware.ts similarity index 96% rename from src/modules/reviews/middlewares/review.middleware.ts rename to src/src/modules/reviews/middlewares/review.middleware.ts index ab05694..6d48d3f 100644 --- a/src/modules/reviews/middlewares/review.middleware.ts +++ b/src/src/modules/reviews/middlewares/review.middleware.ts @@ -1,5 +1,5 @@ import { Response, NextFunction } from 'express'; -import { BadRequestError } from '../../../utils/errors'; +import { BadRequestError } from '../../../../utils/errors'; import { AuthenticatedRequest } from '../../shared/types/auth-request.type'; /** diff --git a/src/modules/shared/decorators/roles.decorator.ts b/src/src/modules/shared/decorators/roles.decorator.ts similarity index 100% rename from src/modules/shared/decorators/roles.decorator.ts rename to src/src/modules/shared/decorators/roles.decorator.ts diff --git a/src/modules/shared/guards/auth.guard.ts b/src/src/modules/shared/guards/auth.guard.ts similarity index 100% rename from src/modules/shared/guards/auth.guard.ts rename to src/src/modules/shared/guards/auth.guard.ts diff --git a/src/modules/shared/guards/jwt-auth.guard.ts b/src/src/modules/shared/guards/jwt-auth.guard.ts similarity index 100% rename from src/modules/shared/guards/jwt-auth.guard.ts rename to src/src/modules/shared/guards/jwt-auth.guard.ts diff --git a/src/modules/shared/guards/role.guard.ts b/src/src/modules/shared/guards/role.guard.ts similarity index 100% rename from src/modules/shared/guards/role.guard.ts rename to src/src/modules/shared/guards/role.guard.ts diff --git a/src/modules/shared/guards/roles.guard.ts b/src/src/modules/shared/guards/roles.guard.ts similarity index 100% rename from src/modules/shared/guards/roles.guard.ts rename to src/src/modules/shared/guards/roles.guard.ts diff --git a/src/modules/shared/middleware/async.middleware.ts b/src/src/modules/shared/middleware/async.middleware.ts similarity index 100% rename from src/modules/shared/middleware/async.middleware.ts rename to src/src/modules/shared/middleware/async.middleware.ts diff --git a/src/modules/shared/middleware/auth.middleware.ts b/src/src/modules/shared/middleware/auth.middleware.ts similarity index 100% rename from src/modules/shared/middleware/auth.middleware.ts rename to src/src/modules/shared/middleware/auth.middleware.ts diff --git a/src/modules/shared/middleware/error.middleware.ts b/src/src/modules/shared/middleware/error.middleware.ts similarity index 100% rename from src/modules/shared/middleware/error.middleware.ts rename to src/src/modules/shared/middleware/error.middleware.ts diff --git a/src/modules/shared/middleware/ratelimiter.middleware.ts b/src/src/modules/shared/middleware/ratelimiter.middleware.ts similarity index 100% rename from src/modules/shared/middleware/ratelimiter.middleware.ts rename to src/src/modules/shared/middleware/ratelimiter.middleware.ts diff --git a/src/modules/shared/middleware/roles.guard.ts b/src/src/modules/shared/middleware/roles.guard.ts similarity index 100% rename from src/modules/shared/middleware/roles.guard.ts rename to src/src/modules/shared/middleware/roles.guard.ts diff --git a/src/modules/shared/middleware/session.middleware.ts b/src/src/modules/shared/middleware/session.middleware.ts similarity index 100% rename from src/modules/shared/middleware/session.middleware.ts rename to src/src/modules/shared/middleware/session.middleware.ts diff --git a/src/modules/shared/middleware/validation.middleware.ts b/src/src/modules/shared/middleware/validation.middleware.ts similarity index 100% rename from src/modules/shared/middleware/validation.middleware.ts rename to src/src/modules/shared/middleware/validation.middleware.ts diff --git a/src/modules/shared/services/role.service.ts b/src/src/modules/shared/services/role.service.ts similarity index 100% rename from src/modules/shared/services/role.service.ts rename to src/src/modules/shared/services/role.service.ts diff --git a/src/modules/shared/shared.module.ts b/src/src/modules/shared/shared.module.ts similarity index 100% rename from src/modules/shared/shared.module.ts rename to src/src/modules/shared/shared.module.ts diff --git a/src/modules/shared/types/auth-request.type.ts b/src/src/modules/shared/types/auth-request.type.ts similarity index 100% rename from src/modules/shared/types/auth-request.type.ts rename to src/src/modules/shared/types/auth-request.type.ts diff --git a/src/modules/shared/types/index.d.ts b/src/src/modules/shared/types/index.d.ts similarity index 100% rename from src/modules/shared/types/index.d.ts rename to src/src/modules/shared/types/index.d.ts diff --git a/src/modules/shared/utils/errors.ts b/src/src/modules/shared/utils/errors.ts similarity index 100% rename from src/modules/shared/utils/errors.ts rename to src/src/modules/shared/utils/errors.ts diff --git a/src/modules/shared/utils/test-utils.ts b/src/src/modules/shared/utils/test-utils.ts similarity index 100% rename from src/modules/shared/utils/test-utils.ts rename to src/src/modules/shared/utils/test-utils.ts diff --git a/src/modules/shared/utils/user-utils.ts b/src/src/modules/shared/utils/user-utils.ts similarity index 100% rename from src/modules/shared/utils/user-utils.ts rename to src/src/modules/shared/utils/user-utils.ts diff --git a/src/modules/supabase/supabase.module.ts b/src/src/modules/supabase/supabase.module.ts similarity index 100% rename from src/modules/supabase/supabase.module.ts rename to src/src/modules/supabase/supabase.module.ts diff --git a/src/modules/supabase/supabase.service.ts b/src/src/modules/supabase/supabase.service.ts similarity index 100% rename from src/modules/supabase/supabase.service.ts rename to src/src/modules/supabase/supabase.service.ts diff --git a/src/modules/users/controllers/user.controller.ts b/src/src/modules/users/controllers/user.controller.ts similarity index 100% rename from src/modules/users/controllers/user.controller.ts rename to src/src/modules/users/controllers/user.controller.ts diff --git a/src/modules/users/entities/user.entity.ts b/src/src/modules/users/entities/user.entity.ts similarity index 100% rename from src/modules/users/entities/user.entity.ts rename to src/src/modules/users/entities/user.entity.ts diff --git a/src/modules/users/enums/user-role.enum.ts b/src/src/modules/users/enums/user-role.enum.ts similarity index 100% rename from src/modules/users/enums/user-role.enum.ts rename to src/src/modules/users/enums/user-role.enum.ts diff --git a/src/modules/users/services/user.service.ts b/src/src/modules/users/services/user.service.ts similarity index 100% rename from src/modules/users/services/user.service.ts rename to src/src/modules/users/services/user.service.ts diff --git a/src/modules/users/users.module.ts b/src/src/modules/users/users.module.ts similarity index 100% rename from src/modules/users/users.module.ts rename to src/src/modules/users/users.module.ts diff --git a/src/modules/wishlist/common/mock/mock-request.ts b/src/src/modules/wishlist/common/mock/mock-request.ts similarity index 100% rename from src/modules/wishlist/common/mock/mock-request.ts rename to src/src/modules/wishlist/common/mock/mock-request.ts diff --git a/src/modules/wishlist/common/types/auth-request.type.ts b/src/src/modules/wishlist/common/types/auth-request.type.ts similarity index 100% rename from src/modules/wishlist/common/types/auth-request.type.ts rename to src/src/modules/wishlist/common/types/auth-request.type.ts diff --git a/src/modules/wishlist/controller/wishlist.controller.ts b/src/src/modules/wishlist/controller/wishlist.controller.ts similarity index 100% rename from src/modules/wishlist/controller/wishlist.controller.ts rename to src/src/modules/wishlist/controller/wishlist.controller.ts diff --git a/src/modules/wishlist/dtos/add-to-wishlist.dto.ts b/src/src/modules/wishlist/dtos/add-to-wishlist.dto.ts similarity index 100% rename from src/modules/wishlist/dtos/add-to-wishlist.dto.ts rename to src/src/modules/wishlist/dtos/add-to-wishlist.dto.ts diff --git a/src/modules/wishlist/dtos/remove-from-wishlist.dto.ts b/src/src/modules/wishlist/dtos/remove-from-wishlist.dto.ts similarity index 100% rename from src/modules/wishlist/dtos/remove-from-wishlist.dto.ts rename to src/src/modules/wishlist/dtos/remove-from-wishlist.dto.ts diff --git a/src/modules/wishlist/entities/wishlist.entity.ts b/src/src/modules/wishlist/entities/wishlist.entity.ts similarity index 100% rename from src/modules/wishlist/entities/wishlist.entity.ts rename to src/src/modules/wishlist/entities/wishlist.entity.ts diff --git a/src/modules/wishlist/services/wishlist.service.ts b/src/src/modules/wishlist/services/wishlist.service.ts similarity index 100% rename from src/modules/wishlist/services/wishlist.service.ts rename to src/src/modules/wishlist/services/wishlist.service.ts diff --git a/src/modules/wishlist/tests/wishlist.controller.spec.ts b/src/src/modules/wishlist/tests/wishlist.controller.spec.ts similarity index 100% rename from src/modules/wishlist/tests/wishlist.controller.spec.ts rename to src/src/modules/wishlist/tests/wishlist.controller.spec.ts diff --git a/src/modules/wishlist/tests/wishlist.service.spec.ts b/src/src/modules/wishlist/tests/wishlist.service.spec.ts similarity index 100% rename from src/modules/wishlist/tests/wishlist.service.spec.ts rename to src/src/modules/wishlist/tests/wishlist.service.spec.ts diff --git a/src/modules/wishlist/wishlist.module.ts b/src/src/modules/wishlist/wishlist.module.ts similarity index 100% rename from src/modules/wishlist/wishlist.module.ts rename to src/src/modules/wishlist/wishlist.module.ts diff --git a/src/types/auth-request.type.ts b/src/src/types/auth-request.type.ts similarity index 100% rename from src/types/auth-request.type.ts rename to src/src/types/auth-request.type.ts diff --git a/src/types/express.d.ts b/src/src/types/express.d.ts similarity index 100% rename from src/types/express.d.ts rename to src/src/types/express.d.ts diff --git a/src/types/global-response.type.ts b/src/src/types/global-response.type.ts similarity index 100% rename from src/types/global-response.type.ts rename to src/src/types/global-response.type.ts diff --git a/src/types/role.ts b/src/src/types/role.ts similarity index 100% rename from src/types/role.ts rename to src/src/types/role.ts diff --git a/test/escrow.e2e-spec.ts b/test/escrow.e2e-spec.ts new file mode 100644 index 0000000..3e142ab --- /dev/null +++ b/test/escrow.e2e-spec.ts @@ -0,0 +1,181 @@ +import request from 'supertest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { AppModule } from '../src/app.module'; +import { DataSource } from 'typeorm'; +import { Escrow } from '../src/modules/escrows/entities/escrow.entity'; +import { Milestone } from '../src/modules/escrows/entities/milestone.entity'; +import { Offer, OfferStatus } from '../src/modules/offers/entities/offer.entity'; +import { BuyerRequest } from '../src/modules/buyer-requests/entities/buyer-request.entity'; +import { User } from '../src/modules/users/entities/user.entity'; +import { Role } from '../src/modules/auth/entities/role.entity'; +import { UserRole } from '../src/modules/auth/entities/user-role.entity'; + +// Utility to create a user with role +async function createUser(ds: DataSource, wallet: string, roleName: 'buyer' | 'seller'): Promise { + const userRepo = ds.getRepository(User); + const roleRepo = ds.getRepository(Role); + const userRoleRepo = ds.getRepository(UserRole); + + let role = await roleRepo.findOne({ where: { name: roleName } }); + if (!role) { + role = roleRepo.create({ name: roleName }); + await roleRepo.save(role); + } + + const user = userRepo.create({ walletAddress: wallet }); + await userRepo.save(user); + const ur = userRoleRepo.create({ userId: user.id, roleId: role.id }); + await userRoleRepo.save(ur); + return user; +} + +describe('Escrow Milestone Approval (e2e)', () => { + let app: INestApplication; + let moduleFixture: TestingModule; + let ds: DataSource; + let buyer: User; + let seller: User; + let escrow: Escrow; + let milestones: Milestone[]; + let authTokenBuyer: string; + let authTokenSeller: string; + + beforeAll(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + ds = moduleFixture.get(DataSource); + + buyer = await createUser(ds, 'GBUYERADDRESS', 'buyer'); + seller = await createUser(ds, 'GSELLERADDRESS', 'seller'); + + // Create buyer request and accepted offer for context + const brRepo = ds.getRepository(BuyerRequest); + const offerRepo = ds.getRepository(Offer); + const escrowRepo = ds.getRepository(Escrow); + const milestoneRepo = ds.getRepository(Milestone); + + const br = brRepo.create({ + title: 'Test Request', + description: 'Need something', + budgetMin: 10, + budgetMax: 100, + categoryId: 1, + userId: buyer.id, + status: 'open', + }); + await brRepo.save(br); + + const offer = offerRepo.create({ + buyerRequestId: br.id, + sellerId: seller.id, + title: 'Offer', + description: 'desc', + price: 50, + deliveryDays: 5, + status: OfferStatus.ACCEPTED, + }); + await offerRepo.save(offer); + + escrow = escrowRepo.create({ + offerId: offer.id, + buyerId: buyer.id, + sellerId: seller.id, + totalAmount: 50, + status: 'pending', + }); + await escrowRepo.save(escrow); + + milestones = await milestoneRepo.save([ + milestoneRepo.create({ escrowId: escrow.id, sequence: 1, title: 'Phase 1', amount: 25 }), + milestoneRepo.create({ escrowId: escrow.id, sequence: 2, title: 'Phase 2', amount: 25 }), + ]); + + // Simulate login by generating tokens via registerWithWallet (simplify by hitting auth/register) + const buyerReg = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ walletAddress: buyer.walletAddress, role: 'buyer' }); + authTokenBuyer = buyerReg.headers['set-cookie'][0].split(';')[0].split('=')[1]; + + const sellerReg = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ walletAddress: seller.walletAddress, role: 'seller' }); + authTokenSeller = sellerReg.headers['set-cookie'][0].split(';')[0].split('=')[1]; + }); + + afterAll(async () => { + await app.close(); + }); + + it('should allow buyer to approve a milestone', async () => { + const res = await request(app.getHttpServer()) + .patch(`/api/v1/escrows/${escrow.id}/milestones/${milestones[0].id}/approve`) + .set('Cookie', `token=${authTokenBuyer}`) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data.status).toBe('approved'); + }); + + it('should block non-buyer (seller) from approving', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/escrows/${escrow.id}/milestones/${milestones[1].id}/approve`) + .set('Cookie', `token=${authTokenSeller}`) + .expect(403); + }); + + it('should prevent approving the same milestone twice', async () => { + // First approval already done in previous test for milestones[0] + await request(app.getHttpServer()) + .patch(`/api/v1/escrows/${escrow.id}/milestones/${milestones[0].id}/approve`) + .set('Cookie', `token=${authTokenBuyer}`) + .expect(400); + }); + + it('should list escrows for signer (both roles)', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/escrows') + .set('Cookie', `token=${authTokenBuyer}`) + .expect(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + const first = res.body.data[0]; + expect(first).toHaveProperty('releasedAmount'); + expect(first).toHaveProperty('remainingAmount'); + }); + + it('should list escrows by role=buyer', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/escrows?role=buyer') + .set('Cookie', `token=${authTokenBuyer}`) + .expect(200); + expect(res.body.success).toBe(true); + expect(res.body.data.every((e: any) => e.buyerId === buyer.id)).toBe(true); + }); + + it('should return balances for multiple escrow ids', async () => { + const res = await request(app.getHttpServer()) + .post('/api/v1/escrows/balances') + .set('Cookie', `token=${authTokenBuyer}`) + .send({ ids: [escrow.id] }) + .expect(200); + expect(res.body.success).toBe(true); + expect(res.body.data[escrow.id]).toHaveProperty('releasedAmount'); + expect(res.body.data[escrow.id]).toHaveProperty('remainingAmount'); + }); + + it('should handle empty balances request', async () => { + const res = await request(app.getHttpServer()) + .post('/api/v1/escrows/balances') + .set('Cookie', `token=${authTokenBuyer}`) + .send({ ids: [] }) + .expect(400); // validation should fail because ArrayNotEmpty + expect(res.body.success).toBe(false); + }); +});