Skip to content
Closed
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
7 changes: 7 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@ model Donation {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

// Conversion metadata for multi-currency analytics
baseCurrency String? @default("USD")
exchangeRate Decimal? @db.Decimal(20, 8)
convertedAmount Decimal? @db.Decimal(20, 8)
conversionTimestamp DateTime?

campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
taxReceipt TaxReceipt?
Expand All @@ -409,6 +415,7 @@ model Donation {
@@index([status])
@@index([blockchainTxHash])
@@index([createdAt])
@@index([currency])
}

// ============================================
Expand Down
3 changes: 3 additions & 0 deletions src/controllers/donation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class DonationController {
campaignId: req.query.campaignId as string,
userId: req.query.userId as string,
status: req.query.status as string,
currency: req.query.currency as string,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
};
Expand Down Expand Up @@ -112,6 +113,7 @@ export class DonationController {
userId: req.user.id,
campaignId: req.query.campaignId as string,
status: req.query.status as string,
currency: req.query.currency as string,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
};
Expand Down Expand Up @@ -141,6 +143,7 @@ export class DonationController {
const filters = {
campaignId,
status: req.query.status as string,
currency: req.query.currency as string,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
};
Expand Down
8 changes: 4 additions & 4 deletions src/controllers/notification.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ export class NotificationController {
throw new AppError('Authentication required', 401);
}

const { campaignTitle, amount } = req.body;
const { campaignTitle, amount, currency } = req.body;

await NotificationService.sendDonationReceivedNotification(req.user.id, campaignTitle, amount);
await NotificationService.sendDonationReceivedNotification(req.user.id, campaignTitle, amount, currency || 'XLM');

res.status(200).json({
success: true,
Expand Down Expand Up @@ -168,9 +168,9 @@ export class NotificationController {
throw new AppError('Authentication required', 401);
}

const { amount } = req.body;
const { amount, currency } = req.body;

await NotificationService.sendDistributionSentNotification(req.user.id, amount);
await NotificationService.sendDistributionSentNotification(req.user.id, amount, currency || 'XLM');

res.status(200).json({
success: true,
Expand Down
65 changes: 54 additions & 11 deletions src/services/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,31 @@ export class AnalyticsService {
throw new Error('Campaign not found');
}

// Calculate donation statistics
// Calculate donation statistics grouped by currency
const donationsByCurrency = campaign.donations.reduce((acc, d) => {
const curr = d.currency || 'XLM';
if (!acc[curr]) acc[curr] = { total: 0, count: 0 };
acc[curr].total += Number(d.amount);
acc[curr].count += 1;
return acc;
}, {} as Record<string, { total: number; count: number }>);

const totalDonations = campaign.donations.length;
const totalRaised = campaign.donations.reduce((sum, d) => sum + Number(d.amount), 0);
const avgDonation = totalDonations > 0 ? totalRaised / totalDonations : 0;
const avgDonation = totalDonations > 0
? Object.values(donationsByCurrency).reduce((s, c) => s + c.total, 0) / totalDonations
: 0;

// Calculate distribution statistics grouped by currency
const distributionsByCurrency = campaign.distributions.reduce((acc, d) => {
const curr = d.currency || 'XLM';
if (!acc[curr]) acc[curr] = { total: 0, count: 0 };
if (d.status === 'COMPLETED') {
acc[curr].total += Number(d.amount);
acc[curr].count += 1;
}
return acc;
}, {} as Record<string, { total: number; count: number }>);

// Calculate distribution statistics
const totalDistributed = campaign.distributions
.filter((d) => d.status === 'COMPLETED')
.reduce((sum, d) => sum + Number(d.amount), 0);
Expand Down Expand Up @@ -91,14 +110,16 @@ export class AnalyticsService {
},
donations: {
total: totalDonations,
totalRaised,
totalRaised: Object.values(donationsByCurrency).reduce((s, c) => s + c.total, 0),
avgDonation,
count: campaign._count.donations,
byCurrency: donationsByCurrency,
},
distributions: {
total: campaign._count.distributions,
totalDistributed,
completed: campaign.distributions.filter((d) => d.status === 'COMPLETED').length,
completed: campaign.distributions.filter((d: any) => d.status === 'COMPLETED').length,
byCurrency: distributionsByCurrency,
},
beneficiaries: {
total: campaign._count.beneficiaries,
Expand All @@ -124,6 +145,15 @@ export class AnalyticsService {
const totalDonated = donations.reduce((sum, d) => sum + Number(d.amount), 0);
const campaignsSupported = new Set(donations.map((d) => d.campaignId)).size;

// Group donations by currency
const donationsByCurrency = donations.reduce((acc, d) => {
const curr = d.currency || 'XLM';
if (!acc[curr]) acc[curr] = { total: 0, count: 0 };
acc[curr].total += Number(d.amount);
acc[curr].count += 1;
return acc;
}, {} as Record<string, { total: number; count: number }>);

// Monthly donation trend
const monthlyDonations = await prisma.$queryRaw`
SELECT
Expand All @@ -143,6 +173,7 @@ export class AnalyticsService {
totalDonations: donations.length,
campaignsSupported,
avgDonation: donations.length > 0 ? totalDonated / donations.length : 0,
byCurrency: donationsByCurrency,
recentDonations: donations.slice(0, 10),
monthlyTrend: monthlyDonations,
};
Expand All @@ -167,23 +198,35 @@ export class AnalyticsService {
});

const totalCampaigns = campaigns.length;
const activeCampaigns = campaigns.filter((c) => c.status === 'ACTIVE').length;
const activeCampaigns = campaigns.filter((c: any) => c.status === 'ACTIVE').length;
const totalRaised = campaigns.reduce(
(sum, c) => sum + c.donations.reduce((dSum, d) => dSum + Number(d.amount), 0),
(sum: any, c: any) => sum + c.donations.reduce((dSum: any, d: any) => dSum + Number(d.amount), 0),
0
);
const totalBeneficiaries = campaigns.reduce((sum, c) => sum + c._count.beneficiaries, 0);
const totalDistributions = campaigns.reduce((sum, c) => sum + c._count.distributions, 0);
const totalBeneficiaries = campaigns.reduce((sum: any, c: any) => sum + c._count.beneficiaries, 0);
const totalDistributions = campaigns.reduce((sum: any, c: any) => sum + c._count.distributions, 0);

// Group raised funds by currency
const fundsByCurrency = campaigns.reduce((acc, c: any) => {
for (const d of c.donations) {
const curr = d.currency || 'XLM';
if (!acc[curr]) acc[curr] = { total: 0, count: 0 };
acc[curr].total += Number(d.amount);
acc[curr].count += 1;
}
return acc;
}, {} as Record<string, { total: number; count: number }>);

return {
campaigns: {
total: totalCampaigns,
active: activeCampaigns,
completed: campaigns.filter((c) => c.status === 'COMPLETED').length,
completed: campaigns.filter((c: any) => c.status === 'COMPLETED').length,
},
funds: {
totalRaised,
avgPerCampaign: totalCampaigns > 0 ? totalRaised / totalCampaigns : 0,
byCurrency: fundsByCurrency,
},
impact: {
totalBeneficiaries,
Expand Down
8 changes: 8 additions & 0 deletions src/services/donation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export class DonationService {
...data,
userId,
status: DonationStatus.PENDING,
baseCurrency: data.baseCurrency || 'USD',
exchangeRate: data.exchangeRate ? data.exchangeRate : undefined,
convertedAmount: data.convertedAmount ? data.convertedAmount : undefined,
conversionTimestamp: (data.exchangeRate && data.convertedAmount) ? new Date() : undefined,
},
});

Expand Down Expand Up @@ -116,6 +120,10 @@ export class DonationService {
where.status = filters.status;
}

if (filters.currency) {
where.currency = filters.currency;
}

if (filters.startDate || filters.endDate) {
where.createdAt = {};

Expand Down
20 changes: 20 additions & 0 deletions src/services/notification.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,18 @@ describe('NotificationService', () => {
});

it('sendDonationReceivedNotification', async () => {
await NotificationService.sendDonationReceivedNotification('user-1', 'Campaign Title', 100, 'USDC');
expect(prismaMock.notification.create).toHaveBeenCalled();
const callArgs = prismaMock.notification.create.mock.calls[0][0];
expect(callArgs.data.message).toContain('100 USDC');
expect(callArgs.data.message).not.toContain('XLM');
});

it('sendDonationReceivedNotification defaults to XLM', async () => {
await NotificationService.sendDonationReceivedNotification('user-1', 'Campaign Title', 100);
expect(prismaMock.notification.create).toHaveBeenCalled();
const callArgs = prismaMock.notification.create.mock.calls[0][0];
expect(callArgs.data.message).toContain('100 XLM');
});

it('sendCampaignUpdateNotification', async () => {
Expand All @@ -365,8 +375,18 @@ describe('NotificationService', () => {
});

it('sendDistributionSentNotification', async () => {
await NotificationService.sendDistributionSentNotification('user-1', 50, 'EUR');
expect(prismaMock.notification.create).toHaveBeenCalled();
const callArgs = prismaMock.notification.create.mock.calls[0][0];
expect(callArgs.data.message).toContain('50 EUR');
expect(callArgs.data.message).not.toContain('XLM');
});

it('sendDistributionSentNotification defaults to XLM', async () => {
await NotificationService.sendDistributionSentNotification('user-1', 50);
expect(prismaMock.notification.create).toHaveBeenCalled();
const callArgs = prismaMock.notification.create.mock.calls[0][0];
expect(callArgs.data.message).toContain('50 XLM');
});

it('sendKYCApprovedNotification', async () => {
Expand Down
Loading