diff --git a/CHANGELOG.md b/CHANGELOG.md index a81a1e2..c093865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `CHANGELOG.md` (this file) ### Changed +- `Campaign.raisedAmount` now stores only the native-XLM (base asset) portion; a new `raisedByAsset` JSON column holds the per-asset breakdown +- `getContractBalance` now reports on-chain balances per asset and never overwrites stored totals - `UpdateCampaignDto` now supports `category` and `endDate` fields - `updateCampaign` service method validates ownership and future `endDate` - `CampaignsController.update` now correctly uses `req.user.sub` (JWT subject) instead of `req.user.id` @@ -49,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `package.json` scripts: added `lint:fix` and `format:check`; separated `lint` from auto-fix ### Fixed +- Multi-asset campaigns no longer report corrupted mixed-unit totals; `raisedAmount`, `progressPercentage`, and `mostFunded` sorting are computed from the native-XLM base unit with per-asset totals in `raisedByAsset` - Missing `donatedAt` index on the `donations` table added to Prisma schema - Milestone `dueDate` validation now enforces future dates - Fund release amount now validated against available `raisedAmount` (not just milestone target) diff --git a/README.md b/README.md index 1dd2e85..e377f24 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,30 @@ All donation CSV exports (`GET /users/me/donations/export` and the async queue v --- +## Multi-Asset Campaign Totals + +A campaign can accept multiple assets (`acceptedAssets`: native XLM and/or issued +assets such as `USDC:`), so raised totals are never collapsed into a single +mixed-unit number. + +| Field | Shape | Meaning | +| --- | --- | --- | +| `raisedByAsset` | `Record` | Per-asset raised totals. Keys are `XLM` (native) or `CODE:ISSUER` (issued); values are decimal strings. | +| `raisedAmount` | decimal string | The native-XLM (base asset) portion only. Powers `mostFunded` browse sorting. | +| `progressPercentage` | number (0–100) | Native-XLM raised ÷ `goalAmount` (XLM-denominated), capped at 100. | + +`GET /campaigns/:id/stats` returns `raisedByAsset` alongside the native-XLM scalar +fields. `GET /campaigns/:id/contract-balance` reports on-chain balances per asset +and **never** overwrites stored totals. + +> **Fiat conversion is intentionally out of scope.** Without a price-oracle +> integration, heterogeneous assets cannot be converted into a single monetary +> value. Clients should render `raisedByAsset` per asset. A future price oracle +> can feed these per-asset amounts into a USD-equivalent summary without another +> schema change. + +--- + ## Environment Variables Reference All configuration is provided via environment variables. Copy `.env.example` to `.env` and fill in the values. diff --git a/prisma/migrations/20260819000000_multi_asset_campaign_totals/migration.sql b/prisma/migrations/20260819000000_multi_asset_campaign_totals/migration.sql new file mode 100644 index 0000000..1cafc50 --- /dev/null +++ b/prisma/migrations/20260819000000_multi_asset_campaign_totals/migration.sql @@ -0,0 +1,52 @@ +-- Multi-asset campaign totals. +-- +-- A Campaign previously stored a single `raisedAmount` scalar even though a +-- campaign can accept multiple assets. This migration adds a per-asset +-- breakdown (`raisedByAsset`) and redefines `raisedAmount` as the native-XLM +-- (base asset) portion only, so heterogeneous assets are never summed. + +-- 1. Add the per-asset breakdown column. +ALTER TABLE "campaigns" ADD COLUMN "raisedByAsset" JSONB; + +-- 2. Backfill per-asset raised totals from confirmed donations. +-- Keys are `XLM` for native XLM and `CODE:ISSUER` for issued assets, +-- matching the application-level `assetKey` encoding. +UPDATE "campaigns" c +SET "raisedByAsset" = sub.raised_by_asset +FROM ( + SELECT + "campaignId", + jsonb_object_agg( + CASE + WHEN "assetCode" = 'XLM' THEN 'XLM' + ELSE upper("assetCode") || ':' || COALESCE("assetIssuer", '') + END, + "amount_sum"::text + ) AS raised_by_asset + FROM ( + SELECT + "campaignId", + "assetCode", + "assetIssuer", + SUM("amount") AS amount_sum + FROM "donations" + WHERE "status" = 'CONFIRMED' + GROUP BY "campaignId", "assetCode", "assetIssuer" + ) grouped + GROUP BY "campaignId" +) sub +WHERE c."id" = sub."campaignId"; + +-- 3. Recompute the scalar `raisedAmount` as the native-XLM portion only, +-- repairing any previously corrupted mixed-unit totals. +UPDATE "campaigns" c +SET "raisedAmount" = COALESCE( + ( + SELECT SUM("amount") + FROM "donations" d + WHERE d."campaignId" = c."id" + AND d."status" = 'CONFIRMED' + AND d."assetCode" = 'XLM' + ), + 0 +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 335c796..0de3c4e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -167,6 +167,10 @@ model Campaign { story String? goalAmount Decimal @db.Decimal(20, 7) raisedAmount Decimal @default(0) @db.Decimal(20, 7) + /// Per-asset raised totals as JSONB. Keys are `XLM` for the native asset and + /// `CODE:ISSUER` for issued assets; values are decimal strings. + /// `raisedAmount` holds only the native-XLM portion (the base asset). + raisedByAsset Json? status CampaignStatus @default(DRAFT) creatorId String contractId String? diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index 8d9d63f..6783828 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -99,7 +99,7 @@ describe('AdminService – refundDonation', () => { donation: { findUnique: jest.fn().mockResolvedValue(confirmedDonation), update: jest.fn().mockResolvedValue(refundedDonation), - aggregate: jest.fn().mockResolvedValue({ _sum: { amount: null } }), + groupBy: jest.fn().mockResolvedValue([]), }, campaign: { update: jest.fn().mockResolvedValue({}) }, }; diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index d179bfc..faa1d3b 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -3,10 +3,10 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SuspendCampaignDto } from './dtos/suspend-campaign.dto'; +import { recalculateCampaignRaised } from '../campaigns/campaign-raised.helper'; @Injectable() export class AdminService { @@ -188,21 +188,10 @@ export class AdminService { data: { status: 'REFUNDED' }, }); - // Recalculate campaign raisedAmount atomically within the same transaction - const agg = await tx.donation.aggregate({ - where: { - campaignId: donation.campaignId, - status: 'CONFIRMED', - }, - _sum: { amount: true }, - }); - - const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0); - - await tx.campaign.update({ - where: { id: donation.campaignId }, - data: { raisedAmount }, - }); + // Recalculate campaign raised totals (per asset) atomically within the + // same transaction. Uses the shared asset-aware aggregation so refunds + // never sum heterogeneous assets into a mixed-unit scalar. + await recalculateCampaignRaised(tx, donation.campaignId); return { id: updated.id, diff --git a/src/campaigns/campaign-raised.helper.spec.ts b/src/campaigns/campaign-raised.helper.spec.ts new file mode 100644 index 0000000..1c44135 --- /dev/null +++ b/src/campaigns/campaign-raised.helper.spec.ts @@ -0,0 +1,96 @@ +import { Prisma } from '@prisma/client'; +import { + assetKey, + buildRaisedByAsset, + nativeRaisedAmount, + recalculateCampaignRaised, +} from './campaign-raised.helper'; + +describe('campaign-raised helpers', () => { + describe('assetKey', () => { + it('collapses native XLM (any case) to "XLM"', () => { + expect(assetKey('XLM', null)).toBe('XLM'); + expect(assetKey('xlm', undefined)).toBe('XLM'); + }); + + it('encodes issued assets as CODE:ISSUER', () => { + expect(assetKey('usdc', 'ISSUER')).toBe('USDC:ISSUER'); + }); + }); + + describe('buildRaisedByAsset', () => { + it('sums per asset and never merges heterogeneous assets', () => { + const result = buildRaisedByAsset([ + { + assetCode: 'XLM', + assetIssuer: null, + amount: new Prisma.Decimal('100'), + }, + { + assetCode: 'USDC', + assetIssuer: 'ISSUER', + amount: new Prisma.Decimal('50'), + }, + { + assetCode: 'XLM', + assetIssuer: null, + amount: new Prisma.Decimal('25'), + }, + ]); + + expect(result).toEqual({ + XLM: '125', + 'USDC:ISSUER': '50', + }); + }); + + it('returns an empty map for no rows', () => { + expect(buildRaisedByAsset([])).toEqual({}); + }); + }); + + describe('nativeRaisedAmount', () => { + it('returns the native-XLM portion, or 0 when absent', () => { + expect(nativeRaisedAmount({ XLM: '100', 'USDC:ISSUER': '50' })).toBe( + '100', + ); + expect(nativeRaisedAmount({})).toBe('0'); + expect(nativeRaisedAmount(null)).toBe('0'); + expect(nativeRaisedAmount(undefined)).toBe('0'); + }); + }); + + describe('recalculateCampaignRaised', () => { + it('writes per-asset totals and an XLM-only raisedAmount (never a mixed scalar)', async () => { + const groupBy = jest.fn().mockResolvedValue([ + { + assetCode: 'XLM', + assetIssuer: null, + _sum: { amount: new Prisma.Decimal('100') }, + }, + { + assetCode: 'USDC', + assetIssuer: 'ISSUER', + _sum: { amount: new Prisma.Decimal('50') }, + }, + ]); + const update = jest.fn().mockResolvedValue({}); + const tx = { donation: { groupBy }, campaign: { update } } as any; + + await recalculateCampaignRaised(tx, 'c1'); + + expect(groupBy).toHaveBeenCalledWith({ + by: ['assetCode', 'assetIssuer'], + where: { campaignId: 'c1', status: 'CONFIRMED' }, + _sum: { amount: true }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { + raisedAmount: '100', + raisedByAsset: { XLM: '100', 'USDC:ISSUER': '50' }, + }, + }); + }); + }); +}); diff --git a/src/campaigns/campaign-raised.helper.ts b/src/campaigns/campaign-raised.helper.ts new file mode 100644 index 0000000..dfb4079 --- /dev/null +++ b/src/campaigns/campaign-raised.helper.ts @@ -0,0 +1,98 @@ +import { Prisma } from '@prisma/client'; + +/** + * The native Stellar asset code. `Campaign.raisedAmount` is denominated in + * native XLM only — the "base asset" — because it is the single well-defined + * unit available without an exchange-rate source. Every other asset is kept + * separately in `Campaign.raisedByAsset`. + */ +export const NATIVE_ASSET_CODE = 'XLM'; + +/** A single per-asset amount read from a donation aggregate. */ +export interface AssetAmountRow { + assetCode: string; + assetIssuer?: string | null; + amount: Prisma.Decimal | string | number; +} + +/** + * Encode an asset as the key used in `Campaign.raisedByAsset`. + * + * Native XLM collapses to `XLM`; issued assets become `CODE:ISSUER` where the + * code is upper-cased and the issuer is preserved verbatim (Stellar account + * IDs are case-sensitive). + */ +export function assetKey( + assetCode: string, + assetIssuer?: string | null, +): string { + const code = String(assetCode ?? '').trim().toUpperCase(); + if (code === NATIVE_ASSET_CODE) return NATIVE_ASSET_CODE; + return `${code}:${String(assetIssuer ?? '')}`; +} + +/** + * Sum per-asset amount rows into a `raisedByAsset` map of decimal strings. + * Amounts are accumulated as `Prisma.Decimal` so precision is preserved. + */ +export function buildRaisedByAsset( + rows: AssetAmountRow[], +): Record { + const totals: Record = {}; + + for (const row of rows) { + const key = assetKey(row.assetCode, row.assetIssuer); + const amount = new Prisma.Decimal(row.amount ?? 0); + totals[key] = totals[key] ? totals[key].add(amount) : amount; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(totals)) { + result[key] = value.toString(); + } + return result; +} + +/** + * Return the native-XLM portion of a `raisedByAsset` map as a decimal string. + * This is the well-defined single-unit summary written to `raisedAmount`. + */ +export function nativeRaisedAmount( + raisedByAsset: Record | null | undefined, +): string { + return raisedByAsset?.[NATIVE_ASSET_CODE] ?? '0'; +} + +/** + * Recompute a campaign's `raisedByAsset` breakdown and native-XLM + * `raisedAmount` from its confirmed donations, atomically within the supplied + * transaction. This is the single source of truth for campaign raised totals; + * callers that already hold a transaction (e.g. refunds) can invoke it with + * `tx` rather than opening a nested transaction. + */ +export async function recalculateCampaignRaised( + tx: Prisma.TransactionClient, + campaignId: string, +): Promise { + const groups = await tx.donation.groupBy({ + by: ['assetCode', 'assetIssuer'], + where: { campaignId, status: 'CONFIRMED' }, + _sum: { amount: true }, + }); + + const raisedByAsset = buildRaisedByAsset( + groups.map((g) => ({ + assetCode: g.assetCode, + assetIssuer: g.assetIssuer, + amount: g._sum.amount ?? new Prisma.Decimal(0), + })), + ); + + await tx.campaign.update({ + where: { id: campaignId }, + data: { + raisedAmount: nativeRaisedAmount(raisedByAsset), + raisedByAsset, + }, + }); +} diff --git a/src/campaigns/campaigns.controller.ts b/src/campaigns/campaigns.controller.ts index fc15fdc..50974d1 100644 --- a/src/campaigns/campaigns.controller.ts +++ b/src/campaigns/campaigns.controller.ts @@ -63,7 +63,10 @@ export class CampaignsController { @Inject(CACHE_MANAGER) private cacheManager: Cache, ) {} - @ApiOperation({ summary: 'Get campaign statistics (creator/admin only)' }) + @ApiOperation({ + summary: + 'Get campaign statistics with per-asset raised totals (creator/admin only)', + }) @ApiParam({ name: 'id', description: 'Campaign UUID' }) @Get(':id/stats') @Roles('creator', 'admin') @@ -139,11 +142,11 @@ export class CampaignsController { /** * GET /campaigns/:id/contract-balance - * Fetch on-chain balances for the campaign's Stellar contract account. - * Discrepancies between on-chain and stored amounts are flagged and auto-corrected. + * Fetch on-chain balances for the campaign's Stellar contract account, + * reported per asset. Stored totals are never overwritten. */ @ApiOperation({ - summary: 'Fetch on-chain contract balance and detect discrepancies', + summary: 'Fetch on-chain contract balances (per asset)', }) @ApiParam({ name: 'id', description: 'Campaign UUID' }) @Get(':id/contract-balance') diff --git a/src/campaigns/campaigns.service.multi-asset.spec.ts b/src/campaigns/campaigns.service.multi-asset.spec.ts new file mode 100644 index 0000000..e0b287e --- /dev/null +++ b/src/campaigns/campaigns.service.multi-asset.spec.ts @@ -0,0 +1,139 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CampaignsService } from './campaigns.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { StellarTransactionsService } from '../stellar/stellar-transactions.service'; + +const USDC_ISSUER = 'ISSUER'; + +const mockPrisma = { + campaign: { + findUnique: jest.fn(), + update: jest.fn(), + }, + donation: { + findMany: jest.fn(), + groupBy: jest.fn(), + }, + $transaction: jest.fn(), +}; + +const mockStellarTxs = { + getContractBalances: jest.fn(), +}; + +describe('CampaignsService – multi-asset raised totals', () => { + let service: CampaignsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CampaignsService, + { provide: PrismaService, useValue: mockPrisma }, + { provide: StellarTransactionsService, useValue: mockStellarTxs }, + ], + }).compile(); + + service = module.get(CampaignsService); + jest.clearAllMocks(); + }); + + describe('recalculateCampaignStats', () => { + it('does not report 100 XLM + 50 USDC as raisedAmount = 150', async () => { + const update = jest.fn().mockResolvedValue({}); + mockPrisma.$transaction.mockImplementation(async (fn: any) => + fn({ + donation: { + groupBy: jest.fn().mockResolvedValue([ + { + assetCode: 'XLM', + assetIssuer: null, + _sum: { amount: '100' }, + }, + { + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + _sum: { amount: '50' }, + }, + ]), + }, + campaign: { update }, + }), + ); + + await service.recalculateCampaignStats('c1'); + + expect(update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { + raisedAmount: '100', + raisedByAsset: { XLM: '100', [`USDC:${USDC_ISSUER}`]: '50' }, + }, + }); + }); + }); + + describe('getContractBalance', () => { + it('reports the two balances separately and never overwrites stored totals', async () => { + mockPrisma.campaign.findUnique.mockResolvedValue({ + id: 'c1', + contractId: 'contract-1', + raisedByAsset: { XLM: '100', [`USDC:${USDC_ISSUER}`]: '50' }, + }); + mockStellarTxs.getContractBalances.mockResolvedValue([ + { assetCode: 'XLM', balance: '100', isNative: true }, + { + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: '50', + isNative: false, + }, + ]); + + const result = await service.getContractBalance('c1'); + + expect(result.balances).toHaveLength(2); + expect(result.balances[0]).toMatchObject({ + assetCode: 'XLM', + balance: '100', + }); + expect(result.balances[1]).toMatchObject({ + assetCode: 'USDC', + balance: '50', + }); + expect(result.storedRaisedByAsset).toEqual({ + XLM: '100', + [`USDC:${USDC_ISSUER}`]: '50', + }); + // The write-back side effect is gone. + expect(mockPrisma.campaign.update).not.toHaveBeenCalled(); + }); + }); + + describe('getCampaignStats', () => { + it('reports a native-XLM scalar and a per-asset breakdown, not a mixed sum', async () => { + mockPrisma.campaign.findUnique.mockResolvedValue({ + id: 'c1', + goalAmount: '1000', + raisedAmount: '100', + }); + mockPrisma.donation.findMany.mockResolvedValue([ + { amount: '100', donorId: 'u1', assetCode: 'XLM', assetIssuer: null }, + { + amount: '50', + donorId: 'u2', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + }, + ]); + + const result = await service.getCampaignStats('c1'); + + expect(result.totalRaised).toBe(100); + expect(result.raisedByAsset).toEqual({ + XLM: '100', + [`USDC:${USDC_ISSUER}`]: '50', + }); + expect(result.progressPercentage).toBe(10); + }); + }); +}); diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index 88ee35e..91a7762 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -14,7 +14,12 @@ import { import { CreateCampaignDto } from './dto/create-campaign.dto'; import { UpdateCampaignDto } from './dto/update-campaign.dto'; import type { CreateUpdateDto } from './dto/create-update.dto'; -import { ContractBalanceResponseDto } from './dto/contract-balance.dto'; +import { + NATIVE_ASSET_CODE, + buildRaisedByAsset, + nativeRaisedAmount, + recalculateCampaignRaised, +} from './campaign-raised.helper'; const MIN_MILESTONE_TARGET_AMOUNT = 0.0000001; @@ -167,6 +172,9 @@ export class CampaignsService { let orderBy: Prisma.CampaignOrderByWithRelationInput; switch (sortBy) { case 'mostFunded': + // `raisedAmount` holds only the native-XLM (base asset) portion, so + // sorting by it ranks campaigns by a single well-defined unit rather + // than a mixed sum of heterogeneous assets. orderBy = { raisedAmount: 'desc' }; break; case 'endingSoon': @@ -256,8 +264,14 @@ export class CampaignsService { } /** - * Fetch on-chain contract balance from Stellar and compare with stored raisedAmount. - * Auto-corrects discrepancies. + * Fetch on-chain contract balances from Stellar, reported per asset. + * + * The previous implementation summed native and issued balances into a + * single mixed-unit `onChainTotal` and wrote it back to `raisedAmount`, + * actively persisting a meaningless scalar. Balances are now returned per + * asset and the stored totals are never overwritten: a mixed-unit scalar is + * meaningless, and on-chain balance legitimately diverges from raised totals + * once funds are released. */ async getContractBalance(campaignId: string) { const campaign = await this.prisma.campaign.findUnique({ @@ -276,56 +290,28 @@ export class CampaignsService { campaign.contractId, ); - // Calculate total on-chain balance - let onChainTotal = 0; - for (const b of balances) { - onChainTotal += parseFloat(b.balance); - } - - const storedRaisedAmount = parseFloat(campaign.raisedAmount.toString()); - const discrepancyDetected = - Math.abs(onChainTotal - storedRaisedAmount) > 0.0001; - - // If discrepancy detected, update the stored raisedAmount - if (discrepancyDetected) { - await this.prisma.campaign.update({ - where: { id: campaignId }, - data: { - raisedAmount: onChainTotal, - }, - }); - } + const storedRaisedByAsset = (campaign.raisedByAsset ?? {}) as Record< + string, + string + >; return { contractId: campaign.contractId, balances, - storedRaisedAmount: campaign.raisedAmount.toString(), - onChainTotal: onChainTotal.toString(), - discrepancyDetected, + storedRaisedByAsset, }; } /** - * Recalculate a campaign's raisedAmount from confirmed donations. - * Uses a Prisma $transaction to ensure the aggregate read and campaign - * update happen atomically. + * Recalculate a campaign's raised totals from confirmed donations. + * Aggregates per asset (never summing across different assets): + * `raisedByAsset` stores the full breakdown while `raisedAmount` stores the + * native-XLM portion only. Runs in a Prisma $transaction so the aggregate + * read and campaign update happen atomically. */ async recalculateCampaignStats(campaignId: string) { await this.prisma.$transaction(async (tx) => { - const agg = await tx.donation.aggregate({ - where: { - campaignId, - status: 'CONFIRMED', - }, - _sum: { amount: true }, - }); - - const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0); - - await tx.campaign.update({ - where: { id: campaignId }, - data: { raisedAmount }, - }); + await recalculateCampaignRaised(tx, campaignId); }); } @@ -424,7 +410,13 @@ export class CampaignsService { }); } - /** Compute aggregate stats for a campaign: total raised, donor count, progress %, etc. */ + /** + * Compute aggregate stats for a campaign: per-asset raised totals, donor + * count, progress %, etc. Heterogeneous assets are never summed together; + * `raisedByAsset` carries the per-asset breakdown while `totalRaised` and + * `progressPercentage` are expressed in the single well-defined native-XLM + * base unit. + */ async getCampaignStats(campaignId: string) { const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId }, @@ -435,15 +427,31 @@ export class CampaignsService { const donations = await this.prisma.donation.findMany({ where: { campaignId, status: 'CONFIRMED' }, - select: { amount: true, donorId: true, assetCode: true, createdAt: true }, + select: { + amount: true, + donorId: true, + assetCode: true, + assetIssuer: true, + }, }); - // Safely handle edge case of zero donations - const totalRaised = donations.reduce((sum, d) => sum + Number(d.amount), 0); + const raisedByAsset = buildRaisedByAsset( + donations.map((d) => ({ + assetCode: d.assetCode, + assetIssuer: d.assetIssuer, + amount: d.amount, + })), + ); + + // Single well-defined unit: native XLM (base asset). + const totalRaised = Number(nativeRaisedAmount(raisedByAsset)); const donorCount = new Set(donations.map((d) => d.donorId)).size; const uniqueAssets = [...new Set(donations.map((d) => d.assetCode))]; + const nativeDonationCount = donations.filter( + (d) => d.assetCode === NATIVE_ASSET_CODE, + ).length; const avgDonation = - donations.length > 0 ? totalRaised / donations.length : 0; + nativeDonationCount > 0 ? totalRaised / nativeDonationCount : 0; const goalAmount = Number(campaign.goalAmount); const progressPercentage = @@ -457,6 +465,7 @@ export class CampaignsService { return { campaignId, totalRaised, + raisedByAsset, goalAmount, progressPercentage, donorCount, @@ -562,6 +571,7 @@ function campaignBrowseSelect() { story: true, goalAmount: true, raisedAmount: true, + raisedByAsset: true, status: true, creatorId: true, startDate: true, diff --git a/src/campaigns/dto/contract-balance.dto.ts b/src/campaigns/dto/contract-balance.dto.ts index 2646908..f10bf0b 100644 --- a/src/campaigns/dto/contract-balance.dto.ts +++ b/src/campaigns/dto/contract-balance.dto.ts @@ -1,16 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; + /** Represents an on-chain asset balance for a Stellar account */ export class AssetBalanceDto { + @ApiProperty({ + description: 'Asset code (e.g. `XLM`, `USDC`)', + example: 'USDC', + }) assetCode: string; + + @ApiProperty({ + description: 'Issuer account for issued assets; absent for native XLM', + required: false, + }) assetIssuer?: string; + + @ApiProperty({ + description: 'On-chain balance as a decimal string', + example: '50.0000000', + }) balance: string; + + @ApiProperty({ + description: 'Whether this is the native (XLM) asset', + example: false, + }) isNative: boolean; } export class ContractBalanceResponseDto { + @ApiProperty({ + description: 'Soroban contract account ID', + example: 'GBC…', + }) contractId: string; + + @ApiProperty({ + description: + 'On-chain balances reported per asset. Heterogeneous assets are never summed.', + type: [AssetBalanceDto], + }) balances: AssetBalanceDto[]; - totalValueInXlm?: string; - discrepancyDetected: boolean; - storedRaisedAmount: string; - onChainTotal: string; + + @ApiProperty({ + description: + 'Stored per-asset raised totals (keys: `XLM` or `CODE:ISSUER`). ' + + 'Provided for comparison; this endpoint never overwrites stored totals.', + example: { XLM: '100.0000000', 'USDC:GBD…': '50.0000000' }, + }) + storedRaisedByAsset: Record; } diff --git a/src/campaigns/interfaces/campaign-stats.interface.ts b/src/campaigns/interfaces/campaign-stats.interface.ts index 2e18e9c..4f8615a 100644 --- a/src/campaigns/interfaces/campaign-stats.interface.ts +++ b/src/campaigns/interfaces/campaign-stats.interface.ts @@ -3,30 +3,45 @@ * * Contains aggregated fundraising metrics for a single campaign. * Fields are computed from confirmed donations at query time. + * + * Multi-asset campaigns are represented per asset: `raisedByAsset` holds the + * full breakdown keyed by `XLM` (native) or `CODE:ISSUER` (issued assets), + * while the scalar `totalRaised` / `progressPercentage` fields are expressed + * in the single well-defined base unit, native XLM. Heterogeneous assets are + * never summed into one number. */ export interface CampaignStats { /** Campaign UUID */ campaignId: string; - /** Total amount raised from all confirmed donations */ + /** + * Total raised in the base asset (native XLM). Use `raisedByAsset` for the + * full per-asset breakdown. + */ totalRaised: number; - /** Campaign's fundraising goal amount */ + /** + * Per-asset raised totals. Keys are `XLM` for native XLM and `CODE:ISSUER` + * for issued assets; values are decimal strings. + */ + raisedByAsset: Record; + + /** Campaign's fundraising goal amount (denominated in native XLM) */ goalAmount: number; /** - * Progress towards the goal as a percentage (0–100). - * Capped at 100 even when totalRaised exceeds goalAmount. + * Progress towards the goal as a percentage (0–100) of the native-XLM + * raised total. Capped at 100 even when totalRaised exceeds goalAmount. */ progressPercentage: number; /** Number of unique donors */ donorCount: number; - /** Asset codes accepted for this campaign (e.g. ['XLM', 'USDC']) */ + /** Asset codes donated to this campaign (e.g. ['XLM', 'USDC']) */ uniqueAssets: string[]; - /** Average donation amount across all confirmed donations */ + /** Average native-XLM donation amount across native-XLM donations */ avgDonation: number; /** Daily donation totals (populated for detailed analytics views) */