changemaker.lite/api/src/modules/payments/payment-settings.service.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

93 lines
3.1 KiB
TypeScript

import { Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database';
import type { PaymentSettings } from '@prisma/client';
import type { UpdatePaymentSettingsInput } from './payments.schemas';
import { encrypt, decrypt } from '../../utils/crypto';
import { resetStripeClient } from '../../services/stripe.client';
const ENCRYPTED_FIELDS = ['stripeSecretKey', 'stripeWebhookSecret'] as const;
const SENSITIVE_FIELDS = ['stripeSecretKey', 'stripeWebhookSecret'] as const;
function decryptSettings(settings: PaymentSettings): PaymentSettings {
for (const field of ENCRYPTED_FIELDS) {
const value = settings[field];
if (typeof value === 'string' && value) {
(settings as Record<string, unknown>)[field] = decrypt(value);
}
}
return settings;
}
export const paymentSettingsService = {
/** Full settings with decrypted secrets (admin use) */
async get(): Promise<PaymentSettings> {
let settings = await prisma.paymentSettings.findFirst();
if (!settings) {
settings = await prisma.paymentSettings.create({ data: {} });
}
return decryptSettings(settings);
},
/** Public-safe settings (strips secret keys) */
async getPublic() {
const settings = await this.get();
const result = { ...settings } as Record<string, unknown>;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
return result;
},
async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> {
const toWrite = { ...data } as Record<string, unknown>;
// Encrypt sensitive fields, skipping masked sentinel values from the admin UI
for (const field of ENCRYPTED_FIELDS) {
if (field in toWrite && typeof toWrite[field] === 'string') {
const val = toWrite[field] as string;
if (!val || val.startsWith('••••')) {
// Empty or mask string submitted — preserve existing encrypted value
delete toWrite[field];
continue;
}
toWrite[field] = encrypt(val);
}
}
// Handle donationSuggestedAmounts as JSON
if (data.donationSuggestedAmounts) {
toWrite.donationSuggestedAmounts = JSON.stringify(data.donationSuggestedAmounts);
}
const existing = await prisma.paymentSettings.findFirst();
let settings: PaymentSettings;
if (existing) {
settings = await prisma.paymentSettings.update({
where: { id: existing.id },
data: toWrite,
});
} else {
settings = await prisma.paymentSettings.create({ data: toWrite });
}
// Reset Stripe client so it picks up new keys
resetStripeClient();
return decryptSettings(settings);
},
};
/** Middleware: reject requests when payments are disabled in site settings */
export async function requirePaymentsEnabled(_req: Request, res: Response, next: NextFunction) {
try {
const settings = await prisma.siteSettings.findFirst({ select: { enablePayments: true } });
if (!settings?.enablePayments) {
res.status(403).json({ error: { message: 'Payments are not enabled', code: 'PAYMENTS_DISABLED' } });
return;
}
next();
} catch (err) {
next(err);
}
}