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
68 changes: 45 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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/*` |

---

Expand Down Expand Up @@ -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:
Expand Down
74 changes: 74 additions & 0 deletions src/common/csv-export.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
buildDonationCsv,
CSV_HEADERS,
DonationCsvRow,
} from './csv-export.helper';

const makeRow = (overrides: Partial<DonationCsvRow> = {}): 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);
});
});
56 changes: 56 additions & 0 deletions src/common/csv-export.helper.ts
Original file line number Diff line number Diff line change
@@ -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');
}
74 changes: 31 additions & 43 deletions src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
DonationResponseDto,
PlatformTipResponseDto,
} from './dto/donation.dto';
import { buildDonationCsv } from '../common/csv-export.helper';

@Injectable()
export class DonationsService {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 */
Expand Down
Loading
Loading