From a29446edcc03615b3aab33458775b1222e68e358 Mon Sep 17 00:00:00 2001 From: Antigravity Bot Date: Thu, 20 Aug 2026 02:54:24 +0800 Subject: [PATCH] Fix: Calculate total/lifetime earnings from Payment ledger (#57) --- src/analytics/analytics.module.ts | 4 +- src/analytics/analytics.service.spec.ts | 71 +++++++++++++++++++++ src/analytics/analytics.service.ts | 14 ++--- src/common/utils/earnings.util.ts | 41 +++++++++++++ src/reputation/reputation.module.ts | 4 +- src/reputation/reputation.service.spec.ts | 75 +++++++++++++++++++++++ src/reputation/reputation.service.ts | 9 ++- 7 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 src/analytics/analytics.service.spec.ts create mode 100644 src/common/utils/earnings.util.ts create mode 100644 src/reputation/reputation.service.spec.ts diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts index 40c6ad3..111ce8a 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, Repository, Payment } 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, Repository, Payment])], 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..244e6f9 --- /dev/null +++ b/src/analytics/analytics.service.spec.ts @@ -0,0 +1,71 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AnalyticsService } from './analytics.service'; +import { Bounty, Issue, Repository as RepositoryEntity, Payment } from '../common/entities'; +import { BountyStatus } 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(), + getRawOne: jest.fn().mockResolvedValue(result.raw), + getRawMany: 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 repositoryRepo: { count: jest.Mock }; + let paymentRepo: { createQueryBuilder: jest.Mock }; + + beforeEach(async () => { + bountyRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn(), createQueryBuilder: jest.fn() }; + issueRepo = { find: jest.fn().mockResolvedValue([]) }; + repositoryRepo = { count: jest.fn() }; + paymentRepo = { createQueryBuilder: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { provide: getRepositoryToken(Bounty), useValue: bountyRepo }, + { provide: getRepositoryToken(Issue), useValue: issueRepo }, + { provide: getRepositoryToken(RepositoryEntity), useValue: repositoryRepo }, + { provide: getRepositoryToken(Payment), useValue: paymentRepo }, + ], + }).compile(); + + service = module.get(AnalyticsService); + }); + + describe('forContributor', () => { + it('computes lifetimeEarnings and topClients from Payment ledger, attributing actual received share for team-splits', async () => { + // Simulate user claiming a team-split bounty. + // E.g. $1000 bounty, but user received 20% = $200. + bountyRepo.find.mockResolvedValue([ + { id: 'b1', amount: '1000', status: BountyStatus.PAID, issueId: 'i1', sponsorId: 's1' }, + ]); + + // The payment query should return the 20% share. + const qb = createMockQueryBuilder({ + many: [{ sponsorId: 's1', total: '200' }], + }); + paymentRepo.createQueryBuilder.mockReturnValue(qb); + + const analytics = await service.forContributor('user-1'); + + // The analytics should have 200, NOT 1000. + expect(analytics.lifetimeEarnings).toBe(200); + expect(analytics.topClients).toEqual([{ sponsorId: 's1', totalPaid: 200 }]); + + expect(paymentRepo.createQueryBuilder).toHaveBeenCalledWith('payment'); + expect(qb.where).toHaveBeenCalledWith('payment.recipientId = :userId', { userId: 'user-1' }); + }); + }); +}); diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index 427c778..1397f7e 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -5,8 +5,10 @@ import { Bounty, Issue, Repository as RepositoryEntity, + Payment, } from '../common/entities'; import { BountyStatus } from '../common/enums'; +import { calculateContributorEarnings } from '../common/utils/earnings.util'; export interface ContributorAnalytics { lifetimeEarnings: number; @@ -26,6 +28,7 @@ export class AnalyticsService { @InjectRepository(Issue) private readonly issueRepo: Repository, @InjectRepository(RepositoryEntity) private readonly repositoryRepo: Repository, + @InjectRepository(Payment) private readonly paymentRepo: Repository, ) {} async forContributor(userId: string): Promise { @@ -37,7 +40,8 @@ export class AnalyticsService { [BountyStatus.MERGED, BountyStatus.PAID].includes(b.status), ); - const lifetimeEarnings = paid.reduce((sum, b) => sum + Number(b.amount), 0); + const { totalEarnings: lifetimeEarnings, earningsBySponsor: clientTotals } = + await calculateContributorEarnings(this.paymentRepo, userId); const mergeRate = claimed.length > 0 ? (merged.length / claimed.length) * 100 : 0; @@ -75,14 +79,6 @@ 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 })) .sort((a, b) => b.totalPaid - a.totalPaid) diff --git a/src/common/utils/earnings.util.ts b/src/common/utils/earnings.util.ts new file mode 100644 index 0000000..9c70108 --- /dev/null +++ b/src/common/utils/earnings.util.ts @@ -0,0 +1,41 @@ +import { Repository } from 'typeorm'; +import { Payment } from '../entities'; +import { PaymentStatus } from '../enums'; + +export interface ContributorEarningsInfo { + totalEarnings: number; + earningsBySponsor: Map; +} + +/** + * Calculates the total lifetime earnings for a contributor from the Payment ledger. + * This sums actual confirmed payments rather than workflow-state proxies (like Bounty.amount), + * ensuring accurate figures for team-split payouts. + */ +export async function calculateContributorEarnings( + paymentRepo: Repository, + userId: string, +): Promise { + const rows = await paymentRepo + .createQueryBuilder('payment') + .innerJoin('payment.escrow', 'escrow') + .select('escrow.sponsorId', 'sponsorId') + .addSelect('SUM(payment.amount)', 'total') + .where('payment.recipientId = :userId', { userId }) + .andWhere('payment.status = :status', { status: PaymentStatus.CONFIRMED }) + .groupBy('escrow.sponsorId') + .getRawMany<{ sponsorId: string | null; total: string }>(); + + let totalEarnings = 0; + const earningsBySponsor = new Map(); + + for (const row of rows) { + const amount = Number(row.total ?? 0); + totalEarnings += amount; + if (row.sponsorId) { + earningsBySponsor.set(row.sponsorId, amount); + } + } + + return { totalEarnings, earningsBySponsor }; +} diff --git a/src/reputation/reputation.module.ts b/src/reputation/reputation.module.ts index c43c986..db471dd 100644 --- a/src/reputation/reputation.module.ts +++ b/src/reputation/reputation.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Bounty, Issue, ReputationSnapshot } from '../common/entities'; +import { Bounty, Issue, ReputationSnapshot, Payment } 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, ReputationSnapshot, Payment])], 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..7096bf0 --- /dev/null +++ b/src/reputation/reputation.service.spec.ts @@ -0,0 +1,75 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ReputationService } from './reputation.service'; +import { Bounty, Issue, ReputationSnapshot, Payment } from '../common/entities'; +import { BountyStatus } 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(), + getRawOne: jest.fn().mockResolvedValue(result.raw), + getRawMany: jest.fn().mockResolvedValue(result.many ?? []), + }; + return qb; +} + +describe('ReputationService', () => { + let service: ReputationService; + let bountyRepo: { find: jest.Mock }; + let issueRepo: { find: jest.Mock }; + let snapshotRepo: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; find: jest.Mock }; + let paymentRepo: { createQueryBuilder: jest.Mock }; + + beforeEach(async () => { + bountyRepo = { find: jest.fn().mockResolvedValue([]) }; + issueRepo = { find: jest.fn().mockResolvedValue([]) }; + snapshotRepo = { + create: jest.fn().mockImplementation((x) => x), + save: jest.fn().mockImplementation((x) => x), + findOne: jest.fn(), + find: jest.fn(), + }; + paymentRepo = { createQueryBuilder: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReputationService, + { provide: getRepositoryToken(Bounty), useValue: bountyRepo }, + { provide: getRepositoryToken(Issue), useValue: issueRepo }, + { provide: getRepositoryToken(ReputationSnapshot), useValue: snapshotRepo }, + { provide: getRepositoryToken(Payment), useValue: paymentRepo }, + ], + }).compile(); + + service = module.get(ReputationService); + }); + + describe('computeAndSave', () => { + it('computes totalEarnings from Payment ledger, attributing actual received share for team-splits', async () => { + // Simulate user claiming a team-split bounty. + // E.g. $1000 bounty, but user received 20% = $200. + bountyRepo.find.mockResolvedValue([ + { id: 'b1', amount: '1000', status: BountyStatus.PAID, issueId: 'i1' }, + ]); + + // The payment query should return the 20% share. + const qb = createMockQueryBuilder({ + many: [{ sponsorId: 's1', total: '200' }], + }); + paymentRepo.createQueryBuilder.mockReturnValue(qb); + + const snapshot = await service.computeAndSave('user-1'); + + // The snapshot should have 200, NOT 1000. + expect(snapshot.totalEarnings).toBe('200.0000000'); + + expect(paymentRepo.createQueryBuilder).toHaveBeenCalledWith('payment'); + expect(qb.where).toHaveBeenCalledWith('payment.recipientId = :userId', { userId: 'user-1' }); + }); + }); +}); diff --git a/src/reputation/reputation.service.ts b/src/reputation/reputation.service.ts index 0404858..89c7f64 100644 --- a/src/reputation/reputation.service.ts +++ b/src/reputation/reputation.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Bounty, Issue, ReputationSnapshot } from '../common/entities'; +import { Bounty, Issue, ReputationSnapshot, Payment } from '../common/entities'; import { BountyStatus } from '../common/enums'; +import { calculateContributorEarnings } from '../common/utils/earnings.util'; @Injectable() export class ReputationService { @@ -11,6 +12,7 @@ export class ReputationService { @InjectRepository(Issue) private readonly issueRepo: Repository, @InjectRepository(ReputationSnapshot) private readonly snapshotRepo: Repository, + @InjectRepository(Payment) private readonly paymentRepo: Repository, ) {} /** @@ -26,7 +28,10 @@ export class ReputationService { ); const paid = claimedBounties.filter((b) => b.status === BountyStatus.PAID); - const totalEarnings = paid.reduce((sum, b) => sum + Number(b.amount), 0); + const { totalEarnings } = await calculateContributorEarnings( + this.paymentRepo, + userId, + ); const completionRate = claimedBounties.length > 0 ? (merged.length / claimedBounties.length) * 100