From 865c5a1f938f7109409d77d542111e86fd9ec247 Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Tue, 3 Mar 2026 07:38:51 +0800 Subject: [PATCH] bulletproof consent: Gift Aid (HMRC), email opt-in, WhatsApp opt-in with full audit trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GIFT AID (HMRC compliance): - Exact HMRC model declaration text displayed and recorded - Home address (line 1 + postcode) collected when Gift Aid is ticked - giftAidAt timestamp recorded separately from the boolean - Declaration text, donor name, timestamp stored in consentMeta JSON EMAIL + WHATSAPP (GDPR/PECR compliance): - Separate, granular opt-in checkboxes (not bundled, not pre-ticked) - Each consent records: exact text shown, timestamp, consent version - Consent checkboxes only appear when relevant contact info is provided - Cron reminders gated on consent — no sends without opt-in - Pledge creation WhatsApp receipt gated on whatsappOptIn AUDIT TRAIL (consentMeta JSON on every pledge): - giftAid: {declared, declarationText, declaredAt} - email: {granted, consentText, grantedAt} - whatsapp: {granted, consentText, grantedAt} - IP address captured server-side from x-forwarded-for - User agent captured client-side - consentVersion field for tracking wording changes EXPORTS: - CRM CSV now includes: donor_address, donor_postcode, gift_aid_declared_at, is_zakat, email_opt_in, whatsapp_opt_in - Gift Aid export has full HMRC-required fields Schema: 6 new columns on Pledge (donorAddressLine1, donorPostcode, giftAidAt, emailOptIn, whatsappOptIn, consentMeta) --- pledge-now-pay-later/pnpl-backup | 1 - .../20260303_consent_fields/migration.sql | 16 + pledge-now-pay-later/prisma/schema.prisma | 20 +- .../src/app/api/cron/reminders/route.ts | 8 +- .../src/app/api/exports/crm-pack/route.ts | 12 + .../src/app/api/pledges/route.ts | 28 +- .../src/app/p/[token]/page.tsx | 22 +- .../src/app/p/[token]/steps/identity-step.tsx | 474 ++++++++++++------ pledge-now-pay-later/src/lib/exports.ts | 6 + pledge-now-pay-later/src/lib/validators.ts | 35 ++ 10 files changed, 468 insertions(+), 154 deletions(-) delete mode 160000 pledge-now-pay-later/pnpl-backup create mode 100644 pledge-now-pay-later/prisma/migrations/20260303_consent_fields/migration.sql diff --git a/pledge-now-pay-later/pnpl-backup b/pledge-now-pay-later/pnpl-backup deleted file mode 160000 index 3883378..0000000 --- a/pledge-now-pay-later/pnpl-backup +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 38833783a20113fef2b43fa8170eb6a8420aed04 diff --git a/pledge-now-pay-later/prisma/migrations/20260303_consent_fields/migration.sql b/pledge-now-pay-later/prisma/migrations/20260303_consent_fields/migration.sql new file mode 100644 index 0000000..47a74d5 --- /dev/null +++ b/pledge-now-pay-later/prisma/migrations/20260303_consent_fields/migration.sql @@ -0,0 +1,16 @@ +-- Bulletproof consent: Gift Aid (HMRC), email opt-in, WhatsApp opt-in +-- Each consent is tracked with timestamp, exact declaration text, IP, user agent + +-- Home address for HMRC Gift Aid claims +ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "donorAddressLine1" TEXT; +ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "donorPostcode" TEXT; + +-- Gift Aid timestamp (when declaration was made) +ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "giftAidAt" TIMESTAMP(3); + +-- Communication consent (GDPR/PECR) +ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "emailOptIn" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "whatsappOptIn" BOOLEAN NOT NULL DEFAULT false; + +-- Consent audit trail (JSON blob — immutable evidence of what was shown + agreed) +ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "consentMeta" JSONB; diff --git a/pledge-now-pay-later/prisma/schema.prisma b/pledge-now-pay-later/prisma/schema.prisma index 06556f7..97ed657 100644 --- a/pledge-now-pay-later/prisma/schema.prisma +++ b/pledge-now-pay-later/prisma/schema.prisma @@ -102,8 +102,24 @@ model Pledge { donorName String? donorEmail String? donorPhone String? - giftAid Boolean @default(false) - isZakat Boolean @default(false) // donor marked this as Zakat + + // --- Home address (required by HMRC for Gift Aid claims) --- + donorAddressLine1 String? + donorPostcode String? + + // --- Gift Aid (HMRC) --- + giftAid Boolean @default(false) + giftAidAt DateTime? // when the declaration was made + isZakat Boolean @default(false) // donor marked this as Zakat + + // --- Communication consent (GDPR / PECR) --- + emailOptIn Boolean @default(false) + whatsappOptIn Boolean @default(false) + + // --- Consent audit trail (immutable evidence) --- + // Stores exact text shown, timestamps, IP, user agent per consent type + consentMeta Json? + iPaidClickedAt DateTime? notes String? diff --git a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts index bf8b089..19a0a56 100644 --- a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts +++ b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts @@ -62,8 +62,8 @@ export async function GET(request: NextRequest) { const daysSince = Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000) try { - // WhatsApp channel - if (channel === "whatsapp" && phone && whatsappReady) { + // WhatsApp channel — only if donor consented + if (channel === "whatsapp" && phone && whatsappReady && pledge.whatsappOptIn) { const result = await sendPledgeReminder(phone, { donorName: pledge.donorName || undefined, amountPounds: (pledge.amountPence / 100).toFixed(0), @@ -96,8 +96,8 @@ export async function GET(request: NextRequest) { } } } - // Email channel (exposed via webhook API for external tools like n8n/Zapier) - else if (channel === "email" && email) { + // Email channel — only if donor consented + else if (channel === "email" && email && pledge.emailOptIn) { // Generate content and store for external pickup const payload = reminder.payload as Record || {} const bankDetails = pledge.paymentInstruction?.bankDetails as Record | null diff --git a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts index 19eb374..95aa1a7 100644 --- a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts +++ b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts @@ -8,10 +8,16 @@ interface ExportPledge { donorName: string | null donorEmail: string | null donorPhone: string | null + donorAddressLine1: string | null + donorPostcode: string | null amountPence: number rail: string status: string giftAid: boolean + giftAidAt: Date | null + isZakat: boolean + emailOptIn: boolean + whatsappOptIn: boolean createdAt: Date paidAt: Date | null event: { name: string } @@ -50,6 +56,8 @@ export async function GET(request: NextRequest) { donor_name: p.donorName || "", donor_email: p.donorEmail || "", donor_phone: p.donorPhone || "", + donor_address: p.donorAddressLine1 || "", + donor_postcode: p.donorPostcode || "", amount_gbp: (p.amountPence / 100).toFixed(2), payment_method: p.rail, status: p.status, @@ -58,6 +66,10 @@ export async function GET(request: NextRequest) { volunteer_name: p.qrSource?.volunteerName || "", table_name: p.qrSource?.tableName || "", gift_aid: p.giftAid ? "Yes" : "No", + gift_aid_declared_at: p.giftAidAt?.toISOString() || "", + is_zakat: p.isZakat ? "Yes" : "No", + email_opt_in: p.emailOptIn ? "Yes" : "No", + whatsapp_opt_in: p.whatsappOptIn ? "Yes" : "No", pledged_at: p.createdAt.toISOString(), paid_at: p.paidAt?.toISOString() || "", days_to_collect: p.paidAt diff --git a/pledge-now-pay-later/src/app/api/pledges/route.ts b/pledge-now-pay-later/src/app/api/pledges/route.ts index afaa91a..3122655 100644 --- a/pledge-now-pay-later/src/app/api/pledges/route.ts +++ b/pledge-now-pay-later/src/app/api/pledges/route.ts @@ -105,7 +105,13 @@ export async function POST(request: NextRequest) { ) } - const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, isZakat, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data + const { amountPence, rail, donorName, donorEmail, donorPhone, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data + + // Capture IP for consent audit trail + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || request.headers.get("x-real-ip") + || "unknown" + const consentMetaWithIp = consentMeta ? { ...consentMeta, ip } : undefined // Get event + org const event = await prisma.event.findUnique({ @@ -160,8 +166,14 @@ export async function POST(request: NextRequest) { donorName: donorName || null, donorEmail: donorEmail || null, donorPhone: donorPhone || null, + donorAddressLine1: donorAddressLine1 || null, + donorPostcode: donorPostcode || null, giftAid, + giftAidAt: giftAid ? new Date() : null, isZakat: isZakat || false, + emailOptIn: emailOptIn || false, + whatsappOptIn: whatsappOptIn || false, + consentMeta: consentMetaWithIp || undefined, eventId, qrSourceId: qrSourceId || null, organizationId: org.id, @@ -189,8 +201,8 @@ export async function POST(request: NextRequest) { } }) - // WhatsApp receipt for the plan - if (donorPhone) { + // WhatsApp receipt for the plan (only if they consented) + if (donorPhone && whatsappOptIn) { const name = donorName?.split(" ")[0] || "there" const { sendWhatsAppMessage } = await import("@/lib/whatsapp") sendWhatsAppMessage(donorPhone, @@ -230,8 +242,14 @@ export async function POST(request: NextRequest) { donorName: donorName || null, donorEmail: donorEmail || null, donorPhone: donorPhone || null, + donorAddressLine1: donorAddressLine1 || null, + donorPostcode: donorPostcode || null, giftAid, + giftAidAt: giftAid ? new Date() : null, isZakat: isZakat || false, + emailOptIn: emailOptIn || false, + whatsappOptIn: whatsappOptIn || false, + consentMeta: consentMetaWithIp || undefined, eventId, qrSourceId: qrSourceId || null, organizationId: org.id, @@ -311,8 +329,8 @@ export async function POST(request: NextRequest) { } } - // Async: Send WhatsApp receipt to donor (non-blocking) - if (donorPhone) { + // Async: Send WhatsApp receipt to donor (only if they consented) + if (donorPhone && whatsappOptIn) { sendPledgeReceipt(donorPhone, { donorName: donorName || undefined, amountPounds: (amountPence / 100).toFixed(0), diff --git a/pledge-now-pay-later/src/app/p/[token]/page.tsx b/pledge-now-pay-later/src/app/p/[token]/page.tsx index cd9d93b..04c9323 100644 --- a/pledge-now-pay-later/src/app/p/[token]/page.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/page.tsx @@ -20,8 +20,14 @@ export interface PledgeData { donorName: string donorEmail: string donorPhone: string + donorAddressLine1: string + donorPostcode: string giftAid: boolean isZakat: boolean + emailOptIn: boolean + whatsappOptIn: boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any + consentMeta?: any // Scheduling scheduleMode: "now" | "date" | "installments" dueDate?: string @@ -64,8 +70,12 @@ export default function PledgePage() { donorName: "", donorEmail: "", donorPhone: "", + donorAddressLine1: "", + donorPostcode: "", giftAid: false, isZakat: false, + emailOptIn: false, + whatsappOptIn: false, scheduleMode: "now", }) const [pledgeResult, setPledgeResult] = useState<{ @@ -137,16 +147,24 @@ export default function PledgePage() { } // Submit pledge (from identity step, or card/DD steps) - const submitPledge = async (identity: { donorName: string; donorEmail: string; donorPhone: string; giftAid: boolean; isZakat?: boolean }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const submitPledge = async (identity: any) => { const finalData = { ...pledgeData, ...identity } setPledgeData(finalData) + // Inject IP + user agent into consent metadata for audit trail + const consentMeta = finalData.consentMeta ? { + ...finalData.consentMeta, + userAgent: navigator.userAgent, + } : undefined + try { const res = await fetch("/api/pledges", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...finalData, + consentMeta, eventId: eventInfo?.id, qrSourceId: eventInfo?.qrSourceId, isZakat: finalData.isZakat || false, @@ -209,7 +227,7 @@ export default function PledgePage() { 0: , 1: , 2: , - 3: , + 3: , 4: pledgeResult && , 5: pledgeResult && ( void - amount: number - zakatEligible?: boolean +// --- HMRC Gift Aid declaration (exact model wording) --- +const GIFT_AID_DECLARATION = + "I am a UK taxpayer and understand that if I pay less Income Tax and/or Capital Gains Tax in the current tax year than the amount of Gift Aid claimed on all my donations in that tax year it is my responsibility to pay any difference." + +const EMAIL_CONSENT_TEXT = + "I agree to receive email updates about this pledge, including payment reminders and receipts." + +const WHATSAPP_CONSENT_TEXT = + "I agree to receive WhatsApp messages about this pledge, including payment reminders and receipts. Reply STOP to opt out." + +const CONSENT_VERSION = "v1" + +interface ConsentData { + donorName: string + donorEmail: string + donorPhone: string + donorAddressLine1: string + donorPostcode: string + giftAid: boolean + isZakat: boolean + emailOptIn: boolean + whatsappOptIn: boolean + consentMeta: { + giftAid?: { declared: boolean; declarationText: string; declaredAt: string } + email?: { granted: boolean; consentText: string; grantedAt: string } + whatsapp?: { granted: boolean; consentText: string; grantedAt: string } + consentVersion: string + } } -export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) { +interface Props { + onSubmit: (data: ConsentData) => void + amount: number + zakatEligible?: boolean + orgName?: string +} + +export function IdentityStep({ onSubmit, amount, zakatEligible, orgName }: Props) { const [name, setName] = useState("") const [email, setEmail] = useState("") const [phone, setPhone] = useState("") + const [addressLine1, setAddressLine1] = useState("") + const [postcode, setPostcode] = useState("") const [giftAid, setGiftAid] = useState(false) const [isZakat, setIsZakat] = useState(false) + const [emailOptIn, setEmailOptIn] = useState(false) + const [whatsappOptIn, setWhatsappOptIn] = useState(false) const [submitting, setSubmitting] = useState(false) - const [contactMode, setContactMode] = useState<"email" | "phone">("email") + const [showGiftAidAddress, setShowGiftAidAddress] = useState(false) const nameRef = useRef(null) useEffect(() => { nameRef.current?.focus() }, []) - const hasContact = contactMode === "email" ? email.includes("@") : phone.length >= 10 - const isValid = hasContact + // When Gift Aid is toggled on, show address fields + useEffect(() => { + if (giftAid && !showGiftAidAddress) setShowGiftAidAddress(true) + }, [giftAid, showGiftAidAddress]) + + // When Gift Aid is toggled off, clear address + useEffect(() => { + if (!giftAid) { + setShowGiftAidAddress(false) + setAddressLine1("") + setPostcode("") + } + }, [giftAid]) + + const hasEmail = email.includes("@") + const hasPhone = phone.length >= 10 + const hasContact = hasEmail || hasPhone + // Gift Aid requires: name + address + postcode + UK taxpayer declaration + const giftAidValid = !giftAid || (name.length > 0 && addressLine1.length > 0 && postcode.length >= 5) + const isValid = hasContact && giftAidValid + const giftAidBonus = Math.round(amount * 0.25) const totalWithAid = amount + giftAidBonus const handleSubmit = async () => { if (!isValid) return setSubmitting(true) + + const now = new Date().toISOString() + + // Build immutable consent audit record + const consentMeta: ConsentData["consentMeta"] = { + consentVersion: CONSENT_VERSION, + } + + if (giftAid) { + consentMeta.giftAid = { + declared: true, + declarationText: GIFT_AID_DECLARATION, + declaredAt: now, + } + } + + if (emailOptIn && hasEmail) { + consentMeta.email = { + granted: true, + consentText: EMAIL_CONSENT_TEXT, + grantedAt: now, + } + } + + if (whatsappOptIn && hasPhone) { + consentMeta.whatsapp = { + granted: true, + consentText: WHATSAPP_CONSENT_TEXT, + grantedAt: now, + } + } + try { - await onSubmit({ donorName: name, donorEmail: email, donorPhone: phone, giftAid, isZakat: zakatEligible ? isZakat : false }) + await onSubmit({ + donorName: name, + donorEmail: email, + donorPhone: phone, + donorAddressLine1: giftAid ? addressLine1 : "", + donorPostcode: giftAid ? postcode : "", + giftAid, + isZakat: zakatEligible ? isZakat : false, + emailOptIn: emailOptIn && hasEmail, + whatsappOptIn: whatsappOptIn && hasPhone, + consentMeta, + }) } catch { setSubmitting(false) } @@ -50,18 +141,18 @@ export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) { Almost there!

- We just need a way to send you payment details + We just need your details to process this pledge

- {/* Minimal form */} + {/* ── Contact Details ── */}
{/* Name */}
setName(e.target.value)} autoComplete="name" @@ -72,145 +163,213 @@ export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) { )}
- {/* Contact mode toggle */} -
- - + {/* Email */} +
+ + setEmail(e.target.value)} + autoComplete="email" + inputMode="email" + className="w-full h-14 pl-12 pr-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all" + /> + {hasEmail && ( +
+ )}
- {/* Contact input */} - {contactMode === "email" ? ( -
- - setEmail(e.target.value)} - autoComplete="email" - inputMode="email" - className="w-full h-14 pl-12 pr-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all" - /> -
- ) : ( -
+ {/* Phone */} +
+
setPhone(e.target.value)} autoComplete="tel" inputMode="tel" className="w-full h-14 pl-12 pr-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all" /> -

