changemaker.lite/api/src/modules/payments/donation-pages-public.routes.ts
bunker-admin 0c2ffe754e Harden Stripe payment integration: 15 security fixes from audit
Addresses 11 original findings (1 critical, 3 high, 4 medium, 3 low)
plus 4 additional findings from security review:

- Mask secrets in PUT /settings response (was leaking decrypted keys)
- Add paymentCheckoutRateLimit (10/hr/IP) to all 5 checkout endpoints
- Implement durable audit logging to payment_audit_log table
- Pin Stripe API version to 2026-01-28.clover (SDK v20.3.1)
- Add charge.dispute.created/closed webhook handlers with DISPUTED status
- Restore tickets on dispute won, handle charge_refunded closure
- Guard against sentinel passthrough corrupting stored Stripe keys
- Wrap refund DB updates in try/catch with webhook reconciliation fallback
- Add $transaction for product maxPurchases race condition
- Remove dead Payment model lookup from handleChargeRefunded
- Cap donation amount at $100k in both schemas
- Add requirePaymentsEnabled middleware on all checkout routes
- Remove Stripe internal IDs from CSV exports
- Add Cache-Control: no-store on admin settings responses

Bunker Admin
2026-03-31 08:34:23 -06:00

72 lines
2.1 KiB
TypeScript

import { Router, Request, Response, NextFunction } from 'express';
import { validate } from '../../middleware/validate';
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
import { requirePaymentsEnabled } from './payment-settings.service';
import { donationPagesService } from './donation-pages.service';
import { donationsService } from './donations.service';
import { donationPageCheckoutSchema } from './donation-pages.schemas';
const router = Router();
// GET /api/donation-pages — list active pages (with stats)
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
const pages = await donationPagesService.findActivePages();
res.json(pages);
} catch (err) {
next(err);
}
});
// GET /api/donation-pages/:slug — get active page by slug
router.get('/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = await donationPagesService.findBySlugPublic(req.params.slug as string);
res.json(page);
} catch (err) {
next(err);
}
});
// POST /api/donation-pages/:slug/donate — create Stripe checkout for this page
router.post(
'/:slug/donate',
requirePaymentsEnabled,
paymentCheckoutRateLimit,
validate(donationPageCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const page = await donationPagesService.findBySlugPublic(req.params.slug as string);
const { amountCents, email, name, message, isAnonymous } = req.body;
// Validate against page-specific minimum
if (amountCents < page.minimumAmount) {
res.status(400).json({
error: {
message: `Minimum donation is $${(page.minimumAmount / 100).toFixed(2)}`,
code: 'MINIMUM_NOT_MET',
},
});
return;
}
const result = await donationsService.createDonationCheckout(
amountCents,
email,
name,
message,
isAnonymous,
page.id,
page.slug,
page.title,
);
res.json(result);
} catch (err) {
next(err);
}
},
);
export { router as donationPagesPublicRouter };