Skip to content
Open
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
24 changes: 24 additions & 0 deletions src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
UseGuards,
Request,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AdminService } from './admin.service';
import { SuspendCampaignDto } from './dtos/suspend-campaign.dto';
Expand All @@ -28,4 +30,26 @@
): Promise<{ message: string }> {
return this.adminService.suspendCampaign(id, dto, req.user.sub, req.user.email);
}

/**

Check warning on line 34 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .user on an `any` value

Check warning on line 34 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
* POST /admin/donations/:id/refund

Check warning on line 35 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .user on an `any` value

Check warning on line 35 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
* Refund a confirmed donation, atomically updating the campaign's raisedAmount.
* Only available to admin users.
*/
@Post('donations/:id/refund')
@HttpCode(HttpStatus.OK)
async refundDonation(
@Param('id', ParseUUIDPipe) id: string,
): Promise<{
id: string;
amount: string;
assetCode: string;
status: string;
campaignId: string;
donorId: string;
txHash: string | null;
refundedAt: Date;
}> {
return this.adminService.refundDonation(id);
}
}
233 changes: 233 additions & 0 deletions src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { AdminService } from './admin.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';

describe('AdminService', () => {
let service: AdminService;
let prisma: PrismaService;

const mockCampaign = {
id: 'campaign-1',
title: 'Test Campaign',
raisedAmount: { toString: () => '150' },
status: 'ACTIVE' as const,
creatorId: 'creator-1',
createdAt: new Date(),
updatedAt: new Date(),
};

const mockDonationConfirmed = {
id: 'donation-1',
amount: { toString: () => '50' },
assetCode: 'XLM',
status: 'CONFIRMED' as const,
campaignId: 'campaign-1',
donorId: 'donor-1',
txHash: 'tx-hash-1',
confirmedAt: new Date(),
donatedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};

const mockDonationPending = {
...mockDonationConfirmed,
id: 'donation-2',
status: 'PENDING' as const,
amount: { toString: () => '30' },
txHash: 'tx-hash-2',
};

const mockDonationRefunded = {
...mockDonationConfirmed,
id: 'donation-3',
status: 'REFUNDED' as const,
amount: { toString: () => '20' },
txHash: 'tx-hash-3',
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{
provide: PrismaService,
useFactory: () => ({
$transaction: jest.fn(),
donation: {
findUnique: jest.fn(),
update: jest.fn(),
aggregate: jest.fn(),
},
campaign: {
findUnique: jest.fn(),
update: jest.fn(),
},
}),
},
{
provide: NotificationsService,
useValue: {
sendCampaignSuspensionEmail: jest.fn(),
},
},
],
}).compile();

