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
118 lines
4.2 KiB
TypeScript
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(),
|
|
});
|