diff --git a/src/app.module.ts b/src/app.module.ts index 636719c64..3e54b7f9c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,3 @@ -// Unit tests stub -import { Test, TestingModule } from '@nestjs/testing'; -import { CommandFrameworkService } from '../command-framework.service'; - -describe('CommandFrameworkService', () => { - let service: CommandFrameworkService; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -29,10 +23,10 @@ import { AnalyticsModule } from './analytics/analytics.module'; import { TransactionsModule } from './transactions/transactions.module'; import { LoggingModule } from './common/logging/logging.module'; import { ScheduledJobsModule } from './scheduled-jobs/scheduled-jobs.module'; -import { AIModerationModule } from './ai-moderation/ai-moderation.module'; import { NFTsModule } from './nfts/nfts.module'; import { AppI18nModule } from './i18n/app-i18n.module'; import { InChatTransfersModule } from './in-chat-transfers/in-chat-transfers.module'; +import { GroupExpensesModule } from './group-expenses/group-expenses.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { ObservabilityModule } from './observability/observability.module'; import { UserSettingsModule } from './user-settings/user-settings.module'; @@ -95,27 +89,37 @@ import { BlockEnforcementModule } from './block-enforcement/block-enforcement.mo TypeOrmModule.forRootAsync({ useFactory: typeOrmConfig }), ThrottlerModule.forRoot([{ ttl: 60000, limit: 10 }]), RedisCacheModule, - ScheduleModule.forRoot(), - WaitlistModule, - LoggingModule, HealthModule, UsersModule, AuthModule, TwoFactorModule, + StellarEventsModule, + TypeOrmModule.forRootAsync({ + useFactory: typeOrmConfig, + }), + ThrottlerModule.forRoot([ + { + ttl: 60000, + limit: 10, + }, + ]), + CacheModule, + FraudDetectionModule, + LoggingModule, + ScheduleModule.forRoot(), + AppI18nModule, + HealthModule, + UsersModule, + UserSettingsModule, + AppConfigModule, + AuthModule, SessionsModule, WalletsModule, AnalyticsModule, TransactionsModule, - LoggingModule, - ScheduledJobsModule, - NFTsModule, - AppI18nModule, - StellarEventsModule, - ContractStateCacheModule, NotificationsModule, ReactionsModule, StickersModule, - UserStickerPacksModule, PrivacyModule, BlockEnforcementModule, SpamDetectionModule, @@ -124,57 +128,21 @@ import { BlockEnforcementModule } from './block-enforcement/block-enforcement.mo Sep10Module, RampModule, QrCodeModule, - PollsModule, - OnboardingModule, - MessageDraftsModule, ScheduledJobsModule, - AIModerationModule, InChatTransfersModule, - BotsModule, + GroupExpensesModule, WebhooksModule, ObservabilityModule, - UserSettingsModule, - AppConfigModule, - AppVersionModule, AdminModule, MembershipTierModule, - CacheModule, - ReportsModule, - BlockchainTransactionsModule, - MessageForwardingModule, - PollsModule, - MentionsModule, - RecurringPaymentsModule, - AnchorModule, - ConversationExportModule, - FeedbackModule, - RevenueModule, - PortfolioModule, - AddressBookModule, - UsernameDiscoveryModule, - DeveloperSandboxModule, - StoriesModule, - PaymentsModule, - LinkPreviewsModule, - NotificationDigestModule, - ReceiptsModule, - TrustNetworkModule, - AmlMonitoringModule, - ConnectionsModule, - PaymentSettingsModule, - ActivityFeedModule, ], - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CommandFrameworkService], - }).compile(); - - service = module.get(CommandFrameworkService); - }); - - it('should parse command', () => { - expect(service.parseCommand('/help')).toBeDefined(); - }); - // TODO: full coverage >85% -}); + controllers: [AppController], + providers: [ + AppService, + { + provide: APP_GUARD, + useClass: AdvancedThrottlerGuard, + }, + ], +}) +export class AppModule {} diff --git a/src/conversations/entities/conversation.entity.ts b/src/conversations/entities/conversation.entity.ts index d762f00e3..1c76d7677 100644 --- a/src/conversations/entities/conversation.entity.ts +++ b/src/conversations/entities/conversation.entity.ts @@ -10,7 +10,7 @@ import { ConversationParticipant } from './conversation-participant.entity'; import { Message } from '../../messages/entities/message.entity'; import { InChatTransfer } from '../../in-chat-transfers/entities/in-chat-transfer.entity'; import { PinnedMessage } from '../../pinned-messages/entities/pinned-message.entity'; -import { PaymentRequest } from '../../payment-requests/entities/payment-request.entity'; +import { GroupExpense } from '../../group-expenses/entities/group-expense.entity'; export enum ConversationType { DIRECT = 'direct', @@ -47,8 +47,8 @@ export class Conversation { @OneToMany(() => PinnedMessage, (pin) => pin.conversation) pinnedMessages!: PinnedMessage[]; - @OneToMany(() => PaymentRequest, (req: PaymentRequest) => req.conversation) - paymentRequests!: PaymentRequest[]; + @OneToMany(() => GroupExpense, (expense) => expense.conversation) + expenses!: GroupExpense[]; @CreateDateColumn({ type: 'timestamp' }) createdAt!: Date; diff --git a/src/group-expenses/dto/create-group-expense.dto.ts b/src/group-expenses/dto/create-group-expense.dto.ts new file mode 100644 index 000000000..4a4d5c356 --- /dev/null +++ b/src/group-expenses/dto/create-group-expense.dto.ts @@ -0,0 +1,65 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; +import { GroupExpenseSplitType } from '../entities/group-expense.entity'; + +export class SplitInputDto { + @ApiProperty() + @IsUUID() + userId!: string; + + @ApiPropertyOptional({ description: 'Used when splitType is CUSTOM' }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 7 }) + @Min(0) + amount?: number; + + @ApiPropertyOptional({ description: 'Used when splitType is PERCENTAGE' }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 4 }) + @Min(0) + percentage?: number; +} + +export class CreateGroupExpenseDto { + @ApiProperty() + @IsString() + @MaxLength(180) + title!: string; + + @ApiProperty({ minimum: 0.0000001 }) + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 7 }) + @Min(0.0000001) + totalAmount!: number; + + @ApiProperty() + @IsString() + @MaxLength(64) + tokenId!: string; + + @ApiProperty({ enum: GroupExpenseSplitType }) + @IsEnum(GroupExpenseSplitType) + splitType!: GroupExpenseSplitType; + + @ApiPropertyOptional({ type: [SplitInputDto] }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => SplitInputDto) + splits?: SplitInputDto[]; +} diff --git a/src/group-expenses/dto/get-group-expenses-query.dto.ts b/src/group-expenses/dto/get-group-expenses-query.dto.ts new file mode 100644 index 000000000..a961e96be --- /dev/null +++ b/src/group-expenses/dto/get-group-expenses-query.dto.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { GroupExpenseStatus } from '../entities/group-expense.entity'; + +export class GetGroupExpensesQueryDto { + @ApiPropertyOptional({ enum: GroupExpenseStatus }) + @IsOptional() + @IsEnum(GroupExpenseStatus) + status?: GroupExpenseStatus; + + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/group-expenses/dto/group-expense-response.dto.ts b/src/group-expenses/dto/group-expense-response.dto.ts new file mode 100644 index 000000000..814e09022 --- /dev/null +++ b/src/group-expenses/dto/group-expense-response.dto.ts @@ -0,0 +1,110 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + GroupExpenseSplitType, + GroupExpenseStatus, +} from '../entities/group-expense.entity'; + +export class ExpenseSplitResponseDto { + @ApiProperty() + expenseId!: string; + + @ApiProperty() + userId!: string; + + @ApiProperty() + amountOwed!: string; + + @ApiProperty() + amountPaid!: string; + + @ApiProperty() + isPaid!: boolean; + + @ApiProperty({ nullable: true }) + paidAt!: Date | null; +} + +export class GroupExpenseResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty() + groupId!: string; + + @ApiProperty() + conversationId!: string; + + @ApiProperty() + createdBy!: string; + + @ApiProperty() + title!: string; + + @ApiProperty() + totalAmount!: string; + + @ApiProperty() + tokenId!: string; + + @ApiProperty({ enum: GroupExpenseSplitType }) + splitType!: GroupExpenseSplitType; + + @ApiProperty({ enum: GroupExpenseStatus }) + status!: GroupExpenseStatus; + + @ApiProperty() + createdAt!: Date; + + @ApiProperty({ type: [ExpenseSplitResponseDto] }) + splits!: ExpenseSplitResponseDto[]; +} + +export class GroupExpenseListResponseDto { + @ApiProperty({ type: [GroupExpenseResponseDto] }) + items!: GroupExpenseResponseDto[]; + + @ApiProperty() + total!: number; + + @ApiProperty() + page!: number; + + @ApiProperty() + limit!: number; +} + +export class GroupBalanceMemberSummaryDto { + @ApiProperty() + userId!: string; + + @ApiProperty() + netOwed!: string; + + @ApiProperty() + netOwedTo!: string; +} + +export class GroupUnsettledBalanceDto { + @ApiProperty() + userId!: string; + + @ApiProperty() + totalOwed!: string; + + @ApiProperty() + totalOwedTo!: string; + + @ApiProperty() + netBalance!: string; +} + +export class GroupBalanceResponseDto { + @ApiProperty() + groupId!: string; + + @ApiProperty({ type: GroupUnsettledBalanceDto }) + unsettledBalance!: GroupUnsettledBalanceDto; + + @ApiProperty({ type: [GroupBalanceMemberSummaryDto] }) + summary!: GroupBalanceMemberSummaryDto[]; +} diff --git a/src/group-expenses/dto/update-expense-splits.dto.ts b/src/group-expenses/dto/update-expense-splits.dto.ts new file mode 100644 index 000000000..3331df120 --- /dev/null +++ b/src/group-expenses/dto/update-expense-splits.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; +import { GroupExpenseSplitType } from '../entities/group-expense.entity'; + +export class UpdateSplitInputDto { + @ApiProperty() + @IsUUID() + userId!: string; + + @ApiPropertyOptional({ description: 'Used when splitType is CUSTOM' }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 7 }) + @Min(0) + amount?: number; + + @ApiPropertyOptional({ description: 'Used when splitType is PERCENTAGE' }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 4 }) + @Min(0) + percentage?: number; +} + +export class UpdateExpenseSplitsDto { + @ApiProperty({ enum: GroupExpenseSplitType }) + @IsEnum(GroupExpenseSplitType) + splitType!: GroupExpenseSplitType; + + @ApiProperty({ type: [UpdateSplitInputDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => UpdateSplitInputDto) + splits!: UpdateSplitInputDto[]; +} diff --git a/src/group-expenses/entities/expense-split.entity.ts b/src/group-expenses/entities/expense-split.entity.ts new file mode 100644 index 000000000..2c9fec846 --- /dev/null +++ b/src/group-expenses/entities/expense-split.entity.ts @@ -0,0 +1,34 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { GroupExpense } from './group-expense.entity'; + +@Entity('expense_splits') +@Index('idx_expense_splits_expense_id', ['expenseId']) +@Index('idx_expense_splits_user_id', ['userId']) +export class ExpenseSplit { + @PrimaryColumn({ type: 'uuid' }) + expenseId!: string; + + @PrimaryColumn({ type: 'uuid' }) + userId!: string; + + @ManyToOne(() => GroupExpense, (expense) => expense.splits, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'expenseId' }) + expense!: GroupExpense; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user!: User; + + @Column({ type: 'numeric', precision: 20, scale: 7 }) + amountOwed!: string; + + @Column({ type: 'numeric', precision: 20, scale: 7, default: '0' }) + amountPaid!: string; + + @Column({ type: 'boolean', default: false }) + isPaid!: boolean; + + @Column({ type: 'timestamp', nullable: true }) + paidAt!: Date | null; +} diff --git a/src/group-expenses/entities/group-expense.entity.ts b/src/group-expenses/entities/group-expense.entity.ts new file mode 100644 index 000000000..51f5b18fd --- /dev/null +++ b/src/group-expenses/entities/group-expense.entity.ts @@ -0,0 +1,80 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Conversation } from '../../conversations/entities/conversation.entity'; +import { User } from '../../users/entities/user.entity'; +import { ExpenseSplit } from './expense-split.entity'; + +export enum GroupExpenseSplitType { + EQUAL = 'EQUAL', + CUSTOM = 'CUSTOM', + PERCENTAGE = 'PERCENTAGE', +} + +export enum GroupExpenseStatus { + OPEN = 'OPEN', + PARTIALLY_SETTLED = 'PARTIALLY_SETTLED', + SETTLED = 'SETTLED', +} + +@Entity('group_expenses') +export class GroupExpense { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 128 }) + @Index('idx_group_expenses_group_id') + groupId!: string; + + @Column({ type: 'uuid' }) + @Index('idx_group_expenses_conversation_id') + conversationId!: string; + + @ManyToOne(() => Conversation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'conversationId' }) + conversation!: Conversation; + + @Column({ type: 'uuid' }) + @Index('idx_group_expenses_created_by') + createdBy!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'createdBy' }) + creator!: User; + + @Column({ type: 'varchar', length: 180 }) + title!: string; + + @Column({ type: 'numeric', precision: 20, scale: 7 }) + totalAmount!: string; + + @Column({ type: 'varchar', length: 64 }) + tokenId!: string; + + @Column({ + type: 'enum', + enum: GroupExpenseSplitType, + }) + splitType!: GroupExpenseSplitType; + + @Column({ + type: 'enum', + enum: GroupExpenseStatus, + default: GroupExpenseStatus.OPEN, + }) + @Index('idx_group_expenses_status') + status!: GroupExpenseStatus; + + @OneToMany(() => ExpenseSplit, (split) => split.expense, { cascade: true }) + splits!: ExpenseSplit[]; + + @CreateDateColumn({ type: 'timestamp' }) + createdAt!: Date; +} diff --git a/src/group-expenses/group-expenses.controller.ts b/src/group-expenses/group-expenses.controller.ts new file mode 100644 index 000000000..812202975 --- /dev/null +++ b/src/group-expenses/group-expenses.controller.ts @@ -0,0 +1,73 @@ +import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { LocalizedParseUUIDPipe } from '../i18n/pipes/localized-parse-uuid.pipe'; +import { CreateGroupExpenseDto } from './dto/create-group-expense.dto'; +import { GetGroupExpensesQueryDto } from './dto/get-group-expenses-query.dto'; +import { + GroupBalanceResponseDto, + GroupExpenseListResponseDto, + GroupExpenseResponseDto, +} from './dto/group-expense-response.dto'; +import { UpdateExpenseSplitsDto } from './dto/update-expense-splits.dto'; +import { GroupExpensesService } from './group-expenses.service'; + +@ApiTags('group-expenses') +@ApiBearerAuth() +@Controller() +export class GroupExpensesController { + constructor(private readonly groupExpensesService: GroupExpensesService) {} + + @Post('groups/:id/expenses') + @ApiOperation({ summary: 'Create a group expense' }) + @ApiResponse({ status: 201, type: GroupExpenseResponseDto }) + createExpense( + @Param('id') groupId: string, + @CurrentUser('id') userId: string, + @Body() dto: CreateGroupExpenseDto, + ): Promise { + return this.groupExpensesService.createExpense(groupId, userId, dto); + } + + @Get('groups/:id/expenses') + @ApiOperation({ summary: 'List group expenses with pagination and status filter' }) + @ApiResponse({ status: 200, type: GroupExpenseListResponseDto }) + getExpenses( + @Param('id') groupId: string, + @CurrentUser('id') userId: string, + @Query() query: GetGroupExpensesQueryDto, + ): Promise { + return this.groupExpensesService.getExpenses(groupId, userId, query); + } + + @Get('groups/:id/expenses/balance') + @ApiOperation({ summary: 'Get unsettled balance and group balance summary' }) + @ApiResponse({ status: 200, type: GroupBalanceResponseDto }) + getBalance( + @Param('id') groupId: string, + @CurrentUser('id') userId: string, + ): Promise { + return this.groupExpensesService.getBalanceView(groupId, userId); + } + + @Post('expenses/:id/settle') + @ApiOperation({ summary: 'Settle an expense split through in-chat transfer' }) + @ApiResponse({ status: 201, type: GroupExpenseResponseDto }) + settleExpense( + @Param('id', LocalizedParseUUIDPipe) expenseId: string, + @CurrentUser('id') userId: string, + ): Promise { + return this.groupExpensesService.settleViaTransfer(expenseId, userId); + } + + @Patch('expenses/:id/splits') + @ApiOperation({ summary: 'Update expense splits' }) + @ApiResponse({ status: 200, type: GroupExpenseResponseDto }) + updateExpenseSplits( + @Param('id', LocalizedParseUUIDPipe) expenseId: string, + @CurrentUser('id') userId: string, + @Body() dto: UpdateExpenseSplitsDto, + ): Promise { + return this.groupExpensesService.updateSplits(expenseId, userId, dto); + } +} diff --git a/src/group-expenses/group-expenses.module.ts b/src/group-expenses/group-expenses.module.ts new file mode 100644 index 000000000..36bc94e28 --- /dev/null +++ b/src/group-expenses/group-expenses.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Conversation } from '../conversations/entities/conversation.entity'; +import { ConversationParticipant } from '../conversations/entities/conversation-participant.entity'; +import { InChatTransfersModule } from '../in-chat-transfers/in-chat-transfers.module'; +import { MessagingModule } from '../messaging/messaging.module'; +import { UsersModule } from '../users/users.module'; +import { ExpenseSplit } from './entities/expense-split.entity'; +import { GroupExpense } from './entities/group-expense.entity'; +import { GroupExpensesController } from './group-expenses.controller'; +import { GroupExpensesService } from './group-expenses.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([GroupExpense, ExpenseSplit, Conversation, ConversationParticipant]), + UsersModule, + InChatTransfersModule, + MessagingModule, + ], + controllers: [GroupExpensesController], + providers: [GroupExpensesService], + exports: [GroupExpensesService], +}) +export class GroupExpensesModule {} diff --git a/src/group-expenses/group-expenses.service.spec.ts b/src/group-expenses/group-expenses.service.spec.ts new file mode 100644 index 000000000..0a42b722f --- /dev/null +++ b/src/group-expenses/group-expenses.service.spec.ts @@ -0,0 +1,303 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ForbiddenException } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ObjectLiteral, Repository } from 'typeorm'; +import { Conversation, ConversationType } from '../conversations/entities/conversation.entity'; +import { ConversationParticipant } from '../conversations/entities/conversation-participant.entity'; +import { InChatTransfersService } from '../in-chat-transfers/in-chat-transfers.service'; +import { ChatGateway } from '../messaging/gateways/chat.gateway'; +import { UsersRepository } from '../users/users.repository'; +import { ExpenseSplit } from './entities/expense-split.entity'; +import { + GroupExpense, + GroupExpenseSplitType, + GroupExpenseStatus, +} from './entities/group-expense.entity'; +import { GroupExpensesService } from './group-expenses.service'; + +type MockRepository = Partial, jest.Mock>>; + +const createRepositoryMock = (): MockRepository => ({ + findOne: jest.fn(), + find: jest.fn(), + findAndCount: jest.fn(), + save: jest.fn(), + create: jest.fn((entity) => entity), + delete: jest.fn(), +}); + +describe('GroupExpensesService', () => { + let service: GroupExpensesService; + let expensesRepository: MockRepository; + let splitsRepository: MockRepository; + let conversationsRepository: MockRepository; + let participantsRepository: MockRepository; + let usersRepository: jest.Mocked; + let inChatTransfersService: jest.Mocked; + let chatGateway: jest.Mocked; + + const groupId = 'chain-group-1'; + const conversationId = '10000000-0000-0000-0000-000000000011'; + const creatorId = '00000000-0000-0000-0000-000000000001'; + const memberA = '00000000-0000-0000-0000-000000000002'; + const memberB = '00000000-0000-0000-0000-000000000003'; + + beforeEach(async () => { + expensesRepository = createRepositoryMock(); + splitsRepository = createRepositoryMock(); + conversationsRepository = createRepositoryMock(); + participantsRepository = createRepositoryMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GroupExpensesService, + { provide: getRepositoryToken(GroupExpense), useValue: expensesRepository }, + { provide: getRepositoryToken(ExpenseSplit), useValue: splitsRepository }, + { provide: getRepositoryToken(Conversation), useValue: conversationsRepository }, + { + provide: getRepositoryToken(ConversationParticipant), + useValue: participantsRepository, + }, + { + provide: UsersRepository, + useValue: { findOne: jest.fn() }, + }, + { + provide: InChatTransfersService, + useValue: { initiateTransfer: jest.fn(), confirmTransfer: jest.fn() }, + }, + { + provide: ChatGateway, + useValue: { sendExpenseNew: jest.fn(), sendExpenseSettled: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(GroupExpensesService); + usersRepository = module.get(UsersRepository); + inChatTransfersService = module.get(InChatTransfersService); + chatGateway = module.get(ChatGateway); + + conversationsRepository.findOne!.mockResolvedValue({ + id: conversationId, + type: ConversationType.GROUP, + chainGroupId: groupId, + } as Conversation); + participantsRepository.find!.mockResolvedValue( + [creatorId, memberA, memberB].map((userId) => ({ userId })) as ConversationParticipant[], + ); + }); + + it('assigns equal split remainder to creator', async () => { + expensesRepository.save! + .mockResolvedValueOnce({ + id: 'expense-1', + groupId, + conversationId, + createdBy: creatorId, + title: 'Dinner', + totalAmount: '10.0000000', + tokenId: 'USDC', + splitType: GroupExpenseSplitType.EQUAL, + status: GroupExpenseStatus.OPEN, + createdAt: new Date(), + } as GroupExpense) + .mockResolvedValueOnce({ + id: 'expense-1', + groupId, + conversationId, + createdBy: creatorId, + title: 'Dinner', + totalAmount: '10.0000000', + tokenId: 'USDC', + splitType: GroupExpenseSplitType.EQUAL, + status: GroupExpenseStatus.PARTIALLY_SETTLED, + createdAt: new Date(), + } as GroupExpense); + + splitsRepository.save!.mockResolvedValue([ + { + expenseId: 'expense-1', + userId: creatorId, + amountOwed: '3.3333334', + amountPaid: '3.3333334', + isPaid: true, + paidAt: new Date(), + }, + { + expenseId: 'expense-1', + userId: memberA, + amountOwed: '3.3333333', + amountPaid: '0.0000000', + isPaid: false, + paidAt: null, + }, + { + expenseId: 'expense-1', + userId: memberB, + amountOwed: '3.3333333', + amountPaid: '0.0000000', + isPaid: false, + paidAt: null, + }, + ] as ExpenseSplit[]); + + const result = await service.createExpense(groupId, creatorId, { + title: 'Dinner', + totalAmount: 10, + tokenId: 'usdc', + splitType: GroupExpenseSplitType.EQUAL, + }); + + const creatorSplit = result.splits.find((split) => split.userId === creatorId); + expect(creatorSplit?.amountOwed).toBe('3.3333334'); + expect(chatGateway.sendExpenseNew).toHaveBeenCalled(); + }); + + it('rejects custom split when amounts do not sum to total', async () => { + await expect( + service.createExpense(groupId, creatorId, { + title: 'Hotel', + totalAmount: 10, + tokenId: 'XLM', + splitType: GroupExpenseSplitType.CUSTOM, + splits: [ + { userId: creatorId, amount: 4 }, + { userId: memberA, amount: 4 }, + ], + }), + ).rejects.toThrow('Custom split amounts must sum to the expense total.'); + }); + + it('returns net owed and owed-to summary per member', async () => { + expensesRepository.find!.mockResolvedValue([ + { + id: 'expense-1', + groupId, + conversationId, + createdBy: creatorId, + title: 'Dinner', + totalAmount: '30.0000000', + tokenId: 'USDC', + splitType: GroupExpenseSplitType.EQUAL, + status: GroupExpenseStatus.PARTIALLY_SETTLED, + createdAt: new Date(), + splits: [ + { + expenseId: 'expense-1', + userId: creatorId, + amountOwed: '10.0000000', + amountPaid: '10.0000000', + isPaid: true, + }, + { + expenseId: 'expense-1', + userId: memberA, + amountOwed: '10.0000000', + amountPaid: '0.0000000', + isPaid: false, + }, + { + expenseId: 'expense-1', + userId: memberB, + amountOwed: '10.0000000', + amountPaid: '4.0000000', + isPaid: false, + }, + ], + }, + ] as GroupExpense[]); + + const summary = await service.getGroupBalanceSummary(groupId, creatorId); + expect(summary.find((row) => row.userId === creatorId)?.netOwedTo).toBe('16.0000000'); + expect(summary.find((row) => row.userId === memberA)?.netOwed).toBe('10.0000000'); + expect(summary.find((row) => row.userId === memberB)?.netOwed).toBe('6.0000000'); + }); + + it('settles split through transfer and auto-marks paid', async () => { + expensesRepository.findOne!.mockResolvedValue({ + id: 'expense-1', + groupId, + conversationId, + createdBy: creatorId, + title: 'Dinner', + totalAmount: '30.0000000', + tokenId: 'USDC', + splitType: GroupExpenseSplitType.EQUAL, + status: GroupExpenseStatus.PARTIALLY_SETTLED, + createdAt: new Date(), + splits: [], + } as unknown as GroupExpense); + splitsRepository.findOne!.mockResolvedValue({ + expenseId: 'expense-1', + userId: memberA, + amountOwed: '10.0000000', + amountPaid: '2.0000000', + isPaid: false, + paidAt: null, + } as ExpenseSplit); + usersRepository.findOne.mockResolvedValue({ + id: creatorId, + username: 'creator', + } as never); + inChatTransfersService.initiateTransfer.mockResolvedValue({ + transferId: 'tr-1', + } as never); + inChatTransfersService.confirmTransfer.mockResolvedValue({ + status: 'completed', + } as never); + splitsRepository.save!.mockResolvedValue({ + expenseId: 'expense-1', + userId: memberA, + amountOwed: '10.0000000', + amountPaid: '10.0000000', + isPaid: true, + paidAt: new Date(), + } as ExpenseSplit); + splitsRepository.find!.mockResolvedValue([ + { + expenseId: 'expense-1', + userId: creatorId, + amountOwed: '10.0000000', + amountPaid: '10.0000000', + isPaid: true, + }, + { + expenseId: 'expense-1', + userId: memberA, + amountOwed: '10.0000000', + amountPaid: '10.0000000', + isPaid: true, + }, + ] as ExpenseSplit[]); + expensesRepository.save!.mockResolvedValue({ + id: 'expense-1', + groupId, + conversationId, + createdBy: creatorId, + title: 'Dinner', + totalAmount: '30.0000000', + tokenId: 'USDC', + splitType: GroupExpenseSplitType.EQUAL, + status: GroupExpenseStatus.SETTLED, + createdAt: new Date(), + splits: [], + } as unknown as GroupExpense); + + const result = await service.settleViaTransfer('expense-1', memberA); + + expect(inChatTransfersService.initiateTransfer).toHaveBeenCalledWith( + memberA, + conversationId, + expect.objectContaining({ rawCommand: expect.stringContaining('/send @creator 8.0000000 USDC') }), + ); + expect(result.status).toBe(GroupExpenseStatus.SETTLED); + expect(chatGateway.sendExpenseSettled).toHaveBeenCalled(); + }); + + it('forbids non-participant from listing expenses', async () => { + await expect( + service.getExpenses(groupId, '00000000-0000-0000-0000-000000009999', { page: 1, limit: 10 }), + ).rejects.toThrow(ForbiddenException); + }); +}); diff --git a/src/group-expenses/group-expenses.service.ts b/src/group-expenses/group-expenses.service.ts new file mode 100644 index 000000000..abf63ecac --- /dev/null +++ b/src/group-expenses/group-expenses.service.ts @@ -0,0 +1,550 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Conversation, ConversationType } from '../conversations/entities/conversation.entity'; +import { ConversationParticipant } from '../conversations/entities/conversation-participant.entity'; +import { InChatTransfersService } from '../in-chat-transfers/in-chat-transfers.service'; +import { ChatGateway } from '../messaging/gateways/chat.gateway'; +import { UsersRepository } from '../users/users.repository'; +import { CreateGroupExpenseDto, SplitInputDto } from './dto/create-group-expense.dto'; +import { GetGroupExpensesQueryDto } from './dto/get-group-expenses-query.dto'; +import { + GroupBalanceMemberSummaryDto, + GroupBalanceResponseDto, + GroupExpenseListResponseDto, + GroupExpenseResponseDto, + GroupUnsettledBalanceDto, +} from './dto/group-expense-response.dto'; +import { UpdateExpenseSplitsDto, UpdateSplitInputDto } from './dto/update-expense-splits.dto'; +import { ExpenseSplit } from './entities/expense-split.entity'; +import { + GroupExpense, + GroupExpenseSplitType, + GroupExpenseStatus, +} from './entities/group-expense.entity'; + +type SplitDraft = { + userId: string; + amountOwed: bigint; +}; + +const STROOPS_FACTOR = 10_000_000; + +@Injectable() +export class GroupExpensesService { + constructor( + @InjectRepository(GroupExpense) + private readonly expensesRepository: Repository, + @InjectRepository(ExpenseSplit) + private readonly splitsRepository: Repository, + @InjectRepository(Conversation) + private readonly conversationsRepository: Repository, + @InjectRepository(ConversationParticipant) + private readonly participantsRepository: Repository, + private readonly usersRepository: UsersRepository, + private readonly inChatTransfersService: InChatTransfersService, + private readonly chatGateway: ChatGateway, + ) {} + + async createExpense( + groupId: string, + createdBy: string, + dto: CreateGroupExpenseDto, + ): Promise { + const conversation = await this.getGroupConversationOrThrow(groupId); + const participants = await this.getParticipantIds(conversation.id); + this.assertParticipant(participants, createdBy); + + const totalAmount = this.toStroops(dto.totalAmount); + const splitDrafts = this.buildSplitDrafts( + participants, + createdBy, + totalAmount, + dto.splitType, + dto.splits ?? [], + ); + + const expense = await this.expensesRepository.save( + this.expensesRepository.create({ + groupId, + conversationId: conversation.id, + createdBy, + title: dto.title.trim(), + totalAmount: this.fromStroops(totalAmount), + tokenId: dto.tokenId.trim().toUpperCase(), + splitType: dto.splitType, + status: this.computeStatus(splitDrafts.map((draft) => this.fromStroops(draft.amountOwed)), []), + }), + ); + + const splits = await this.splitsRepository.save( + splitDrafts.map((draft) => + this.splitsRepository.create({ + expenseId: expense.id, + userId: draft.userId, + amountOwed: this.fromStroops(draft.amountOwed), + amountPaid: draft.userId === createdBy ? this.fromStroops(draft.amountOwed) : '0.0000000', + isPaid: draft.userId === createdBy, + paidAt: draft.userId === createdBy ? new Date() : null, + }), + ), + ); + + expense.status = this.computeStatus( + splits.map((split) => split.amountOwed), + splits.map((split) => split.amountPaid), + ); + await this.expensesRepository.save(expense); + + const dtoResponse = this.toExpenseDto({ ...expense, splits }); + await this.chatGateway.sendExpenseNew(conversation.id, dtoResponse); + return dtoResponse; + } + + async updateSplits( + expenseId: string, + actorUserId: string, + dto: UpdateExpenseSplitsDto, + ): Promise { + const expense = await this.getExpenseOrThrow(expenseId); + if (expense.createdBy !== actorUserId) { + throw new ForbiddenException('Only the expense creator can update splits.'); + } + + const participants = await this.getParticipantIds(expense.conversationId); + const totalAmount = this.toStroops(expense.totalAmount); + const splitDrafts = this.buildSplitDrafts( + participants, + expense.createdBy, + totalAmount, + dto.splitType, + dto.splits, + ); + + const existingSplits = await this.splitsRepository.find({ where: { expenseId } }); + const existingByUser = new Map(existingSplits.map((split) => [split.userId, split])); + + const nextSplits = splitDrafts.map((draft) => { + const current = existingByUser.get(draft.userId); + const currentPaid = current ? this.toStroops(current.amountPaid) : BigInt(0); + const cappedPaid = currentPaid > draft.amountOwed ? draft.amountOwed : currentPaid; + const isPaid = cappedPaid >= draft.amountOwed; + const paidAt = isPaid ? (current?.paidAt ?? new Date()) : null; + + return this.splitsRepository.create({ + expenseId, + userId: draft.userId, + amountOwed: this.fromStroops(draft.amountOwed), + amountPaid: this.fromStroops(cappedPaid), + isPaid, + paidAt, + }); + }); + + await this.splitsRepository.delete({ expenseId }); + const savedSplits = await this.splitsRepository.save(nextSplits); + + expense.splitType = dto.splitType; + expense.status = this.computeStatus( + savedSplits.map((split) => split.amountOwed), + savedSplits.map((split) => split.amountPaid), + ); + await this.expensesRepository.save(expense); + + return this.toExpenseDto({ ...expense, splits: savedSplits }); + } + + async markPaid( + expenseId: string, + userId: string, + amount: string, + markPaidAt: Date = new Date(), + ): Promise { + const expense = await this.getExpenseOrThrow(expenseId); + const split = await this.splitsRepository.findOne({ where: { expenseId, userId } }); + if (!split) { + throw new NotFoundException('Split not found for this user.'); + } + + const amountToApply = this.toStroops(amount); + if (amountToApply <= BigInt(0)) { + throw new BadRequestException('Paid amount must be greater than zero.'); + } + + const amountPaid = this.toStroops(split.amountPaid); + const amountOwed = this.toStroops(split.amountOwed); + const nextPaid = amountPaid + amountToApply; + + if (nextPaid > amountOwed) { + throw new BadRequestException('Paid amount exceeds the owed split amount.'); + } + + split.amountPaid = this.fromStroops(nextPaid); + split.isPaid = nextPaid >= amountOwed; + split.paidAt = split.isPaid ? markPaidAt : split.paidAt; + await this.splitsRepository.save(split); + + const splits = await this.splitsRepository.find({ where: { expenseId } }); + expense.status = this.computeStatus( + splits.map((item) => item.amountOwed), + splits.map((item) => item.amountPaid), + ); + await this.expensesRepository.save(expense); + + return this.toExpenseDto({ ...expense, splits }); + } + + async settleViaTransfer(expenseId: string, payerUserId: string): Promise { + const expense = await this.getExpenseOrThrow(expenseId); + const split = await this.splitsRepository.findOne({ + where: { expenseId, userId: payerUserId }, + }); + if (!split) { + throw new NotFoundException('Split not found for this user.'); + } + if (expense.createdBy === payerUserId) { + throw new BadRequestException('Expense creator cannot settle against themselves.'); + } + + const remaining = this.toStroops(split.amountOwed) - this.toStroops(split.amountPaid); + if (remaining <= BigInt(0)) { + throw new BadRequestException('This split is already fully settled.'); + } + + const creator = await this.usersRepository.findOne({ + where: { id: expense.createdBy }, + select: ['id', 'username'], + }); + if (!creator) { + throw new NotFoundException('Expense creator not found.'); + } + if (!creator.username) { + throw new BadRequestException('Expense creator is missing a username for in-chat settlement.'); + } + + const rawCommand = `/send @${creator.username} ${this.fromStroops(remaining)} ${expense.tokenId}`; + const preview = await this.inChatTransfersService.initiateTransfer( + payerUserId, + expense.conversationId, + { rawCommand }, + ); + const confirmed = await this.inChatTransfersService.confirmTransfer(preview.transferId, payerUserId); + + if (confirmed.status !== 'completed') { + throw new BadRequestException('On-chain settlement failed to confirm.'); + } + + const updated = await this.markPaid(expenseId, payerUserId, this.fromStroops(remaining)); + await this.chatGateway.sendExpenseSettled(expense.conversationId, updated); + return updated; + } + + async getExpenses( + groupId: string, + userId: string, + query: GetGroupExpensesQueryDto, + ): Promise { + const conversation = await this.getGroupConversationOrThrow(groupId); + const participants = await this.getParticipantIds(conversation.id); + this.assertParticipant(participants, userId); + + const where: { groupId: string; status?: GroupExpenseStatus } = { groupId }; + if (query.status) { + where.status = query.status; + } + + const [items, total] = await this.expensesRepository.findAndCount({ + where, + relations: ['splits'], + order: { createdAt: 'DESC' }, + skip: (query.page - 1) * query.limit, + take: query.limit, + }); + + return { + items: items.map((item) => this.toExpenseDto(item)), + total, + page: query.page, + limit: query.limit, + }; + } + + async getUnsettledBalance(groupId: string, userId: string): Promise { + const summary = await this.getGroupBalanceSummary(groupId, userId); + const current = summary.find((item) => item.userId === userId) ?? { + userId, + netOwed: '0.0000000', + netOwedTo: '0.0000000', + }; + + const totalOwed = current.netOwed; + const totalOwedTo = current.netOwedTo; + const netBalance = this.fromStroops(this.toStroops(totalOwedTo) - this.toStroops(totalOwed)); + + return { + userId, + totalOwed, + totalOwedTo, + netBalance, + }; + } + + async getGroupBalanceSummary( + groupId: string, + userId: string, + ): Promise { + const conversation = await this.getGroupConversationOrThrow(groupId); + const participants = await this.getParticipantIds(conversation.id); + this.assertParticipant(participants, userId); + + const expenses = await this.expensesRepository.find({ + where: { + groupId, + status: In([GroupExpenseStatus.OPEN, GroupExpenseStatus.PARTIALLY_SETTLED]), + }, + relations: ['splits'], + }); + + const summary = new Map(); + for (const participantId of participants) { + summary.set(participantId, { netOwed: BigInt(0), netOwedTo: BigInt(0) }); + } + + for (const expense of expenses) { + for (const split of expense.splits ?? []) { + if (split.userId === expense.createdBy) { + continue; + } + const remaining = this.toStroops(split.amountOwed) - this.toStroops(split.amountPaid); + if (remaining <= BigInt(0)) { + continue; + } + + const debtor = summary.get(split.userId); + const creditor = summary.get(expense.createdBy); + if (debtor) { + debtor.netOwed += remaining; + } + if (creditor) { + creditor.netOwedTo += remaining; + } + } + } + + return Array.from(summary.entries()).map(([memberId, balances]) => ({ + userId: memberId, + netOwed: this.fromStroops(balances.netOwed), + netOwedTo: this.fromStroops(balances.netOwedTo), + })); + } + + async getBalanceView(groupId: string, userId: string): Promise { + const [summary, unsettledBalance] = await Promise.all([ + this.getGroupBalanceSummary(groupId, userId), + this.getUnsettledBalance(groupId, userId), + ]); + + return { + groupId, + unsettledBalance, + summary, + }; + } + + private async getExpenseOrThrow(expenseId: string): Promise { + const expense = await this.expensesRepository.findOne({ + where: { id: expenseId }, + relations: ['splits'], + }); + if (!expense) { + throw new NotFoundException('Expense not found.'); + } + return expense; + } + + private async getGroupConversationOrThrow(groupId: string): Promise { + const conversation = await this.conversationsRepository.findOne({ + where: { chainGroupId: groupId, type: ConversationType.GROUP }, + }); + if (!conversation) { + throw new NotFoundException('Group conversation not found.'); + } + return conversation; + } + + private async getParticipantIds(conversationId: string): Promise { + const participants = await this.participantsRepository.find({ + where: { conversationId }, + select: ['userId'], + }); + return participants.map((item) => item.userId); + } + + private assertParticipant(participants: string[], userId: string): void { + if (!participants.includes(userId)) { + throw new ForbiddenException('User is not a participant in this group conversation.'); + } + } + + private buildSplitDrafts( + participantIds: string[], + createdBy: string, + totalAmount: bigint, + splitType: GroupExpenseSplitType, + splits: Array, + ): SplitDraft[] { + const participantSet = new Set(participantIds); + if (!participantSet.has(createdBy)) { + throw new BadRequestException('Expense creator must be a group participant.'); + } + + if (splitType === GroupExpenseSplitType.EQUAL) { + const baseShare = totalAmount / BigInt(participantIds.length); + const remainder = totalAmount % BigInt(participantIds.length); + return participantIds.map((participantId) => ({ + userId: participantId, + amountOwed: baseShare + (participantId === createdBy ? remainder : BigInt(0)), + })); + } + + if (!splits.length) { + throw new BadRequestException('Splits are required for CUSTOM and PERCENTAGE split types.'); + } + + const seen = new Set(); + for (const split of splits) { + if (!participantSet.has(split.userId)) { + throw new BadRequestException(`User ${split.userId} is not in the group conversation.`); + } + if (seen.has(split.userId)) { + throw new BadRequestException(`Duplicate split user ${split.userId}.`); + } + seen.add(split.userId); + } + + if (splitType === GroupExpenseSplitType.CUSTOM) { + const drafts = splits.map((split) => { + if (split.amount === undefined) { + throw new BadRequestException('Custom split entries require amount.'); + } + return { + userId: split.userId, + amountOwed: this.toStroops(split.amount), + }; + }); + + const total = drafts.reduce((acc, split) => acc + split.amountOwed, BigInt(0)); + if (total !== totalAmount) { + throw new BadRequestException('Custom split amounts must sum to the expense total.'); + } + + return drafts; + } + + const percentageEntries = splits.map((split) => { + if (split.percentage === undefined) { + throw new BadRequestException('Percentage split entries require percentage.'); + } + return { + userId: split.userId, + percentage: split.percentage, + }; + }); + + const totalPercentage = percentageEntries.reduce((acc, split) => acc + split.percentage, 0); + if (Math.abs(totalPercentage - 100) > 0.0001) { + throw new BadRequestException('Percentage splits must total 100%.'); + } + + const drafts = percentageEntries.map((entry) => ({ + userId: entry.userId, + amountOwed: this.percentageToAmount(totalAmount, entry.percentage), + })); + + const distributed = drafts.reduce((acc, split) => acc + split.amountOwed, BigInt(0)); + const remainder = totalAmount - distributed; + if (remainder !== BigInt(0)) { + const creatorSplit = + drafts.find((split) => split.userId === createdBy) ?? + (() => { + const empty = { userId: createdBy, amountOwed: BigInt(0) }; + drafts.push(empty); + return empty; + })(); + creatorSplit.amountOwed += remainder; + } + + const postTotal = drafts.reduce((acc, split) => acc + split.amountOwed, BigInt(0)); + if (postTotal !== totalAmount) { + throw new BadRequestException('Percentage split calculation failed validation.'); + } + + return drafts; + } + + private computeStatus(owedAmounts: string[], paidAmounts: string[]): GroupExpenseStatus { + if (!owedAmounts.length || owedAmounts.length !== paidAmounts.length) { + return GroupExpenseStatus.OPEN; + } + let paidCount = 0; + for (let i = 0; i < owedAmounts.length; i += 1) { + const owed = this.toStroops(owedAmounts[i]); + const paid = this.toStroops(paidAmounts[i]); + if (paid >= owed) { + paidCount += 1; + } + } + if (paidCount === owedAmounts.length) { + return GroupExpenseStatus.SETTLED; + } + if (paidCount > 0) { + return GroupExpenseStatus.PARTIALLY_SETTLED; + } + return GroupExpenseStatus.OPEN; + } + + private percentageToAmount(totalAmount: bigint, percentage: number): bigint { + const bps = BigInt(Math.round(percentage * 10_000)); + return (totalAmount * bps) / BigInt(1_000_000); + } + + private toStroops(value: number | string): bigint { + const amount = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(amount) || amount < 0) { + throw new BadRequestException('Invalid amount.'); + } + return BigInt(Math.round(amount * STROOPS_FACTOR)); + } + + private fromStroops(value: bigint): string { + return (Number(value) / STROOPS_FACTOR).toFixed(7); + } + + private toExpenseDto(expense: GroupExpense): GroupExpenseResponseDto { + return { + id: expense.id, + groupId: expense.groupId, + conversationId: expense.conversationId, + createdBy: expense.createdBy, + title: expense.title, + totalAmount: expense.totalAmount, + tokenId: expense.tokenId, + splitType: expense.splitType, + status: expense.status, + createdAt: expense.createdAt, + splits: (expense.splits ?? []).map((split) => ({ + expenseId: split.expenseId, + userId: split.userId, + amountOwed: split.amountOwed, + amountPaid: split.amountPaid, + isPaid: split.isPaid, + paidAt: split.paidAt, + })), + }; + } +} diff --git a/src/messaging/gateways/chat.gateway.ts b/src/messaging/gateways/chat.gateway.ts index 0d1df1647..28d811c49 100644 --- a/src/messaging/gateways/chat.gateway.ts +++ b/src/messaging/gateways/chat.gateway.ts @@ -286,10 +286,16 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { this.server.to(roomId).emit('reaction:remove', event); } - async sendPollUpdated(conversationId: string, payload: Record): Promise { + async sendExpenseNew(conversationId: string, expense: Record): Promise { const roomId = `conversation:${conversationId}`; - await this.eventReplayService.storeEvent(roomId, 'poll:updated', payload); - this.server.to(roomId).emit('poll:updated', payload); + await this.eventReplayService.storeEvent(roomId, 'expense:new', expense); + this.server.to(roomId).emit('expense:new', expense); + } + + async sendExpenseSettled(conversationId: string, expense: Record): Promise { + const roomId = `conversation:${conversationId}`; + await this.eventReplayService.storeEvent(roomId, 'expense:settled', expense); + this.server.to(roomId).emit('expense:settled', expense); } emitMessagePinned(conversationId: string, payload: Record): void { diff --git a/test/group-expenses.e2e-spec.ts b/test/group-expenses.e2e-spec.ts new file mode 100644 index 000000000..68d903aa1 --- /dev/null +++ b/test/group-expenses.e2e-spec.ts @@ -0,0 +1,319 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { randomUUID } from 'crypto'; +import { Conversation, ConversationType } from '../src/conversations/entities/conversation.entity'; +import { ConversationParticipant } from '../src/conversations/entities/conversation-participant.entity'; +import { InChatTransfersService } from '../src/in-chat-transfers/in-chat-transfers.service'; +import { ChatGateway } from '../src/messaging/gateways/chat.gateway'; +import { UsersRepository } from '../src/users/users.repository'; +import { ExpenseSplit } from '../src/group-expenses/entities/expense-split.entity'; +import { + GroupExpense, + GroupExpenseSplitType, + GroupExpenseStatus, +} from '../src/group-expenses/entities/group-expense.entity'; +import { GroupExpensesController } from '../src/group-expenses/group-expenses.controller'; +import { GroupExpensesService } from '../src/group-expenses/group-expenses.service'; + +type InMemoryStore = { + conversations: Conversation[]; + participants: ConversationParticipant[]; + expenses: GroupExpense[]; + splits: ExpenseSplit[]; +}; + +describe('GroupExpensesController (e2e)', () => { + let controller: GroupExpensesController; + let store: InMemoryStore; + let inChatTransfersMock: { + initiateTransfer: jest.Mock; + confirmTransfer: jest.Mock; + }; + let chatGatewayMock: { + sendExpenseNew: jest.Mock; + sendExpenseSettled: jest.Mock; + }; + + const groupId = 'group-chain-id-777'; + const conversationId = '10000000-0000-0000-0000-000000000041'; + const creatorId = '00000000-0000-0000-0000-000000000011'; + const memberA = '00000000-0000-0000-0000-000000000012'; + const memberB = '00000000-0000-0000-0000-000000000013'; + + beforeEach(async () => { + inChatTransfersMock = { + initiateTransfer: jest.fn().mockResolvedValue({ transferId: 'tr-1' }), + confirmTransfer: jest.fn().mockResolvedValue({ status: 'completed' }), + }; + chatGatewayMock = { + sendExpenseNew: jest.fn().mockResolvedValue(undefined), + sendExpenseSettled: jest.fn().mockResolvedValue(undefined), + }; + + store = { + conversations: [ + { + id: conversationId, + type: ConversationType.GROUP, + title: 'Group Conversation', + chainGroupId: groupId, + participants: [], + messages: [], + transfers: [], + pinnedMessages: [], + expenses: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + participants: [ + makeParticipant(conversationId, creatorId), + makeParticipant(conversationId, memberA), + makeParticipant(conversationId, memberB), + ], + expenses: [], + splits: [], + }; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [GroupExpensesController], + providers: [ + GroupExpensesService, + { + provide: InChatTransfersService, + useValue: inChatTransfersMock, + }, + { + provide: ChatGateway, + useValue: chatGatewayMock, + }, + { + provide: UsersRepository, + useValue: { + findOne: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve({ id: where.id, username: 'creator-settle' }), + ), + }, + }, + repositoryProvider(getRepositoryToken(Conversation), { + findOne: async ({ where }: { where: { chainGroupId: string; type: ConversationType } }) => + store.conversations.find( + (conversation) => + conversation.chainGroupId === where.chainGroupId && conversation.type === where.type, + ) ?? null, + }), + repositoryProvider(getRepositoryToken(ConversationParticipant), { + find: async ({ where }: { where: { conversationId: string } }) => + store.participants.filter((participant) => participant.conversationId === where.conversationId), + }), + repositoryProvider(getRepositoryToken(GroupExpense), { + create: (entity: Partial) => entity, + save: async (entity: Partial) => { + if (entity.id) { + const index = store.expenses.findIndex((expense) => expense.id === entity.id); + if (index >= 0) { + store.expenses[index] = { + ...store.expenses[index], + ...entity, + } as GroupExpense; + return store.expenses[index]; + } + } + + const saved = { + id: randomUUID(), + groupId: entity.groupId!, + conversationId: entity.conversationId!, + createdBy: entity.createdBy!, + title: entity.title!, + totalAmount: entity.totalAmount!, + tokenId: entity.tokenId!, + splitType: entity.splitType!, + status: entity.status ?? GroupExpenseStatus.OPEN, + createdAt: new Date(), + splits: [], + } as unknown as GroupExpense; + store.expenses.push(saved); + return saved; + }, + findOne: async ({ where }: { where: { id: string } }) => + store.expenses.find((expense) => expense.id === where.id) ?? null, + findAndCount: async ({ + where, + skip, + take, + }: { + where: { groupId: string; status?: GroupExpenseStatus }; + skip: number; + take: number; + }) => { + const filtered = store.expenses + .filter( + (expense) => + expense.groupId === where.groupId && + (!where.status || expense.status === where.status), + ) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(skip, skip + take) + .map((expense) => ({ + ...expense, + splits: store.splits.filter((split) => split.expenseId === expense.id), + })); + const total = store.expenses.filter( + (expense) => + expense.groupId === where.groupId && + (!where.status || expense.status === where.status), + ).length; + return [filtered, total]; + }, + find: async ({ where }: { where: { groupId: string; status?: GroupExpenseStatus[] } }) => { + const statuses = where.status ?? []; + return store.expenses + .filter( + (expense) => + expense.groupId === where.groupId && + (statuses.length === 0 || statuses.includes(expense.status)), + ) + .map((expense) => ({ + ...expense, + splits: store.splits.filter((split) => split.expenseId === expense.id), + })); + }, + }), + repositoryProvider(getRepositoryToken(ExpenseSplit), { + create: (entity: Partial) => entity, + save: async (entity: Partial | Partial[]) => { + if (Array.isArray(entity)) { + const saved: ExpenseSplit[] = []; + for (const row of entity) { + store.splits = store.splits.filter( + (split) => !(split.expenseId === row.expenseId && split.userId === row.userId), + ); + const next = { + expenseId: row.expenseId!, + userId: row.userId!, + amountOwed: row.amountOwed!, + amountPaid: row.amountPaid!, + isPaid: row.isPaid!, + paidAt: row.paidAt ?? null, + expense: undefined as never, + user: undefined as never, + } as ExpenseSplit; + store.splits.push(next); + saved.push(next); + } + return saved; + } + + const index = store.splits.findIndex( + (split) => split.expenseId === entity.expenseId && split.userId === entity.userId, + ); + const next = { + ...(index >= 0 ? store.splits[index] : {}), + ...entity, + paidAt: entity.paidAt ?? null, + } as ExpenseSplit; + if (index >= 0) { + store.splits[index] = next; + } else { + store.splits.push(next); + } + return next; + }, + findOne: async ({ where }: { where: { expenseId: string; userId: string } }) => + store.splits.find( + (split) => split.expenseId === where.expenseId && split.userId === where.userId, + ) ?? null, + find: async ({ where }: { where: { expenseId: string } }) => + store.splits.filter((split) => split.expenseId === where.expenseId), + delete: async ({ expenseId }: { expenseId: string }) => { + store.splits = store.splits.filter((split) => split.expenseId !== expenseId); + return { affected: 1 }; + }, + }), + ], + }).compile(); + + controller = moduleFixture.get(GroupExpensesController); + }); + + it('creates equal split expense with creator remainder', async () => { + const created = await controller.createExpense(groupId, creatorId, { + title: 'Brunch', + totalAmount: 10, + tokenId: 'usdc', + splitType: GroupExpenseSplitType.EQUAL, + }); + + expect(created.splits).toHaveLength(3); + expect(created.splits.find((split) => split.userId === creatorId)?.amountOwed).toBe('3.3333334'); + }); + + it('lists expenses with status filter and pagination', async () => { + const openExpense = await controller.createExpense(groupId, creatorId, { + title: 'Taxi', + totalAmount: 6, + tokenId: 'XLM', + splitType: GroupExpenseSplitType.EQUAL, + }); + await controller.settleExpense(openExpense.id, memberA); + + const response = await controller.getExpenses(groupId, creatorId, { + status: GroupExpenseStatus.PARTIALLY_SETTLED, + page: 1, + limit: 10, + }); + + expect(response.items).toHaveLength(1); + expect(response.items[0].status).toBe(GroupExpenseStatus.PARTIALLY_SETTLED); + }); + + it('settle endpoint triggers in-chat transfer and marks split paid', async () => { + const created = await controller.createExpense(groupId, creatorId, { + title: 'Dinner', + totalAmount: 30, + tokenId: 'USDC', + splitType: GroupExpenseSplitType.EQUAL, + }); + + const settled = await controller.settleExpense(created.id, memberA); + + const split = settled.splits.find((row) => row.userId === memberA); + expect(inChatTransfersMock.initiateTransfer).toHaveBeenCalled(); + expect(split?.isPaid).toBe(true); + }); + + it('returns group balance with net owed and owed-to', async () => { + await controller.createExpense(groupId, creatorId, { + title: 'Event', + totalAmount: 30, + tokenId: 'USDC', + splitType: GroupExpenseSplitType.EQUAL, + }); + + const balance = await controller.getBalance(groupId, creatorId); + + const creator = balance.summary.find((row) => row.userId === creatorId); + const member = balance.summary.find((row) => row.userId === memberA); + expect(creator?.netOwedTo).toBe('20.0000000'); + expect(member?.netOwed).toBe('10.0000000'); + }); +}); + +function makeParticipant(conversationId: string, userId: string): ConversationParticipant { + return { + id: randomUUID(), + conversationId, + userId, + conversation: undefined as never, + user: undefined as never, + createdAt: new Date(), + }; +} + +function repositoryProvider(provide: string | Function, value: T) { + return { + provide, + useValue: value, + }; +}