- We'll send reminders via WhatsApp ✓ + {hasPhone && ( +

+ )} +
+ {!hasEmail && !hasPhone && ( +

+ Enter at least an email or mobile number

+ )} +
+
+ + {/* ── Zakat ── */} + {zakatEligible && ( + + )} + + {/* ── Gift Aid (HMRC) ── */} +
+ + + {/* Gift Aid address + declaration (shown when Gift Aid is on) */} + {giftAid && showGiftAidAddress && ( +
+ {/* HMRC requires home address */} +
+ + Home address (required by HMRC for Gift Aid) +
+ +
+ setAddressLine1(e.target.value)} + autoComplete="address-line1" + className="w-full h-12 px-4 rounded-xl border-2 border-gray-200 bg-white text-sm font-medium placeholder:text-gray-300 focus:border-success-green focus:ring-4 focus:ring-success-green/10 outline-none transition-all" + /> + {addressLine1 && ( +
+ )} +
+ +
+ setPostcode(e.target.value.toUpperCase())} + autoComplete="postal-code" + className="w-full h-12 px-4 rounded-xl border-2 border-gray-200 bg-white text-sm font-medium placeholder:text-gray-300 focus:border-success-green focus:ring-4 focus:ring-success-green/10 outline-none transition-all uppercase" + /> + {postcode.length >= 5 && ( +
+ )} +
+ + {/* HMRC declaration — exact model wording */} +
+

