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
34 changes: 25 additions & 9 deletions src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export interface ContributorAnalyticsQuery {

export interface PlatformSummary {
totalBounties: number;
/** @deprecated sums USDC and XLM into one meaningless number — use totalPaidOutByAsset (#574). */
totalPaidOut: number;
/** Per-asset payout totals: {USDC: n, XLM: n} — the currency-correct breakdown. */
totalPaidOutByAsset: Record<string, number>;
totalRepos: number;
}

Expand Down Expand Up @@ -97,19 +100,32 @@ export class AnalyticsService {
}

private async loadPlatformSummary(): Promise<PlatformSummary> {
const [totalBounties, totalPaidRaw, totalRepos] = await Promise.all([
this.bountyRepo.count(),
this.bountyRepo
.createQueryBuilder('bounty')
.select('COALESCE(SUM(bounty.amount), 0)', 'total')
.where('bounty.status = :status', { status: BountyStatus.PAID })
.getRawOne<{ total: string }>(),
this.repositoryRepo.count(),
]);
const [totalBounties, totalPaidRaw, paidByAssetRows, totalRepos] =
await Promise.all([
this.bountyRepo.count(),
this.bountyRepo
.createQueryBuilder('bounty')
.select('COALESCE(SUM(bounty.amount), 0)', 'total')
.where('bounty.status = :status', { status: BountyStatus.PAID })
.getRawOne<{ total: string }>(),
// Per-asset payout — the single totalPaidOut sums USDC and XLM into a
// meaningless number (#574); group by asset for the correct breakdown.
this.bountyRepo
.createQueryBuilder('bounty')
.select('bounty.asset', 'asset')
.addSelect('COALESCE(SUM(bounty.amount), 0)', 'total')
.where('bounty.status = :status', { status: BountyStatus.PAID })
.groupBy('bounty.asset')
.getRawMany<{ asset: string; total: string }>(),
this.repositoryRepo.count(),
]);

return {
totalBounties,
totalPaidOut: Number(totalPaidRaw?.total ?? 0),
totalPaidOutByAsset: Object.fromEntries(
paidByAssetRows.map((r) => [r.asset, Number(r.total)]),
),
totalRepos,
};
}
Expand Down
67 changes: 60 additions & 7 deletions src/sponsors/sponsors.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ import {

export interface SponsorDashboard {
activeBounties: Bounty[];
/** @deprecated sums USDC and XLM into one meaningless number — use totalSpentByAsset (#574). */
totalSpent: number;
/** @deprecated sums USDC and XLM into one meaningless number — use budgetLockedByAsset (#574). */
budgetLocked: number;
/** Per-asset spend: {USDC: n, XLM: n} — the currency-correct totals. */
totalSpentByAsset: Record<string, number>;
/** Per-asset locked balance: {USDC: n, XLM: n}. */
budgetLockedByAsset: Record<string, number>;
activeMilestones: Milestone[];
recentPayments: Payment[];
}
Expand Down Expand Up @@ -63,6 +69,26 @@ export class SponsorsService {
* this stays correct even if the parent bounty/milestone is later deleted
* (#27).
*/
/**
* Per-asset spend — a sponsor who paid 100 USDC and 100 XLM was previously
* shown "totalSpent: 200", a meaningless sum of unlike currencies (#574).
* Group by the escrow's asset so the total stays meaningful per currency.
*/
async totalSpendByAsset(sponsorId: string): Promise<Record<string, number>> {
const rows = await this.paymentRepo
.createQueryBuilder('payment')
.innerJoin('payment.escrow', 'escrow')
.select('escrow.asset', 'asset')
.addSelect('COALESCE(SUM(payment.amount), 0)', 'total')
.where('escrow.sponsorId = :sponsorId', { sponsorId })
.andWhere('payment.status = :status', {
status: PaymentStatus.CONFIRMED,
})
.groupBy('escrow.asset')
.getRawMany<{ asset: string; total: string }>();
return Object.fromEntries(rows.map((r) => [r.asset, Number(r.total)]));
}

async totalSpend(sponsorId: string): Promise<number> {
const row = await this.paymentRepo
.createQueryBuilder('payment')
Expand Down Expand Up @@ -95,6 +121,21 @@ export class SponsorsService {
return Number(row?.total ?? 0);
}

/** Per-asset locked-balance — see #574 (unlike currencies must not sum). */
async budgetLockedByAsset(
sponsorId: string,
): Promise<Record<string, number>> {
const rows = await this.escrowRepo
.createQueryBuilder('escrow')
.select('escrow.asset', 'asset')
.addSelect('COALESCE(SUM(escrow.amount), 0)', 'total')
.where('escrow.sponsorId = :sponsorId', { sponsorId })
.andWhere('escrow.status = :status', { status: EscrowStatus.LOCKED })
.groupBy('escrow.asset')
.getRawMany<{ asset: string; total: string }>();
return Object.fromEntries(rows.map((r) => [r.asset, Number(r.total)]));
}

async activeMilestones(
sponsorId: string,
options: SponsorDashboardOptions = {},
Expand Down Expand Up @@ -131,13 +172,23 @@ export class SponsorsService {
sponsorId: string,
options: SponsorDashboardOptions = {},
): Promise<SponsorDashboard> {
const [activeBounties, totalSpent, budgetLocked, activeMilestones] =
await Promise.all([
this.activeBounties(sponsorId, options),
this.totalSpend(sponsorId),
this.budgetLocked(sponsorId),
this.activeMilestones(sponsorId, options),
]);
const [
activeBounties,
totalSpent,
budgetLocked,
activeMilestones,
totalSpentByAsset,
budgetLockedByAsset,
] = await Promise.all([
this.activeBounties(sponsorId, options),
this.totalSpend(sponsorId),
this.budgetLocked(sponsorId),
this.activeMilestones(sponsorId, options),
// Per-asset breakdowns (#574) — the single numbers above sum unlike
// currencies; these group by asset so USDC and XLM stay separate.
this.totalSpendByAsset(sponsorId),
this.budgetLockedByAsset(sponsorId),
]);

// Joins on escrow.sponsorId directly (not escrow.bounty.sponsorId): the
// old join required the escrow's parent bounty row to still exist and
Expand All @@ -157,6 +208,8 @@ export class SponsorsService {
activeBounties,
totalSpent,
budgetLocked,
totalSpentByAsset,
budgetLockedByAsset,
activeMilestones,
recentPayments,
};
Expand Down