From 5ffd227a75c5afd081ee74af54ecd1ed3e59f1f6 Mon Sep 17 00:00:00 2001 From: Yggdrasil Date: Wed, 14 Oct 2026 01:56:38 -0400 Subject: [PATCH] =?UTF-8?q?fix(analytics):=20per-asset=20money=20totals=20?= =?UTF-8?q?=E2=80=94=20stop=20summing=20USDC=20+=20XLM=20together?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every monetary aggregate ignored asset — SUM(amount) across all currencies returned one meaningless number (a sponsor paying 100 USDC + 100 XLM saw '200'). Add per-asset breakdowns grouped by asset: - totalSpendByAsset, budgetLockedByAsset (sponsor dashboard) - totalPaidOutByAsset (platform summary) The single-number fields stay (marked deprecated) so existing consumers don't break, but the per-asset breakdowns give the currency-correct totals. Resolves #574 --- src/analytics/analytics.service.ts | 34 +++++++++++---- src/sponsors/sponsors.service.ts | 67 ++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index e747812b..2d8ae822 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -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; totalRepos: number; } @@ -97,19 +100,32 @@ export class AnalyticsService { } private async loadPlatformSummary(): Promise { - 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, }; } diff --git a/src/sponsors/sponsors.service.ts b/src/sponsors/sponsors.service.ts index ef6d3840..4075d3dc 100644 --- a/src/sponsors/sponsors.service.ts +++ b/src/sponsors/sponsors.service.ts @@ -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; + /** Per-asset locked balance: {USDC: n, XLM: n}. */ + budgetLockedByAsset: Record; activeMilestones: Milestone[]; recentPayments: Payment[]; } @@ -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> { + 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 { const row = await this.paymentRepo .createQueryBuilder('payment') @@ -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> { + 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 = {}, @@ -131,13 +172,23 @@ export class SponsorsService { sponsorId: string, options: SponsorDashboardOptions = {}, ): Promise { - 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 @@ -157,6 +208,8 @@ export class SponsorsService { activeBounties, totalSpent, budgetLocked, + totalSpentByAsset, + budgetLockedByAsset, activeMilestones, recentPayments, };