Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
71 changes: 71 additions & 0 deletions src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
});
14 changes: 5 additions & 9 deletions src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +28,7 @@ export class AnalyticsService {
@InjectRepository(Issue) private readonly issueRepo: Repository<Issue>,
@InjectRepository(RepositoryEntity)
private readonly repositoryRepo: Repository<RepositoryEntity>,
@InjectRepository(Payment) private readonly paymentRepo: Repository<Payment>,
) {}

async forContributor(userId: string): Promise<ContributorAnalytics> {
Expand All @@ -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;

Expand Down Expand Up @@ -75,14 +79,6 @@ export class AnalyticsService {
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));

const clientTotals = new Map<string, number>();
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)
Expand Down
41 changes: 41 additions & 0 deletions src/common/utils/earnings.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Repository } from 'typeorm';
import { Payment } from '../entities';
import { PaymentStatus } from '../enums';

export interface ContributorEarningsInfo {
totalEarnings: number;
earningsBySponsor: Map<string, number>;
}

/**
* 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<Payment>,
userId: string,
): Promise<ContributorEarningsInfo> {
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<string, number>();

for (const row of rows) {
const amount = Number(row.total ?? 0);
totalEarnings += amount;
if (row.sponsorId) {
earningsBySponsor.set(row.sponsorId, amount);
}
}

return { totalEarnings, earningsBySponsor };
}
4 changes: 2 additions & 2 deletions src/reputation/reputation.module.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
75 changes: 75 additions & 0 deletions src/reputation/reputation.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
});
9 changes: 7 additions & 2 deletions src/reputation/reputation.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,6 +12,7 @@ export class ReputationService {
@InjectRepository(Issue) private readonly issueRepo: Repository<Issue>,
@InjectRepository(ReputationSnapshot)
private readonly snapshotRepo: Repository<ReputationSnapshot>,
@InjectRepository(Payment) private readonly paymentRepo: Repository<Payment>,
) {}

/**
Expand All @@ -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
Expand Down