Gift Aid Declaration

+

+ {GIFT_AID_DECLARATION} +

+ {name && ( +

+ By ticking Gift Aid above, {name} declares this donation{orgName ? ` to ${orgName}` : ""} is eligible for Gift Aid. +

+ )} +
+ + {(!addressLine1 || postcode.length < 5) && ( +

+ + Please complete your address to claim Gift Aid +

+ )}
)}
- {/* Zakat — only when campaign is Zakat-eligible */} - {zakatEligible && ( - - )} + {/* ── Communication Consent (GDPR / PECR) ── */} +
+

Communication preferences

- {/* Gift Aid — the hero */} - + {/* WhatsApp consent — only shown if phone is provided */} + {hasPhone && ( + + )} - {/* Submit */} + {!hasEmail && !hasPhone && ( +

+ Enter an email or mobile number above to set your communication preferences. +

+ )} +
+ + {/* ── Submit ── */}
) } + +/* ── Reusable consent checkbox ── */ +function ConsentCheckbox({ checked, onChange, icon, label, description }: { + checked: boolean + onChange: (v: boolean) => void + icon: string + label: string + description: string +}) { + return ( + + ) +} diff --git a/pledge-now-pay-later/src/lib/exports.ts b/pledge-now-pay-later/src/lib/exports.ts index 91e547a..b10c090 100644 --- a/pledge-now-pay-later/src/lib/exports.ts +++ b/pledge-now-pay-later/src/lib/exports.ts @@ -3,6 +3,8 @@ export interface CrmExportRow { donor_name: string donor_email: string donor_phone: string + donor_address: string + donor_postcode: string amount_gbp: string payment_method: string status: string @@ -11,6 +13,10 @@ export interface CrmExportRow { volunteer_name: string table_name: string gift_aid: string + gift_aid_declared_at: string + is_zakat: string + email_opt_in: string + whatsapp_opt_in: string pledged_at: string paid_at: string days_to_collect: string diff --git a/pledge-now-pay-later/src/lib/validators.ts b/pledge-now-pay-later/src/lib/validators.ts index 45a1a73..ed000cb 100644 --- a/pledge-now-pay-later/src/lib/validators.ts +++ b/pledge-now-pay-later/src/lib/validators.ts @@ -25,8 +25,41 @@ export const createPledgeSchema = z.object({ donorName: z.string().max(200).optional().default(''), donorEmail: z.string().max(200).optional().default(''), donorPhone: z.string().max(20).optional().default(''), + + // Home address (required for HMRC Gift Aid) + donorAddressLine1: z.string().max(300).optional().default(''), + donorPostcode: z.string().max(10).optional().default(''), + + // Gift Aid giftAid: z.boolean().default(false), isZakat: z.boolean().default(false), + + // Communication consent (GDPR/PECR) + emailOptIn: z.boolean().default(false), + whatsappOptIn: z.boolean().default(false), + + // Consent audit trail + consentMeta: z.object({ + giftAid: z.object({ + declared: z.boolean(), + declarationText: z.string(), + declaredAt: z.string(), + }).optional(), + email: z.object({ + granted: z.boolean(), + consentText: z.string(), + grantedAt: z.string(), + }).optional(), + whatsapp: z.object({ + granted: z.boolean(), + consentText: z.string(), + grantedAt: z.string(), + }).optional(), + ip: z.string().optional(), + userAgent: z.string().optional(), + consentVersion: z.string().default('v1'), + }).optional(), + eventId: z.string(), qrSourceId: z.string().nullable().optional(), // Payment scheduling @@ -39,6 +72,8 @@ export const createPledgeSchema = z.object({ donorEmail: data.donorEmail && data.donorEmail.includes('@') ? data.donorEmail : undefined, donorPhone: data.donorPhone && data.donorPhone.length >= 10 ? data.donorPhone : undefined, donorName: data.donorName || undefined, + donorAddressLine1: data.donorAddressLine1 || undefined, + donorPostcode: data.donorPostcode || undefined, qrSourceId: data.qrSourceId || undefined, }))