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, 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],
Expand Down
82 changes: 82 additions & 0 deletions src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }]);
});
});
});
37 changes: 25 additions & 12 deletions src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +26,7 @@ export class AnalyticsService {
constructor(
@InjectRepository(Bounty) private readonly bountyRepo: Repository<Bounty>,
@InjectRepository(Issue) private readonly issueRepo: Repository<Issue>,
@InjectRepository(Payment) private readonly paymentRepo: Repository<Payment>,
@InjectRepository(RepositoryEntity)
private readonly repositoryRepo: Repository<RepositoryEntity>,
) {}
Expand All @@ -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;

Expand Down Expand Up @@ -75,16 +81,23 @@ 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 }))
// 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);

Expand Down
24 changes: 24 additions & 0 deletions src/reputation/contributor-earnings.util.ts
Original file line number Diff line number Diff line change
@@ -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<Payment>,
recipientId: string,
): Promise<number> {
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);
}
6 changes: 4 additions & 2 deletions src/reputation/reputation.module.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
97 changes: 97 additions & 0 deletions src/reputation/reputation.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
14 changes: 11 additions & 3 deletions src/reputation/reputation.service.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
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<Bounty>,
@InjectRepository(Issue) private readonly issueRepo: Repository<Issue>,
@InjectRepository(Payment) private readonly paymentRepo: Repository<Payment>,
@InjectRepository(ReputationSnapshot)
private readonly snapshotRepo: Repository<ReputationSnapshot>,
) {}

/**
* 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<ReputationSnapshot> {
const claimedBounties = await this.bountyRepo.find({
Expand All @@ -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
Expand Down