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

118 lines
4.2 KiB
TypeScript

import { z } from 'zod';
// --- Payment Settings ---
export const updatePaymentSettingsSchema = z.object({
stripeSecretKey: z.string().max(500).optional(),
stripePublishableKey: z.string().max(500).optional(),
stripeWebhookSecret: z.string().max(500).optional(),
defaultCurrency: z.string().min(3).max(3).optional(),
enableDonations: z.boolean().optional(),
donationSuggestedAmounts: z.array(z.number().int().min(100)).optional(),
donationMinimum: z.number().int().min(100).optional(),
donationPageTitle: z.string().max(200).optional(),
donationPageDescription: z.string().max(2000).nullable().optional(),
thankYouMessage: z.string().max(2000).optional(),
});
export type UpdatePaymentSettingsInput = z.infer<typeof updatePaymentSettingsSchema>;
// --- Subscription Plans ---
export const createPlanSchema = z.object({
name: z.string().min(1).max(100),
priceCAD: z.number().int().min(0),
durationDays: z.number().int().min(1),
yearlyPriceCAD: z.number().int().min(0).nullable().optional(),
features: z.array(z.string()).optional(),
description: z.string().max(2000).nullable().optional(),
tier: z.number().int().min(0).optional(),
displayOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
coverPhoto: z.string().max(1000).nullable().optional(),
coverVideoId: z.number().int().positive().nullable().optional(),
richDescription: z.string().max(50000).nullable().optional(),
ctaText: z.string().max(200).nullable().optional(),
ctaSubtext: z.string().max(500).nullable().optional(),
highlightPlan: z.boolean().optional(),
});
export const updatePlanSchema = createPlanSchema.partial();
export const listPlansSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
isActive: z.enum(['true', 'false']).optional(),
});
// --- Subscribe ---
export const createSubscriptionCheckoutSchema = z.object({
planId: z.number().int(),
frequency: z.enum(['monthly', 'yearly']).default('monthly'),
});
// --- Products ---
export const createProductSchema = z.object({
title: z.string().min(1).max(200),
slug: z.string().min(1).max(200).regex(/^[a-z0-9-]+$/),
description: z.string().max(5000).nullable().optional(),
priceCAD: z.number().int().min(0),
type: z.enum(['DIGITAL', 'EVENT', 'DONATION']),
isActive: z.boolean().optional(),
imageUrl: z.string().url().nullable().optional().or(z.literal('')),
photoId: z.number().int().positive().nullable().optional(),
videoId: z.number().int().positive().nullable().optional(),
galleryPhotoIds: z.array(z.number().int().positive()).nullable().optional(),
downloadUrl: z.string().max(1000).nullable().optional(),
metadata: z.record(z.unknown()).nullable().optional(),
maxPurchases: z.number().int().min(1).nullable().optional(),
});
export const updateProductSchema = createProductSchema.partial();
// --- Product Checkout ---
export const createProductCheckoutSchema = z.object({
productId: z.string(),
buyerEmail: z.string().email(),
buyerName: z.string().max(200).optional(),
});
// --- Donation ---
export const createDonationCheckoutSchema = z.object({
amountCents: z.number().int().min(100).max(10000000), // max $100,000
email: z.string().email(),
name: z.string().max(200).optional(),
message: z.string().max(2000).optional(),
isAnonymous: z.boolean().optional(),
campaignId: z.string().optional(),
});
// --- Refund ---
export const refundDonationSchema = z.object({
reason: z.string().max(500).optional(),
});
// --- Admin filters ---
export const subscriptionFiltersSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['active', 'cancelled', 'grace_period', 'delinquent', 'none', 'lifetime']).optional(),
planId: z.coerce.number().int().optional(),
search: z.string().optional(),
});
export const orderFiltersSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['PENDING', 'COMPLETED', 'FAILED', 'REFUNDED', 'DISPUTED']).optional(),
type: z.enum(['product', 'donation']).optional(),
search: z.string().optional(),
});