service = module.get<AdminService>(AdminService);
prisma = module.get<PrismaService>(PrismaService);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('refundDonation', () => {
it('should refund a confirmed donation and recalculate campaign raisedAmount', async () => {
const txMock = {
donation: {
findUnique: jest.fn().mockResolvedValue(mockDonationConfirmed),
update: jest.fn().mockResolvedValue({
...mockDonationConfirmed,
status: 'REFUNDED',
updatedAt: new Date(),
}),
aggregate: jest.fn().mockResolvedValue({
_sum: { amount: { toString: () => '100' } },
}),
},
campaign: {
update: jest.fn().mockResolvedValue({
...mockCampaign,
raisedAmount: { toString: () => '100' },
}),
},
};

(prisma.$transaction as jest.Mock).mockImplementation(
async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
);

const result = await service.refundDonation('donation-1');

expect(result.status).toBe('REFUNDED');
expect(result.amount).toBe('50');
expect(result.campaignId).toBe('campaign-1');
expect(txMock.donation.findUnique).toHaveBeenCalledWith({
where: { id: 'donation-1' },
});
expect(txMock.donation.update).toHaveBeenCalledWith({
where: { id: 'donation-1' },
data: { status: 'REFUNDED' },
});
expect(txMock.donation.aggregate).toHaveBeenCalledWith({
where: {
campaignId: 'campaign-1',
status: 'CONFIRMED',
},
_sum: { amount: true },
});
expect(txMock.campaign.update).toHaveBeenCalledWith({
where: { id: 'campaign-1' },
data: { raisedAmount: expect.objectContaining({ toString: expect.any(Function) }) },
});

Check warning on line 135 in src/admin/admin.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
});

Check warning on line 136 in src/admin/admin.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value

it('should throw NotFoundException for non-existent donation', async () => {
const txMock = {
donation: {
findUnique: jest.fn().mockResolvedValue(null),
},
};

(prisma.$transaction as jest.Mock).mockImplementation(
async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
);

await expect(service.refundDonation('nonexistent')).rejects.toThrow(
NotFoundException,
);
});

it('should throw BadRequestException when donation is not CONFIRMED', async () => {
const txMock = {
donation: {
findUnique: jest.fn().mockResolvedValue(mockDonationPending),
},
};

(prisma.$transaction as jest.Mock).mockImplementation(
async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
);

await expect(service.refundDonation('donation-2')).rejects.toThrow(
BadRequestException,
);
await expect(service.refundDonation('donation-2')).rejects.toThrow(
'Only confirmed donations can be refunded',
);
});

it('should throw BadRequestException when donation is already REFUNDED', async () => {
const txMock = {
donation: {
findUnique: jest.fn().mockResolvedValue(mockDonationRefunded),
},
};

(prisma.$transaction as jest.Mock).mockImplementation(
async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
);

await expect(service.refundDonation('donation-3')).rejects.toThrow(
BadRequestException,
);
});

it('should correctly decrease raisedAmount by the refunded amount', async () => {
const txMock = {
donation: {
findUnique: jest.fn().mockResolvedValue(mockDonationConfirmed),
update: jest.fn().mockResolvedValue({
...mockDonationConfirmed,
status: 'REFUNDED',
updatedAt: new Date(),
}),
aggregate: jest.fn().mockResolvedValue({
_sum: { amount: { toString: () => '100' } },
}),
},
campaign: {
update: jest.fn().mockResolvedValue({
...mockCampaign,
raisedAmount: { toString: () => '100' },
}),
},
};

(prisma.$transaction as jest.Mock).mockImplementation(
async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
);

const result = await service.refundDonation('donation-1');

expect(result.status).toBe('REFUNDED');
expect(txMock.donation.aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: {
campaignId: 'campaign-1',
status: 'CONFIRMED',
},
}),
);
expect(txMock.campaign.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'campaign-1' },
data: { raisedAmount: expect.anything() },
}),
);
});
});

Check warning on line 232 in src/admin/admin.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
});
64 changes: 64 additions & 0 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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';
Expand All @@ -19,7 +20,7 @@
campaignId: string,
dto: SuspendCampaignDto,
adminId: string,
adminEmail: string,

Check warning on line 23 in src/admin/admin.service.ts

View workflow job for this annotation

GitHub Actions / Lint

'adminEmail' is defined but never used
): Promise<{ message: string }> {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
Expand Down Expand Up @@ -65,4 +66,67 @@

return { message: `Campaign ${campaignId} has been suspended` };
}

/**
* Refund a confirmed donation and atomically recalculate the campaign's
* raisedAmount within a single Prisma transaction.
*/
async refundDonation(donationId: string): Promise<{
id: string;
amount: string;
assetCode: string;
status: string;
campaignId: string;
donorId: string;
txHash: string | null;
refundedAt: Date;
}> {
return this.prisma.$transaction(async (tx) => {
const donation = await tx.donation.findUnique({
where: { id: donationId },
});

if (!donation) {
throw new NotFoundException('Donation not found');
}

if (donation.status !== 'CONFIRMED') {
throw new BadRequestException(
`Only confirmed donations can be refunded. Current status: ${donation.status}`,
);
}

const updated = await tx.donation.update({
where: { id: donationId },
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 },
});

return {
id: updated.id,
amount: updated.amount.toString(),
assetCode: updated.assetCode,
status: updated.status,
campaignId: updated.campaignId,
donorId: updated.donorId,
txHash: updated.txHash,
refundedAt: updated.updatedAt,
};
});
}
}
30 changes: 18 additions & 12 deletions src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,21 +244,27 @@ export class CampaignsService {
};
}

/** Recalculate a campaign's raisedAmount from confirmed donations */
/**
* Recalculate a campaign's raisedAmount from confirmed donations.
* Uses a Prisma $transaction to ensure the aggregate read and campaign
* update happen atomically.
*/
async recalculateCampaignStats(campaignId: string) {
const agg = await this.prisma.donation.aggregate({
where: {
campaignId,
status: 'CONFIRMED',
},
_sum: { amount: true },
});
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);
const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0);

await this.prisma.campaign.update({
where: { id: campaignId },
data: { raisedAmount },
await tx.campaign.update({
where: { id: campaignId },
data: { raisedAmount },
});
});
}

Expand Down
Loading
Loading