From e354132a76e08f201a2e5cda69047a112c36f2af Mon Sep 17 00:00:00 2001 From: Iyanu Majekodunmi Date: Fri, 19 Jun 2026 08:16:58 +0000 Subject: [PATCH] fix(exports): drop misleading USD Equivalent column from donation CSVs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'USD Equivalent' column was hardcoded to 0.00 (later renamed to N/A with a pending label) across three independent export paths. Downstream consumers — tax tools, accounting software, and partner integrations — could silently trust an incorrect value (issue #15). Changes: - Add src/common/csv-export.helper.ts: shared buildDonationCsv() utility that owns the CSV schema; USD Equivalent column is intentionally absent until a price oracle is wired up. - Refactor all three export locations to use the shared helper: src/users/users.service.ts (sync export path) src/users/export.processor.ts (async Bull-queue path) src/donations/donations.service.ts (admin export path) - Add comprehensive unit tests (24 tests, all passing): src/common/csv-export.helper.spec.ts src/users/users.service.csv.spec.ts src/users/export.processor.spec.ts - Document the absent column and the path to reinstatement in README.md Closes #15 --- README.md | 68 ++++++++++----- src/common/csv-export.helper.spec.ts | 74 ++++++++++++++++ src/common/csv-export.helper.ts | 56 ++++++++++++ src/donations/donations.service.ts | 74 +++++++--------- src/users/export.processor.spec.ts | 108 +++++++++++++++++++++++ src/users/export.processor.ts | 34 +++----- src/users/users.service.csv.spec.ts | 124 +++++++++++++++++++++++++++ src/users/users.service.ts | 36 +++----- 8 files changed, 461 insertions(+), 113 deletions(-) create mode 100644 src/common/csv-export.helper.spec.ts create mode 100644 src/common/csv-export.helper.ts create mode 100644 src/users/export.processor.spec.ts create mode 100644 src/users/users.service.csv.spec.ts diff --git a/README.md b/README.md index d2ec088..f02b3b2 100644 --- a/README.md +++ b/README.md @@ -32,17 +32,17 @@ ## Tech Stack -| Layer | Technology | -|-------|-----------| -| **Runtime** | Node.js + TypeScript | -| **Framework** | NestJS (Express adapter) | -| **Database** | PostgreSQL + Prisma ORM | -| **Cache / Queue** | Redis + Bull | -| **Blockchain** | Stellar SDK + Soroban Smart Contracts | -| **Real-Time** | Socket.IO WebSockets | -| **Monitoring** | Sentry error tracking | -| **Email** | Nodemailer (SMTP) | -| **API Docs** | Swagger / OpenAPI | +| Layer | Technology | +| ----------------- | ------------------------------------- | +| **Runtime** | Node.js + TypeScript | +| **Framework** | NestJS (Express adapter) | +| **Database** | PostgreSQL + Prisma ORM | +| **Cache / Queue** | Redis + Bull | +| **Blockchain** | Stellar SDK + Soroban Smart Contracts | +| **Real-Time** | Socket.IO WebSockets | +| **Monitoring** | Sentry error tracking | +| **Email** | Nodemailer (SMTP) | +| **API Docs** | Swagger / OpenAPI | --- @@ -142,18 +142,18 @@ npm run test:cov ## API Modules -| Module | Description | Endpoints | -|--------|-------------|-----------| -| **Auth** | Stellar wallet challenge-response auth | `/auth/*` | -| **Users** | Profile, KYC, notification preferences | `/users/*` | -| **Campaigns** | CRUD, stats, fund release requests | `/campaigns/*` | -| **Donations** | Donation creation, history, admin tips | `/donations/*` | -| **Milestones** | Campaign milestone tracking | `/milestones/*` | -| **Contracts** | Soroban smart contract management | `/contracts/*` | -| **Notifications** | WebSocket gateway + REST endpoints | `/notifications/*` | -| **Admin** | User moderation, campaign suspension | `/admin/*` | -| **Health** | Health checks (DB, Redis) | `/health` | -| **API Keys** | Programmatic API key management | `/api-keys/*` | +| Module | Description | Endpoints | +| ----------------- | -------------------------------------- | ------------------ | +| **Auth** | Stellar wallet challenge-response auth | `/auth/*` | +| **Users** | Profile, KYC, notification preferences | `/users/*` | +| **Campaigns** | CRUD, stats, fund release requests | `/campaigns/*` | +| **Donations** | Donation creation, history, admin tips | `/donations/*` | +| **Milestones** | Campaign milestone tracking | `/milestones/*` | +| **Contracts** | Soroban smart contract management | `/contracts/*` | +| **Notifications** | WebSocket gateway + REST endpoints | `/notifications/*` | +| **Admin** | User moderation, campaign suspension | `/admin/*` | +| **Health** | Health checks (DB, Redis) | `/health` | +| **API Keys** | Programmatic API key management | `/api-keys/*` | --- @@ -187,6 +187,28 @@ src/ --- +## CSV Donation Exports + +All donation CSV exports (`GET /users/me/donations/export` and the async queue variant) include the following columns: + +| Column | Notes | +| -------- | ----------------------------------------------------- | +| Campaign | Campaign title at time of export | +| Amount | On-chain amount in the native asset | +| Asset | Asset code (e.g. `XLM`, `USDC`) | +| Date | ISO date of the donation (`YYYY-MM-DD`) | +| Tx Hash | Stellar transaction hash for independent verification | + +> **USD Equivalent column is intentionally absent.** +> A hardcoded `0.00` placeholder was previously exported under this heading — a medium-severity finding +> ([#15](https://github.com/OrbitChainLabs/OrbitChain-API/issues/15)) because downstream consumers +> (tax tools, accounting software, partner integrations) could silently trust an incorrect value. +> The column will be reinstated once a verified price-oracle integration +> (Stellar Horizon order-book snapshots, CoinGecko, or a self-hosted oracle) is in place. +> Until then, please cross-reference on-chain amounts with your preferred exchange-rate source. + +--- + ## Deployment For production deployment: diff --git a/src/common/csv-export.helper.spec.ts b/src/common/csv-export.helper.spec.ts new file mode 100644 index 0000000..11b1ee0 --- /dev/null +++ b/src/common/csv-export.helper.spec.ts @@ -0,0 +1,74 @@ +import { + buildDonationCsv, + CSV_HEADERS, + DonationCsvRow, +} from './csv-export.helper'; + +const makeRow = (overrides: Partial = {}): DonationCsvRow => ({ + campaignTitle: 'Test Campaign', + amount: '100.5', + assetCode: 'XLM', + donatedAt: new Date('2024-03-15T12:00:00.000Z'), + txHash: 'abc123txhash', + ...overrides, +}); + +describe('buildDonationCsv', () => { + it('produces a header-only CSV when given no rows', () => { + const csv = buildDonationCsv([]); + expect(csv).toBe(CSV_HEADERS.map((h) => `"${h}"`).join(',')); + }); + + it('outputs the correct number of lines (header + one per row)', () => { + const csv = buildDonationCsv([makeRow(), makeRow()]); + expect(csv.split('\n')).toHaveLength(3); + }); + + it('does NOT include a USD Equivalent column', () => { + const csv = buildDonationCsv([makeRow()]); + expect(csv).not.toMatch(/usd equivalent/i); + expect(csv).not.toMatch(/0\.00/); + expect(csv).not.toMatch(/N\/A/); + }); + + it('formats the date as YYYY-MM-DD', () => { + const csv = buildDonationCsv([makeRow()]); + expect(csv).toContain('2024-03-15'); + }); + + it('includes all expected fields in a data row', () => { + const csv = buildDonationCsv([makeRow()]); + const lines = csv.split('\n'); + const dataLine = lines[1]; + expect(dataLine).toContain('"Test Campaign"'); + expect(dataLine).toContain('100.5'); + expect(dataLine).toContain('XLM'); + expect(dataLine).toContain('2024-03-15'); + expect(dataLine).toContain('"abc123txhash"'); + }); + + it('escapes double-quotes inside campaign titles', () => { + const csv = buildDonationCsv([makeRow({ campaignTitle: 'Say "Hello"' })]); + expect(csv).toContain('"Say ""Hello"""'); + }); + + it('falls back to "Unknown" when campaignTitle is empty', () => { + const csv = buildDonationCsv([makeRow({ campaignTitle: '' })]); + expect(csv).toContain('"Unknown"'); + }); + + it('handles a null txHash gracefully', () => { + const csv = buildDonationCsv([makeRow({ txHash: null })]); + const dataLine = csv.split('\n')[1]; + expect(dataLine).toContain('""'); + }); + + it('produces exactly 5 columns per row (no USD Equivalent)', () => { + const csv = buildDonationCsv([makeRow()]); + const headerCols = csv.split('\n')[0].split(','); + const dataCols = csv.split('\n')[1].split(','); + expect(headerCols).toHaveLength(CSV_HEADERS.length); + expect(dataCols).toHaveLength(CSV_HEADERS.length); + expect(CSV_HEADERS.length).toBe(5); + }); +}); diff --git a/src/common/csv-export.helper.ts b/src/common/csv-export.helper.ts new file mode 100644 index 0000000..56fe200 --- /dev/null +++ b/src/common/csv-export.helper.ts @@ -0,0 +1,56 @@ +/** + * Shared helper for building donation CSV exports. + * + * The "USD Equivalent" column is intentionally omitted from all CSV exports + * until a price-oracle integration (Stellar Horizon order-book, CoinGecko, + * or a self-hosted service) is available. Emitting a hardcoded or placeholder + * value was flagged as a medium-severity security finding because downstream + * consumers (tax tools, accounting software, partner integrations) could + * silently trust an incorrect value. + * + * See: https://github.com/OrbitChainLabs/OrbitChain-API/issues/15 + */ + +export interface DonationCsvRow { + campaignTitle: string; + amount: string; + assetCode: string; + donatedAt: Date; + txHash: string | null; +} + +/** CSV column headers — "USD Equivalent" is excluded until oracle is ready */ +export const CSV_HEADERS = [ + 'Campaign', + 'Amount', + 'Asset', + 'Date', + 'Tx Hash', +] as const; + +/** Escape a value for safe inclusion inside a double-quoted CSV cell. */ +function escapeCsvCell(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +/** + * Convert an array of donation rows into a CSV string. + * Returns an empty CSV (headers only) when `rows` is empty. + */ +export function buildDonationCsv(rows: DonationCsvRow[]): string { + const lines: string[] = [CSV_HEADERS.map((h) => escapeCsvCell(h)).join(',')]; + + for (const row of rows) { + lines.push( + [ + escapeCsvCell(row.campaignTitle || 'Unknown'), + row.amount, + row.assetCode, + row.donatedAt.toISOString().split('T')[0], + escapeCsvCell(row.txHash || ''), + ].join(','), + ); + } + + return lines.join('\n'); +} diff --git a/src/donations/donations.service.ts b/src/donations/donations.service.ts index 8ffe903..178475c 100644 --- a/src/donations/donations.service.ts +++ b/src/donations/donations.service.ts @@ -15,6 +15,7 @@ import { DonationResponseDto, PlatformTipResponseDto, } from './dto/donation.dto'; +import { buildDonationCsv } from '../common/csv-export.helper'; @Injectable() export class DonationsService { @@ -287,28 +288,29 @@ export class DonationsService { }); if (!campaign) throw new NotFoundException('Campaign not found'); - const skip = (page - 1) * limit; const total = await this.prisma.donation.count({ - where: { campaignId, status: 'CONFIRMED' }, - }); + const skip = (page - 1) * limit; + const total = await this.prisma.donation.count({ + where: { campaignId, status: 'CONFIRMED' }, + }); - const donations = await this.prisma.donation.findMany({ - where: { campaignId, status: 'CONFIRMED' }, - include: { donor: { select: { walletAddress: true } } }, - orderBy: { [sortBy]: order }, - skip, - take: limit, - }); + const donations = await this.prisma.donation.findMany({ + where: { campaignId, status: 'CONFIRMED' }, + include: { donor: { select: { walletAddress: true } } }, + orderBy: { [sortBy]: order }, + skip, + take: limit, + }); - const donationsWithRank = donations.map((donation, index) => ({ - rank: skip + index + 1, - walletAddress: donation.isAnonymous - ? 'Anonymous' - : (donation.donor?.walletAddress ?? 'Anonymous'), - amount: donation.amount.toString(), - assetCode: donation.assetCode, - createdAt: donation.createdAt, - txHash: donation.txHash, - })); + const donationsWithRank = donations.map((donation, index) => ({ + rank: skip + index + 1, + walletAddress: donation.isAnonymous + ? 'Anonymous' + : (donation.donor?.walletAddress ?? 'Anonymous'), + amount: donation.amount.toString(), + assetCode: donation.assetCode, + createdAt: donation.createdAt, + txHash: donation.txHash, + })); return { donations: donationsWithRank, @@ -412,29 +414,15 @@ export class DonationsService { orderBy: { donatedAt: 'desc' }, }); - const headers = [ - 'Campaign', - 'Amount', - 'Asset', - 'Date', - 'Tx Hash', - 'USD Equivalent (pending)', - ]; - const rows: string[] = [headers.map((h) => `"${h}"`).join(',')]; - - for (const donation of donations) { - const row = [ - `"${(donation.campaign?.title || 'Unknown').replace(/"/g, '""')}"`, - donation.amount.toString(), - donation.assetCode, - donation.donatedAt.toISOString().split('T')[0], - `"${donation.txHash || ''}"`, - 'N/A', // Price oracle not yet integrated - ]; - rows.push(row.join(',')); - } - - return rows.join('\n'); + return buildDonationCsv( + donations.map((d) => ({ + campaignTitle: d.campaign?.title || 'Unknown', + amount: d.amount.toString(), + assetCode: d.assetCode, + donatedAt: d.donatedAt, + txHash: d.txHash, + })), + ); } /** Get or create a user record by Stellar wallet address */ diff --git a/src/users/export.processor.spec.ts b/src/users/export.processor.spec.ts new file mode 100644 index 0000000..5bb7664 --- /dev/null +++ b/src/users/export.processor.spec.ts @@ -0,0 +1,108 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExportProcessor, ExportDonationJobData } from './export.processor'; +import { PrismaService } from '../prisma/prisma.service'; +import { CSV_HEADERS } from '../common/csv-export.helper'; +import { Decimal } from '@prisma/client/runtime/library'; +import type { Job } from 'bull'; + +const makeDonation = (overrides: Record = {}) => ({ + id: 'don-2', + amount: new Decimal('75.00'), + assetCode: 'USDC', + txHash: 'txhash-xyz', + donatedAt: new Date('2024-05-20T00:00:00.000Z'), + status: 'CONFIRMED', + donorId: 'user-2', + campaignId: 'campaign-2', + isAnonymous: false, + assetIssuer: null, + tipAmount: null, + tipAsset: null, + tipId: null, + confirmedAt: null, + createdAt: new Date('2024-05-20T00:00:00.000Z'), + campaign: { title: 'Build A School' }, + ...overrides, +}); + +const makeJob = (data: ExportDonationJobData): Job => + ({ data, progress: jest.fn() }) as unknown as Job; + +describe('ExportProcessor – handleDonationExport', () => { + let processor: ExportProcessor; + let prismaMock: { donation: { findMany: jest.Mock } }; + + beforeEach(async () => { + prismaMock = { donation: { findMany: jest.fn() } }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExportProcessor, + { provide: PrismaService, useValue: prismaMock }, + ], + }).compile(); + + processor = module.get(ExportProcessor); + }); + + it('returns correct rowCount', async () => { + prismaMock.donation.findMany.mockResolvedValue([ + makeDonation(), + makeDonation({ id: 'don-3' }), + ]); + + const result = await processor.handleDonationExport( + makeJob({ userId: 'user-2' }), + ); + + expect(result.rowCount).toBe(2); + }); + + it('csv header line matches CSV_HEADERS and has no USD Equivalent', async () => { + prismaMock.donation.findMany.mockResolvedValue([makeDonation()]); + + const { csv } = await processor.handleDonationExport( + makeJob({ userId: 'user-2' }), + ); + const headerLine = csv.split('\n')[0]; + + expect(headerLine).toBe(CSV_HEADERS.map((h) => `"${h}"`).join(',')); + expect(headerLine).not.toMatch(/usd equivalent/i); + }); + + it('does not emit 0.00 or N/A placeholders', async () => { + prismaMock.donation.findMany.mockResolvedValue([makeDonation()]); + + const { csv } = await processor.handleDonationExport( + makeJob({ userId: 'user-2' }), + ); + + expect(csv).not.toMatch(/\b0\.00\b/); + expect(csv).not.toMatch(/\bN\/A\b/); + }); + + it('includes all expected fields in the data row', async () => { + prismaMock.donation.findMany.mockResolvedValue([makeDonation()]); + + const { csv } = await processor.handleDonationExport( + makeJob({ userId: 'user-2' }), + ); + const dataLine = csv.split('\n')[1]; + + expect(dataLine).toContain('75'); + expect(dataLine).toContain('USDC'); + expect(dataLine).toContain('2024-05-20'); + expect(dataLine).toContain('"txhash-xyz"'); + }); + + it('returns an empty csv (headers only) when no donations are found', async () => { + prismaMock.donation.findMany.mockResolvedValue([]); + + const { csv, rowCount } = await processor.handleDonationExport( + makeJob({ userId: 'user-2' }), + ); + + expect(rowCount).toBe(0); + expect(csv.split('\n')).toHaveLength(1); + }); +}); diff --git a/src/users/export.processor.ts b/src/users/export.processor.ts index bea4eb8..3ea3aed 100644 --- a/src/users/export.processor.ts +++ b/src/users/export.processor.ts @@ -4,6 +4,7 @@ import { Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { QUEUE_EXPORT } from '../queue/queue.constants'; +import { buildDonationCsv } from '../common/csv-export.helper'; export interface ExportDonationJobData { userId: string; @@ -63,30 +64,15 @@ export class ExportProcessor { orderBy: { donatedAt: 'desc' }, }); - // Build CSV - const headers = [ - 'Campaign', - 'Amount', - 'Asset', - 'Date', - 'Tx Hash', - 'USD Equivalent (pending)', - ]; - const rows: string[] = [headers.map((h) => `"${h}"`).join(',')]; - - for (const donation of donations) { - const row = [ - `"${(donation.campaignId || 'Unknown').replace(/"/g, '""')}"`, - donation.amount.toString(), - donation.assetCode, - donation.donatedAt.toISOString().split('T')[0], - `"${donation.txHash || ''}"`, - 'N/A', // Price oracle not yet integrated - ]; - rows.push(row.join(',')); - } - - const csv = rows.join('\n'); + const csv = buildDonationCsv( + donations.map((d) => ({ + campaignTitle: d.campaignId || 'Unknown', + amount: d.amount.toString(), + assetCode: d.assetCode, + donatedAt: d.donatedAt, + txHash: d.txHash, + })), + ); this.logger.log( `Donation export complete for user ${userId}: ${donations.length} rows`, diff --git a/src/users/users.service.csv.spec.ts b/src/users/users.service.csv.spec.ts new file mode 100644 index 0000000..7a62f47 --- /dev/null +++ b/src/users/users.service.csv.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getQueueToken } from '@nestjs/bull'; +import { UsersService } from './users.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { QUEUE_EXPORT } from '../queue/queue.constants'; +import { CSV_HEADERS } from '../common/csv-export.helper'; +import { Decimal } from '@prisma/client/runtime/library'; + +/** Minimal donation fixture that satisfies the Prisma shape used by the service */ +const makeDonation = (overrides: Record = {}) => ({ + id: 'don-1', + amount: new Decimal('50.00'), + assetCode: 'XLM', + txHash: 'txhash-abc', + donatedAt: new Date('2024-01-10T00:00:00.000Z'), + status: 'CONFIRMED', + donorId: 'user-1', + campaignId: 'campaign-1', + isAnonymous: false, + tipAmount: null, + tipAsset: null, + tipId: null, + confirmedAt: null, + createdAt: new Date('2024-01-10T00:00:00.000Z'), + assetIssuer: null, + campaign: { title: 'My Campaign' }, + ...overrides, +}); + +describe('UsersService – exportUserDonationsAsCSV (sync path)', () => { + let service: UsersService; + let prismaMock: { donation: { count: jest.Mock; findMany: jest.Mock } }; + let queueMock: { add: jest.Mock; getJob: jest.Mock }; + + beforeEach(async () => { + prismaMock = { + donation: { + count: jest.fn(), + findMany: jest.fn(), + }, + }; + + queueMock = { add: jest.fn(), getJob: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: PrismaService, useValue: prismaMock }, + { provide: getQueueToken(QUEUE_EXPORT), useValue: queueMock }, + ], + }).compile(); + + service = module.get(UsersService); + }); + + it('returns queued=false and a csv string for small exports', async () => { + prismaMock.donation.count.mockResolvedValue(1); + prismaMock.donation.findMany.mockResolvedValue([makeDonation()]); + + const result = await service.exportUserDonationsAsCSV('user-1'); + + expect(result.queued).toBe(false); + expect(result.jobId).toBeUndefined(); + expect(typeof result.csv).toBe('string'); + }); + + it('csv has exactly the expected headers without USD Equivalent', async () => { + prismaMock.donation.count.mockResolvedValue(1); + prismaMock.donation.findMany.mockResolvedValue([makeDonation()]); + + const { csv } = await service.exportUserDonationsAsCSV('user-1'); + const headerLine = csv!.split('\n')[0]; + + expect(headerLine).toBe(CSV_HEADERS.map((h) => `"${h}"`).join(',')); + expect(headerLine).not.toMatch(/usd equivalent/i); + expect(headerLine).not.toMatch(/pending/i); + }); + + it('csv data row contains campaign title, amount, asset, date, txhash', async () => { + prismaMock.donation.count.mockResolvedValue(1); + prismaMock.donation.findMany.mockResolvedValue([makeDonation()]); + + const { csv } = await service.exportUserDonationsAsCSV('user-1'); + const dataLine = csv!.split('\n')[1]; + + expect(dataLine).toContain('"My Campaign"'); + expect(dataLine).toContain('50'); + expect(dataLine).toContain('XLM'); + expect(dataLine).toContain('2024-01-10'); + expect(dataLine).toContain('"txhash-abc"'); + }); + + it('csv does not contain hardcoded 0.00 or N/A values', async () => { + prismaMock.donation.count.mockResolvedValue(1); + prismaMock.donation.findMany.mockResolvedValue([makeDonation()]); + + const { csv } = await service.exportUserDonationsAsCSV('user-1'); + + expect(csv).not.toMatch(/\b0\.00\b/); + expect(csv).not.toMatch(/\bN\/A\b/); + }); + + it('enqueues job and returns queued=true for large exports', async () => { + prismaMock.donation.count.mockResolvedValue(501); + queueMock.add.mockResolvedValue({ id: 'job-99' }); + + const result = await service.exportUserDonationsAsCSV('user-1'); + + expect(result.queued).toBe(true); + expect(result.jobId).toBe('job-99'); + expect(result.csv).toBeUndefined(); + }); + + it('returns headers-only csv when user has no donations', async () => { + prismaMock.donation.count.mockResolvedValue(0); + prismaMock.donation.findMany.mockResolvedValue([]); + + const { csv } = await service.exportUserDonationsAsCSV('user-1'); + const lines = csv!.split('\n'); + + expect(lines).toHaveLength(1); + expect(lines[0]).toBe(CSV_HEADERS.map((h) => `"${h}"`).join(',')); + }); +}); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index a9b7e86..b05dcbe 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -15,6 +15,7 @@ import { } from './dto/notification-preferences.dto'; import { QUEUE_EXPORT } from '../queue/queue.constants'; import type { ExportDonationJobData } from './export.processor'; +import { buildDonationCsv } from '../common/csv-export.helper'; /** Threshold above which exports are processed via Bull queue */ const EXPORT_QUEUE_THRESHOLD = 500; @@ -319,7 +320,8 @@ export class UsersService { _count: true, }); - const totalDonated: string = totalDonatedResult._sum?.amount?.toString() || '0'; + const totalDonated: string = + totalDonatedResult._sum?.amount?.toString() || '0'; const totalDonations: number = totalDonatedResult._count ?? 0; const averageDonation = totalDonations > 0 @@ -400,29 +402,17 @@ export class UsersService { orderBy: { donatedAt: 'desc' }, }); - const headers = [ - 'Campaign', - 'Amount', - 'Asset', - 'Date', - 'Tx Hash', - 'USD Equivalent (pending)', - ]; - const rows: string[] = [headers.map((h) => `"${h}"`).join(',')]; - - for (const donation of donations) { - const row = [ - `"${((donation as any).campaign?.title || 'Unknown').replace(/"/g, '""')}"`, - donation.amount.toString(), - donation.assetCode, - donation.donatedAt.toISOString().split('T')[0], - `"${donation.txHash || ''}"`, - 'N/A', // Price oracle not yet integrated - ]; - rows.push(row.join(',')); - } + const csv = buildDonationCsv( + donations.map((d) => ({ + campaignTitle: (d as any).campaign?.title || 'Unknown', + amount: d.amount.toString(), + assetCode: d.assetCode, + donatedAt: d.donatedAt, + txHash: d.txHash, + })), + ); - return { csv: rows.join('\n'), queued: false }; + return { csv, queued: false }; } /**