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
78 changes: 78 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ model User {
updatedAt DateTime @updatedAt

// Relations
sessions Session[]
organizations Organization?
beneficiary Beneficiary?
donations Donation[]
campaigns Campaign[]
auditLogs AuditLog[]
notifications Notification[]
kycSubmissions KYCSubmission[]
pledges Pledge[]
sessions Session[]
organizations Organization?
beneficiary Beneficiary?
Expand Down Expand Up @@ -234,6 +243,7 @@ model Campaign {
suspensions Suspension[]
appeals Appeal[]
fraudReports FraudReport[]
pledges Pledge[]

@@index([organizationId])
@@index([userId])
Expand Down Expand Up @@ -918,6 +928,74 @@ model FraudReport {
@@index([createdAt])
}

// ─── Pledge System ───────────────────────────────────────────────────────────

enum PledgeType {
ONE_OFF
RECURRING
}

enum PledgeCadence {
WEEKLY
MONTHLY
}

enum PledgeStatus {
ACTIVE
PAUSED
CANCELLED
FAILED
COMPLETED
}

enum PledgeAttemptStatus {
PENDING
SUCCESS
FAILED
}

model Pledge {
id String @id @default(uuid())
donorId String
campaignId String?
amount Decimal
currency String @default("USD")
type PledgeType
cadence PledgeCadence?
startDate DateTime
nextRunAt DateTime?
endDate DateTime?
status PledgeStatus @default(ACTIVE)
idempotencyKey String? @unique
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

donor User @relation(fields: [donorId], references: [id])
campaign Campaign? @relation(fields: [campaignId], references: [id])
attempts PledgeAttempt[]

@@index([donorId])
@@index([status, nextRunAt])
@@map("pledges")
}

model PledgeAttempt {
id String @id @default(uuid())
pledgeId String
attemptAt DateTime @default(now())
status PledgeAttemptStatus @default(PENDING)
providerReference String?
failureReason String?
retryCount Int @default(0)
metadata Json?
createdAt DateTime @default(now())

pledge Pledge @relation(fields: [pledgeId], references: [id])

@@index([pledgeId])
@@index([status])
@@map("pledge_attempts")
// ============================================
// TAX RECEIPTS
// ============================================
Expand Down
175 changes: 175 additions & 0 deletions src/controllers/pledge.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Request, Response, NextFunction } from 'express';
import { PledgeService } from '../services/pledge.service';
import { PledgeType, PledgeCadence, PledgeStatus } from '@prisma/client';
import logger from '../utils/logger';

export function createPledgeController(pledgeService: PledgeService) {
/**
* @notice POST /pledges
* Create a new pledge
*/
async function createPledge(req: Request, res: Response, next: NextFunction) {
try {
const {
donorId,
campaignId,
amount,
currency,
type,
cadence,
startDate,
endDate,
idempotencyKey,
metadata,
} = req.body;

if (!amount || !type || !startDate) {
return res.status(400).json({
error: { code: 'bad_request', message: 'amount, type, and startDate are required' },
});
}

if (!Object.values(PledgeType).includes(type)) {
return res.status(400).json({
error: { code: 'bad_request', message: `type must be ONE_OFF or RECURRING` },
});
}

const pledge = await pledgeService.createPledge({
donorId: donorId ?? (req as any).user?.id,
campaignId,
amount: Number(amount),
currency,
type,
cadence,
startDate: new Date(startDate),
endDate: endDate ? new Date(endDate) : undefined,
idempotencyKey,
metadata,
});

return res.status(201).json({ status: 'success', data: pledge });
} catch (error: any) {
if (error.message?.includes('required')) {
return res.status(400).json({ error: { code: 'bad_request', message: error.message } });
}
next(error);
}
}

/**
* @notice GET /pledges/:id
* Get pledge details with recent attempts
*/
async function getPledge(req: Request, res: Response, next: NextFunction) {
try {
const pledge = await pledgeService.getPledgeById(req.params.id);
if (!pledge) {
return res.status(404).json({ error: { code: 'not_found', message: 'Pledge not found' } });
}
return res.json({ status: 'success', data: pledge });
} catch (error) {
next(error);
}
}

/**
* @notice GET /pledges
* List pledges with pagination
*/
async function listPledges(req: Request, res: Response, next: NextFunction) {
try {
const { status, page, limit } = req.query;
const donorId = (req as any).user?.id;

const result = await pledgeService.listPledges({
donorId,
status: status as PledgeStatus | undefined,
page: page ? Number(page) : undefined,
limit: limit ? Number(limit) : undefined,
});

return res.json({ status: 'success', ...result });
} catch (error) {
next(error);
}
}

/**
* @notice POST /pledges/:id/cancel
* Cancel a pledge
*/
async function cancelPledge(req: Request, res: Response, next: NextFunction) {
try {
const { reason } = req.body;
const pledge = await pledgeService.cancelPledge(req.params.id, reason);
return res.json({ status: 'success', data: pledge });
} catch (error: any) {
if (error.message === 'Pledge not found') {
return res.status(404).json({ error: { code: 'not_found', message: error.message } });
}
if (error.message?.includes('already cancelled')) {
return res.status(409).json({ error: { code: 'conflict', message: error.message } });
}
next(error);
}
}

/**
* @notice GET /admin/pledges
* Admin: list all pledges
*/
async function adminListPledges(req: Request, res: Response, next: NextFunction) {
try {
const { status, donorId, page, limit } = req.query;
const result = await pledgeService.listPledges({
donorId: donorId as string | undefined,
status: status as PledgeStatus | undefined,
page: page ? Number(page) : undefined,
limit: limit ? Number(limit) : undefined,
});
return res.json({ status: 'success', ...result });
} catch (error) {
next(error);
}
}

/**
* @notice POST /admin/pledges/:id/pause
* Admin: pause or resume a pledge
*/
async function adminPausePledge(req: Request, res: Response, next: NextFunction) {
try {
const { action } = req.body;
const pledge = action === 'resume'
? await pledgeService.resumePledge(req.params.id)
: await pledgeService.pausePledge(req.params.id);
return res.json({ status: 'success', data: pledge });
} catch (error) {
next(error);
}
}

/**
* @notice GET /admin/pledges/:id/attempts
* Admin: list attempts for a pledge
*/
async function adminListAttempts(req: Request, res: Response, next: NextFunction) {
try {
const attempts = await pledgeService.listAttempts(req.params.id);
return res.json({ status: 'success', data: attempts });
} catch (error) {
next(error);
}
}

return {
createPledge,
getPledge,
listPledges,
cancelPledge,
adminListPledges,
adminPausePledge,
adminListAttempts,
};
}
36 changes: 36 additions & 0 deletions src/routes/pledge.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
import { PledgeService } from '../services/pledge.service';
import { createPledgeController } from '../controllers/pledge.controller';

/**
* @notice Creates pledge router with injected dependencies
*/
export function createPledgeRouter(prisma: PrismaClient): Router {
const router = Router();
const pledgeService = new PledgeService(prisma);
const controller = createPledgeController(pledgeService);

// Donor endpoints
router.post('/', controller.createPledge);
router.get('/', controller.listPledges);
router.get('/:id', controller.getPledge);
router.post('/:id/cancel', controller.cancelPledge);

return router;
}

/**
* @notice Creates admin pledge router
*/
export function createAdminPledgeRouter(prisma: PrismaClient): Router {
const router = Router();
const pledgeService = new PledgeService(prisma);
const controller = createPledgeController(pledgeService);

router.get('/', controller.adminListPledges);
router.post('/:id/pause', controller.adminPausePledge);
router.get('/:id/attempts', controller.adminListAttempts);

return router;
}
Loading