diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts index 40c6ad3..c0e875b 100644 --- a/src/analytics/analytics.module.ts +++ b/src/analytics/analytics.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Bounty, Issue, Repository } from '../common/entities'; +import { Bounty, Issue, Payment, Repository } from '../common/entities'; import { AnalyticsService } from './analytics.service'; import { AnalyticsController } from './analytics.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Bounty, Issue, Repository])], + imports: [TypeOrmModule.forFeature([Bounty, Issue, Payment, Repository])], controllers: [AnalyticsController], providers: [AnalyticsService], exports: [AnalyticsService], diff --git a/src/analytics/analytics.service.spec.ts b/src/analytics/analytics.service.spec.ts new file mode 100644 index 0000000..cadf3ed --- /dev/null +++ b/src/analytics/analytics.service.spec.ts @@ -0,0 +1,82 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AnalyticsService } from './analytics.service'; +import { Bounty, Issue, Payment, Repository as RepositoryEntity } from '../common/entities'; +import { BountyStatus, PaymentStatus } from '../common/enums'; + +function createMockQueryBuilder(result: { raw?: unknown; many?: unknown[] }) { + const qb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue(result.raw), + getRawMany: jest.fn().mockResolvedValue(result.many ?? []), + getMany: jest.fn().mockResolvedValue(result.many ?? []), + }; + return qb; +} + +describe('AnalyticsService', () => { + let service: AnalyticsService; + let bountyRepo: { find: jest.Mock; count: jest.Mock; createQueryBuilder: jest.Mock }; + let issueRepo: { find: jest.Mock }; + let paymentRepo: { createQueryBuilder: jest.Mock }; + let repositoryRepo: { count: jest.Mock }; + + beforeEach(async () => { + bountyRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn(), createQueryBuilder: jest.fn() }; + issueRepo = { find: jest.fn().mockResolvedValue([]) }; + paymentRepo = { createQueryBuilder: jest.fn() }; + repositoryRepo = { count: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { provide: getRepositoryToken(Bounty), useValue: bountyRepo }, + { provide: getRepositoryToken(Issue), useValue: issueRepo }, + { provide: getRepositoryToken(Payment), useValue: paymentRepo }, + { provide: getRepositoryToken(RepositoryEntity), useValue: repositoryRepo }, + ], + }).compile(); + + service = module.get(AnalyticsService); + }); + + describe('forContributor', () => { + it('computes lifetimeEarnings and topClients from confirmed payments ledger', async () => { + bountyRepo.find.mockResolvedValue([ + { + id: 'b-1', + claimedById: 'user-1', + amount: '1000.0000000', + status: BountyStatus.PAID, + claimedAt: new Date('2026-01-01T00:00:00Z'), + mergedAt: new Date('2026-01-01T02:00:00Z'), + paidAt: new Date('2026-01-01T02:30:00Z'), + issueId: 'issue-1', + sponsorId: 'sponsor-1', + }, + ]); + issueRepo.find.mockResolvedValue([]); + + const earningsQb = createMockQueryBuilder({ raw: { total: '200.0000000' } }); + const clientsQb = createMockQueryBuilder({ + many: [{ sponsorId: 'sponsor-1', totalPaid: '200.0000000' }], + }); + + paymentRepo.createQueryBuilder + .mockReturnValueOnce(earningsQb) + .mockReturnValueOnce(clientsQb); + + const result = await service.forContributor('user-1'); + + expect(result.lifetimeEarnings).toBe(200); + expect(result.topClients).toEqual([{ sponsorId: 'sponsor-1', totalPaid: 200 }]); + }); + }); +}); diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index 427c778..3f06143 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -4,9 +4,11 @@ import { Repository } from 'typeorm'; import { Bounty, Issue, + Payment, Repository as RepositoryEntity, } from '../common/entities'; -import { BountyStatus } from '../common/enums'; +import { BountyStatus, PaymentStatus } from '../common/enums'; +import { computeContributorTotalEarnings } from '../reputation/contributor-earnings.util'; export interface ContributorAnalytics { lifetimeEarnings: number; @@ -24,6 +26,7 @@ export class AnalyticsService { constructor( @InjectRepository(Bounty) private readonly bountyRepo: Repository, @InjectRepository(Issue) private readonly issueRepo: Repository, + @InjectRepository(Payment) private readonly paymentRepo: Repository, @InjectRepository(RepositoryEntity) private readonly repositoryRepo: Repository, ) {} @@ -37,7 +40,10 @@ export class AnalyticsService { [BountyStatus.MERGED, BountyStatus.PAID].includes(b.status), ); - const lifetimeEarnings = paid.reduce((sum, b) => sum + Number(b.amount), 0); + const lifetimeEarnings = await computeContributorTotalEarnings( + this.paymentRepo, + userId, + ); const mergeRate = claimed.length > 0 ? (merged.length / claimed.length) * 100 : 0; @@ -75,16 +81,23 @@ export class AnalyticsService { .sort(([a], [b]) => a.localeCompare(b)) .map(([date, count]) => ({ date, count })); - const clientTotals = new Map(); - for (const bounty of paid) { - if (!bounty.sponsorId) continue; - clientTotals.set( - bounty.sponsorId, - (clientTotals.get(bounty.sponsorId) ?? 0) + Number(bounty.amount), - ); - } - const topClients = [...clientTotals.entries()] - .map(([sponsorId, totalPaid]) => ({ sponsorId, totalPaid })) + // Compute topClients by summing actual confirmed payments to this contributor by sponsor + const clientPayments = await this.paymentRepo + .createQueryBuilder('payment') + .innerJoin('payment.escrow', 'escrow') + .select('escrow.sponsorId', 'sponsorId') + .addSelect('COALESCE(SUM(payment.amount), 0)', 'totalPaid') + .where('payment.recipientId = :userId', { userId }) + .andWhere('payment.status = :status', { status: PaymentStatus.CONFIRMED }) + .andWhere('escrow.sponsorId IS NOT NULL') + .groupBy('escrow.sponsorId') + .getRawMany<{ sponsorId: string; totalPaid: string }>(); + + const topClients = clientPayments + .map((row) => ({ + sponsorId: row.sponsorId, + totalPaid: Number(row.totalPaid), + })) .sort((a, b) => b.totalPaid - a.totalPaid) .slice(0, 10); diff --git a/src/reputation/contributor-earnings.util.ts b/src/reputation/contributor-earnings.util.ts new file mode 100644 index 0000000..4082a72 --- /dev/null +++ b/src/reputation/contributor-earnings.util.ts @@ -0,0 +1,24 @@ +import { Repository } from 'typeorm'; +import { Payment } from '../common/entities'; +import { PaymentStatus } from '../common/enums'; + +/** + * Computes a contributor's total lifetime earnings from confirmed Payment + * ledger rows, ensuring team splits and individual payouts reflect the actual + * funds received rather than full Bounty face values. + */ +export async function computeContributorTotalEarnings( + paymentRepo: Repository, + recipientId: string, +): Promise { + const row = await paymentRepo + .createQueryBuilder('payment') + .select('COALESCE(SUM(payment.amount), 0)', 'total') + .where('payment.recipientId = :recipientId', { recipientId }) + .andWhere('payment.status = :status', { + status: PaymentStatus.CONFIRMED, + }) + .getRawOne<{ total: string }>(); + + return Number(row?.total ?? 0); +} diff --git a/src/reputation/reputation.module.ts b/src/reputation/reputation.module.ts index c43c986..ae79475 100644 --- a/src/reputation/reputation.module.ts +++ b/src/reputation/reputation.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Bounty, Issue, ReputationSnapshot } from '../common/entities'; +import { Bounty, Issue, Payment, ReputationSnapshot } from '../common/entities'; import { ReputationService } from './reputation.service'; import { ReputationController } from './reputation.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Bounty, Issue, ReputationSnapshot])], + imports: [ + TypeOrmModule.forFeature([Bounty, Issue, Payment, ReputationSnapshot]), + ], controllers: [ReputationController], providers: [ReputationService], exports: [ReputationService], diff --git a/src/reputation/reputation.service.spec.ts b/src/reputation/reputation.service.spec.ts new file mode 100644 index 0000000..f30a1f9 --- /dev/null +++ b/src/reputation/reputation.service.spec.ts @@ -0,0 +1,97 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ReputationService } from './reputation.service'; +import { Bounty, Issue, Payment, ReputationSnapshot } from '../common/entities'; +import { BountyStatus, PaymentStatus } from '../common/enums'; + +function createMockQueryBuilder(result: { raw?: unknown; many?: unknown[] }) { + const qb = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue(result.raw), + getRawMany: jest.fn().mockResolvedValue(result.many ?? []), + getMany: jest.fn().mockResolvedValue(result.many ?? []), + }; + return qb; +} + +describe('ReputationService', () => { + let service: ReputationService; + let bountyRepo: { find: jest.Mock }; + let issueRepo: { find: jest.Mock }; + let paymentRepo: { createQueryBuilder: jest.Mock }; + let snapshotRepo: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; find: jest.Mock }; + + beforeEach(async () => { + bountyRepo = { find: jest.fn().mockResolvedValue([]) }; + issueRepo = { find: jest.fn().mockResolvedValue([]) }; + paymentRepo = { createQueryBuilder: jest.fn() }; + snapshotRepo = { + create: jest.fn((d) => d), + save: jest.fn((s) => Promise.resolve({ id: 'snap-1', ...s })), + findOne: jest.fn(), + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReputationService, + { provide: getRepositoryToken(Bounty), useValue: bountyRepo }, + { provide: getRepositoryToken(Issue), useValue: issueRepo }, + { provide: getRepositoryToken(Payment), useValue: paymentRepo }, + { provide: getRepositoryToken(ReputationSnapshot), useValue: snapshotRepo }, + ], + }).compile(); + + service = module.get(ReputationService); + }); + + describe('computeAndSave', () => { + it('computes totalEarnings from confirmed payments rather than full Bounty.amount for team splits', async () => { + // Bounty was for $1000, but user received a 20% split ($200) + bountyRepo.find.mockResolvedValue([ + { + id: 'b-1', + claimedById: 'user-1', + amount: '1000.0000000', + status: BountyStatus.PAID, + claimedAt: new Date('2026-01-01T00:00:00Z'), + mergedAt: new Date('2026-01-01T02:00:00Z'), + issueId: 'issue-1', + }, + ]); + issueRepo.find.mockResolvedValue([]); + + const qb = createMockQueryBuilder({ raw: { total: '200.0000000' } }); + paymentRepo.createQueryBuilder.mockReturnValue(qb); + + const snapshot = await service.computeAndSave('user-1'); + + expect(paymentRepo.createQueryBuilder).toHaveBeenCalledWith('payment'); + expect(qb.where).toHaveBeenCalledWith('payment.recipientId = :recipientId', { + recipientId: 'user-1', + }); + expect(qb.andWhere).toHaveBeenCalledWith('payment.status = :status', { + status: PaymentStatus.CONFIRMED, + }); + expect(snapshot.totalEarnings).toBe('200.0000000'); + }); + + it('handles zero confirmed payments correctly', async () => { + bountyRepo.find.mockResolvedValue([]); + issueRepo.find.mockResolvedValue([]); + + const qb = createMockQueryBuilder({ raw: undefined }); + paymentRepo.createQueryBuilder.mockReturnValue(qb); + + const snapshot = await service.computeAndSave('user-1'); + expect(snapshot.totalEarnings).toBe('0.0000000'); + }); + }); +}); diff --git a/src/reputation/reputation.service.ts b/src/reputation/reputation.service.ts index 0404858..a8c7096 100644 --- a/src/reputation/reputation.service.ts +++ b/src/reputation/reputation.service.ts @@ -1,14 +1,16 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Bounty, Issue, ReputationSnapshot } from '../common/entities'; +import { Bounty, Issue, Payment, ReputationSnapshot } from '../common/entities'; import { BountyStatus } from '../common/enums'; +import { computeContributorTotalEarnings } from './contributor-earnings.util'; @Injectable() export class ReputationService { constructor( @InjectRepository(Bounty) private readonly bountyRepo: Repository, @InjectRepository(Issue) private readonly issueRepo: Repository, + @InjectRepository(Payment) private readonly paymentRepo: Repository, @InjectRepository(ReputationSnapshot) private readonly snapshotRepo: Repository, ) {} @@ -16,6 +18,9 @@ export class ReputationService { /** * Recomputes a contributor's reputation stats from their historical bounty * activity and appends a new snapshot row. + * + * Lifetime earnings are summed from confirmed Payment ledger rows rather than + * full Bounty.amount values, ensuring split-team payouts are accurately reflected. */ async computeAndSave(userId: string): Promise { const claimedBounties = await this.bountyRepo.find({ @@ -24,9 +29,12 @@ export class ReputationService { const merged = claimedBounties.filter((b) => [BountyStatus.MERGED, BountyStatus.PAID].includes(b.status), ); - const paid = claimedBounties.filter((b) => b.status === BountyStatus.PAID); - const totalEarnings = paid.reduce((sum, b) => sum + Number(b.amount), 0); + const totalEarnings = await computeContributorTotalEarnings( + this.paymentRepo, + userId, + ); + const completionRate = claimedBounties.length > 0 ? (merged.length / claimedBounties.length) * 100