Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<issuer>`), so raised totals are never collapsed into a single
mixed-unit number.

| Field | Shape | Meaning |
| --- | --- | --- |
| `raisedByAsset` | `Record<string, string>` | 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}) },
};
Expand Down
21 changes: 5 additions & 16 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions src/campaigns/campaign-raised.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -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' },
},
});
});
});
});
98 changes: 98 additions & 0 deletions src/campaigns/campaign-raised.helper.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const totals: Record<string, Prisma.Decimal> = {};

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<string, string> = {};
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<string, string> | 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<void> {
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,
},
});
}
11 changes: 7 additions & 4 deletions src/campaigns/campaigns.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
Loading