feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
37
pledge-now-pay-later/src/lib/analytics.ts
Normal file
37
pledge-now-pay-later/src/lib/analytics.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import prisma from './prisma'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
|
||||
export type AnalyticsEventType =
|
||||
| 'pledge_start'
|
||||
| 'amount_selected'
|
||||
| 'rail_selected'
|
||||
| 'identity_submitted'
|
||||
| 'pledge_completed'
|
||||
| 'instruction_copy_clicked'
|
||||
| 'i_paid_clicked'
|
||||
| 'payment_matched'
|
||||
|
||||
export async function trackEvent(
|
||||
eventType: AnalyticsEventType,
|
||||
data: {
|
||||
pledgeId?: string
|
||||
eventId?: string
|
||||
qrSourceId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
) {
|
||||
try {
|
||||
await prisma.analyticsEvent.create({
|
||||
data: {
|
||||
eventType,
|
||||
pledgeId: data.pledgeId,
|
||||
eventId: data.eventId,
|
||||
qrSourceId: data.qrSourceId,
|
||||
metadata: (data.metadata || {}) as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Analytics tracking error:', error)
|
||||
// Never throw - analytics should not break the flow
|
||||
}
|
||||
}
|
||||
42
pledge-now-pay-later/src/lib/exports.ts
Normal file
42
pledge-now-pay-later/src/lib/exports.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export interface CrmExportRow {
|
||||
pledge_reference: string
|
||||
donor_name: string
|
||||
donor_email: string
|
||||
donor_phone: string
|
||||
amount_gbp: string
|
||||
payment_method: string
|
||||
status: string
|
||||
event_name: string
|
||||
source_label: string
|
||||
volunteer_name: string
|
||||
table_name: string
|
||||
gift_aid: string
|
||||
pledged_at: string
|
||||
paid_at: string
|
||||
days_to_collect: string
|
||||
}
|
||||
|
||||
export function formatCrmExportCsv(rows: CrmExportRow[]): string {
|
||||
if (rows.length === 0) return ''
|
||||
const headers = Object.keys(rows[0])
|
||||
const lines = [
|
||||
headers.join(','),
|
||||
...rows.map((row) =>
|
||||
headers
|
||||
.map((h) => {
|
||||
const val = (row as unknown as Record<string, string>)[h] || ''
|
||||
return val.includes(',') || val.includes('"') ? `"${val.replace(/"/g, '""')}"` : val
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function formatWebhookPayload(event: string, data: Record<string, unknown>) {
|
||||
return {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
171
pledge-now-pay-later/src/lib/gocardless.ts
Normal file
171
pledge-now-pay-later/src/lib/gocardless.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* GoCardless API client using direct REST calls.
|
||||
* Supports Sandbox and Live environments.
|
||||
*/
|
||||
|
||||
const GC_URLS = {
|
||||
sandbox: "https://api-sandbox.gocardless.com",
|
||||
live: "https://api.gocardless.com",
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.GOCARDLESS_ENVIRONMENT === "live"
|
||||
? GC_URLS.live
|
||||
: GC_URLS.sandbox
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
const token = process.env.GOCARDLESS_ACCESS_TOKEN
|
||||
if (!token || token === "sandbox_token" || token === "REPLACE_ME") return null
|
||||
return token
|
||||
}
|
||||
|
||||
async function gcFetch(path: string, body: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
const token = getToken()
|
||||
if (!token) return null
|
||||
|
||||
const res = await fetch(`${getBaseUrl()}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"GoCardless-Version": "2015-07-06",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
console.error(`GoCardless API error [${res.status}] ${path}:`, err)
|
||||
return null
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function gcGet(path: string): Promise<Record<string, unknown> | null> {
|
||||
const token = getToken()
|
||||
if (!token) return null
|
||||
|
||||
const res = await fetch(`${getBaseUrl()}${path}`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"GoCardless-Version": "2015-07-06",
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
console.error(`GoCardless API error [${res.status}] ${path}:`, err)
|
||||
return null
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if GoCardless is configured and accessible.
|
||||
*/
|
||||
export function isGoCardlessConfigured(): boolean {
|
||||
return getToken() !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Redirect Flow — the simplest way to set up a Direct Debit mandate.
|
||||
* The donor is redirected to GoCardless-hosted pages to enter bank details.
|
||||
*/
|
||||
export async function createRedirectFlow(opts: {
|
||||
description: string
|
||||
reference: string
|
||||
pledgeId: string
|
||||
successRedirectUrl: string
|
||||
}): Promise<{ redirectUrl: string; redirectFlowId: string } | null> {
|
||||
const data = await gcFetch("/redirect_flows", {
|
||||
redirect_flows: {
|
||||
description: opts.description,
|
||||
session_token: opts.pledgeId,
|
||||
success_redirect_url: opts.successRedirectUrl,
|
||||
scheme: "bacs",
|
||||
},
|
||||
})
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const flow = (data as { redirect_flows: { id: string; redirect_url: string } }).redirect_flows
|
||||
return {
|
||||
redirectUrl: flow.redirect_url,
|
||||
redirectFlowId: flow.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a Redirect Flow after the donor returns from GoCardless.
|
||||
* This creates the mandate and returns its ID.
|
||||
*/
|
||||
export async function completeRedirectFlow(
|
||||
redirectFlowId: string,
|
||||
sessionToken: string
|
||||
): Promise<{ mandateId: string; customerId: string } | null> {
|
||||
const data = await gcFetch(`/redirect_flows/${redirectFlowId}/actions/complete`, {
|
||||
data: {
|
||||
session_token: sessionToken,
|
||||
},
|
||||
})
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const flow = (data as { redirect_flows: { links: { mandate: string; customer: string } } }).redirect_flows
|
||||
return {
|
||||
mandateId: flow.links.mandate,
|
||||
customerId: flow.links.customer,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payment against an existing mandate.
|
||||
*/
|
||||
export async function createPayment(opts: {
|
||||
amountPence: number
|
||||
mandateId: string
|
||||
reference: string
|
||||
pledgeId: string
|
||||
description: string
|
||||
}): Promise<{ paymentId: string; status: string; chargeDate: string } | null> {
|
||||
const data = await gcFetch("/payments", {
|
||||
payments: {
|
||||
amount: opts.amountPence,
|
||||
currency: "GBP",
|
||||
description: opts.description,
|
||||
reference: opts.reference,
|
||||
metadata: {
|
||||
pledge_id: opts.pledgeId,
|
||||
},
|
||||
links: {
|
||||
mandate: opts.mandateId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const payment = (data as { payments: { id: string; status: string; charge_date: string } }).payments
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: payment.status,
|
||||
chargeDate: payment.charge_date,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a payment's current status.
|
||||
*/
|
||||
export async function getPayment(paymentId: string): Promise<{ status: string; chargeDate: string } | null> {
|
||||
const data = await gcGet(`/payments/${paymentId}`)
|
||||
if (!data) return null
|
||||
|
||||
const payment = (data as { payments: { status: string; charge_date: string } }).payments
|
||||
return {
|
||||
status: payment.status,
|
||||
chargeDate: payment.charge_date,
|
||||
}
|
||||
}
|
||||
98
pledge-now-pay-later/src/lib/matching.ts
Normal file
98
pledge-now-pay-later/src/lib/matching.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { normalizeReference } from './reference'
|
||||
|
||||
export interface BankRow {
|
||||
date: string
|
||||
description: string
|
||||
amount: number // in pounds, positive for credits
|
||||
reference?: string
|
||||
raw: Record<string, string>
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
bankRow: BankRow
|
||||
pledgeId: string | null
|
||||
pledgeReference: string | null
|
||||
confidence: 'exact' | 'partial' | 'amount_only' | 'none'
|
||||
matchedAmount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to match a bank statement row against known pledge references
|
||||
*/
|
||||
export function matchBankRow(
|
||||
row: BankRow,
|
||||
pledgeRefs: Map<string, { id: string; amountPence: number }>
|
||||
): MatchResult {
|
||||
const entries = Array.from(pledgeRefs.entries())
|
||||
|
||||
// Strategy 1: exact reference match in ref field
|
||||
if (row.reference) {
|
||||
const normalized = normalizeReference(row.reference)
|
||||
for (const [ref, pledge] of entries) {
|
||||
if (normalizeReference(ref) === normalized) {
|
||||
return {
|
||||
bankRow: row,
|
||||
pledgeId: pledge.id,
|
||||
pledgeReference: ref,
|
||||
confidence: 'exact',
|
||||
matchedAmount: row.amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: reference found in description (not in dedicated ref field)
|
||||
const descNormalized = row.description.replace(/[\s]/g, '').toUpperCase()
|
||||
for (const [ref, pledge] of entries) {
|
||||
const refNorm = normalizeReference(ref)
|
||||
if (descNormalized.includes(refNorm)) {
|
||||
return {
|
||||
bankRow: row,
|
||||
pledgeId: pledge.id,
|
||||
pledgeReference: ref,
|
||||
confidence: 'partial',
|
||||
matchedAmount: row.amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: partial reference match (at least the 4-char code portion)
|
||||
for (const [ref, pledge] of entries) {
|
||||
const parts = ref.split('-')
|
||||
const codePart = parts.length >= 2 ? parts[1] : ''
|
||||
if (codePart.length >= 4 && descNormalized.includes(codePart)) {
|
||||
return {
|
||||
bankRow: row,
|
||||
pledgeId: pledge.id,
|
||||
pledgeReference: ref,
|
||||
confidence: 'partial',
|
||||
matchedAmount: row.amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bankRow: row,
|
||||
pledgeId: null,
|
||||
pledgeReference: null,
|
||||
confidence: 'none',
|
||||
matchedAmount: row.amount,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSV bank statement into BankRow[]
|
||||
* Handles common UK bank export formats
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function parseBankCsv(_csvData: string, _columnMapping?: {
|
||||
dateCol?: string
|
||||
descriptionCol?: string
|
||||
amountCol?: string
|
||||
creditCol?: string
|
||||
referenceCol?: string
|
||||
}): BankRow[] {
|
||||
// Dynamic import handled at call site; this returns parsed rows
|
||||
// Caller should use papaparse to get rows, then pass here
|
||||
return [] // implemented in the API route with papaparse
|
||||
}
|
||||
29
pledge-now-pay-later/src/lib/org.ts
Normal file
29
pledge-now-pay-later/src/lib/org.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import prisma from "@/lib/prisma"
|
||||
|
||||
/**
|
||||
* Resolve organization ID from x-org-id header.
|
||||
* The header may contain a slug or a direct ID — we try slug first, then ID.
|
||||
*/
|
||||
export async function resolveOrgId(headerValue: string | null): Promise<string | null> {
|
||||
const val = headerValue?.trim()
|
||||
if (!val) return null
|
||||
|
||||
// Try by slug first (most common from frontend)
|
||||
const bySlug = await prisma.organization.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ slug: val },
|
||||
{ slug: { startsWith: val } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (bySlug) return bySlug.id
|
||||
|
||||
// Try direct ID
|
||||
const byId = await prisma.organization.findUnique({
|
||||
where: { id: val },
|
||||
select: { id: true },
|
||||
})
|
||||
return byId?.id ?? null
|
||||
}
|
||||
21
pledge-now-pay-later/src/lib/prisma.ts
Normal file
21
pledge-now-pay-later/src/lib/prisma.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import pg from 'pg'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { PrismaClient } from '@/generated/prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
function createPrismaClient(): PrismaClient {
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
|
||||
const adapter = new PrismaPg(pool)
|
||||
return new PrismaClient({ adapter })
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
|
||||
export default prisma
|
||||
48
pledge-now-pay-later/src/lib/qr.ts
Normal file
48
pledge-now-pay-later/src/lib/qr.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
export interface QrGenerateOptions {
|
||||
baseUrl: string
|
||||
code: string
|
||||
width?: number
|
||||
margin?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code as data URL (for display)
|
||||
*/
|
||||
export async function generateQrDataUrl(opts: QrGenerateOptions): Promise<string> {
|
||||
const url = `${opts.baseUrl}/p/${opts.code}`
|
||||
return QRCode.toDataURL(url, {
|
||||
width: opts.width || 400,
|
||||
margin: opts.margin || 2,
|
||||
color: { dark: '#1e40af', light: '#ffffff' },
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code as SVG string (for print)
|
||||
*/
|
||||
export async function generateQrSvg(opts: QrGenerateOptions): Promise<string> {
|
||||
const url = `${opts.baseUrl}/p/${opts.code}`
|
||||
return QRCode.toString(url, {
|
||||
type: 'svg',
|
||||
width: opts.width || 400,
|
||||
margin: opts.margin || 2,
|
||||
color: { dark: '#1e40af', light: '#ffffff' },
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code as PNG buffer (for download)
|
||||
*/
|
||||
export async function generateQrBuffer(opts: QrGenerateOptions): Promise<Buffer> {
|
||||
const url = `${opts.baseUrl}/p/${opts.code}`
|
||||
return QRCode.toBuffer(url, {
|
||||
width: opts.width || 800,
|
||||
margin: opts.margin || 2,
|
||||
color: { dark: '#1e40af', light: '#ffffff' },
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
}
|
||||
39
pledge-now-pay-later/src/lib/reference.ts
Normal file
39
pledge-now-pay-later/src/lib/reference.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
// Human-safe alphabet: no 0/O, 1/I/l confusion
|
||||
const safeAlphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
const generateCode = customAlphabet(safeAlphabet, 6)
|
||||
|
||||
/**
|
||||
* Generate a unique, human-safe, bank-compatible payment reference.
|
||||
* Format: PREFIX-XXXXXX-NN where:
|
||||
* - PREFIX is org configurable (default "PNPL")
|
||||
* - XXXXXX is 6-char alphanumeric (safe chars only)
|
||||
* - NN is amount pounds (helps manual matching)
|
||||
* Total max 18 chars (UK bank ref limit)
|
||||
*/
|
||||
export function generateReference(prefix: string = 'PNPL', amountPence?: number): string {
|
||||
const code = generateCode()
|
||||
const amountSuffix = amountPence ? Math.floor(amountPence / 100).toString().slice(-3) : ''
|
||||
const ref = amountSuffix ? `${prefix}-${code}-${amountSuffix}` : `${prefix}-${code}`
|
||||
|
||||
// UK BACS reference max 18 chars
|
||||
if (ref.length > 18) {
|
||||
return `${prefix.slice(0, 4)}-${code}`
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a reference for matching (strip spaces, dashes, uppercase)
|
||||
*/
|
||||
export function normalizeReference(ref: string): string {
|
||||
return ref.replace(/[\s\-]/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two references match (fuzzy matching for bank statements)
|
||||
*/
|
||||
export function referencesMatch(ref1: string, ref2: string): boolean {
|
||||
return normalizeReference(ref1) === normalizeReference(ref2)
|
||||
}
|
||||
100
pledge-now-pay-later/src/lib/reminders.ts
Normal file
100
pledge-now-pay-later/src/lib/reminders.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export interface ReminderStep {
|
||||
step: number
|
||||
delayDays: number
|
||||
channel: 'email' | 'sms' | 'whatsapp'
|
||||
subject: string
|
||||
templateKey: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const DEFAULT_REMINDER_SEQUENCE: ReminderStep[] = [
|
||||
{
|
||||
step: 0,
|
||||
delayDays: 0,
|
||||
channel: 'email',
|
||||
subject: 'Your pledge payment details',
|
||||
templateKey: 'instructions',
|
||||
description: 'Payment instructions with bank details and reference',
|
||||
},
|
||||
{
|
||||
step: 1,
|
||||
delayDays: 2,
|
||||
channel: 'email',
|
||||
subject: 'Quick reminder about your pledge',
|
||||
templateKey: 'gentle_nudge',
|
||||
description: 'Gentle nudge - "We noticed your pledge hasn\'t been received yet"',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
delayDays: 7,
|
||||
channel: 'email',
|
||||
subject: 'Your pledge is making a difference',
|
||||
templateKey: 'urgency_impact',
|
||||
description: 'Impact story + urgency - "Your £X helps fund..."',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
delayDays: 14,
|
||||
channel: 'email',
|
||||
subject: 'Final reminder about your pledge',
|
||||
templateKey: 'final_reminder',
|
||||
description: 'Final reminder with easy cancel option',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Calculate reminder schedule dates from pledge creation
|
||||
*/
|
||||
export function calculateReminderSchedule(
|
||||
pledgeCreatedAt: Date,
|
||||
sequence: ReminderStep[] = DEFAULT_REMINDER_SEQUENCE
|
||||
): Array<{ step: number; scheduledAt: Date; channel: string; templateKey: string; subject: string }> {
|
||||
return sequence.map((s) => ({
|
||||
step: s.step,
|
||||
scheduledAt: new Date(pledgeCreatedAt.getTime() + s.delayDays * 24 * 60 * 60 * 1000),
|
||||
channel: s.channel,
|
||||
templateKey: s.templateKey,
|
||||
subject: s.subject,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate reminder message content from template
|
||||
*/
|
||||
export function generateReminderContent(
|
||||
templateKey: string,
|
||||
variables: {
|
||||
donorName?: string
|
||||
amount: string
|
||||
reference: string
|
||||
bankName?: string
|
||||
sortCode?: string
|
||||
accountNo?: string
|
||||
accountName?: string
|
||||
eventName: string
|
||||
pledgeUrl: string
|
||||
cancelUrl: string
|
||||
}
|
||||
): { subject: string; body: string } {
|
||||
const name = variables.donorName || 'there'
|
||||
const templates: Record<string, { subject: string; body: string }> = {
|
||||
instructions: {
|
||||
subject: `Payment details for your £${variables.amount} pledge`,
|
||||
body: `Hi ${name},\n\nThank you for pledging £${variables.amount} at ${variables.eventName}!\n\nTo complete your donation, please transfer to:\n\nBank: ${variables.bankName || 'See details'}\nSort Code: ${variables.sortCode || 'N/A'}\nAccount: ${variables.accountNo || 'N/A'}\nName: ${variables.accountName || 'N/A'}\nReference: ${variables.reference}\n\n⚠️ Please use the exact reference above so we can match your payment.\n\nView your pledge: ${variables.pledgeUrl}\n\nThank you for your generosity!`,
|
||||
},
|
||||
gentle_nudge: {
|
||||
subject: `Quick reminder: your £${variables.amount} pledge`,
|
||||
body: `Hi ${name},\n\nJust a friendly reminder about your £${variables.amount} pledge at ${variables.eventName}.\n\nIf you've already sent the payment, thank you! It can take a few days to appear.\n\nIf not, here's your reference: ${variables.reference}\n\nView details: ${variables.pledgeUrl}\n\nNo longer wish to donate? ${variables.cancelUrl}`,
|
||||
},
|
||||
urgency_impact: {
|
||||
subject: `Your £${variables.amount} pledge is making a difference`,
|
||||
body: `Hi ${name},\n\nYour £${variables.amount} pledge from ${variables.eventName} is still outstanding.\n\nEvery donation makes a real impact. Your contribution helps us continue our vital work.\n\nPayment reference: ${variables.reference}\nView details: ${variables.pledgeUrl}\n\nNeed help? Just reply to this email.\nCancel pledge: ${variables.cancelUrl}`,
|
||||
},
|
||||
final_reminder: {
|
||||
subject: `Final reminder: £${variables.amount} pledge`,
|
||||
body: `Hi ${name},\n\nThis is our final reminder about your £${variables.amount} pledge from ${variables.eventName}.\n\nWe understand circumstances change. If you'd like to:\n✅ Pay now - use reference: ${variables.reference}\n❌ Cancel - ${variables.cancelUrl}\n\nView details: ${variables.pledgeUrl}\n\nThank you for considering us.`,
|
||||
},
|
||||
}
|
||||
|
||||
return templates[templateKey] || templates.instructions
|
||||
}
|
||||
127
pledge-now-pay-later/src/lib/stripe.ts
Normal file
127
pledge-now-pay-later/src/lib/stripe.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import Stripe from "stripe"
|
||||
|
||||
let stripeClient: Stripe | null = null
|
||||
|
||||
export function getStripe(): Stripe | null {
|
||||
if (stripeClient) return stripeClient
|
||||
|
||||
const key = process.env.STRIPE_SECRET_KEY
|
||||
if (!key || key === "sk_test_REPLACE_ME") return null
|
||||
|
||||
stripeClient = new Stripe(key, {
|
||||
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
|
||||
typescript: true,
|
||||
})
|
||||
return stripeClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout Session for a card payment.
|
||||
* Returns the checkout URL to redirect the donor to.
|
||||
*/
|
||||
export async function createCheckoutSession(opts: {
|
||||
amountPence: number
|
||||
currency: string
|
||||
pledgeId: string
|
||||
reference: string
|
||||
eventName: string
|
||||
organizationName: string
|
||||
donorEmail?: string
|
||||
successUrl: string
|
||||
cancelUrl: string
|
||||
}): Promise<{ sessionId: string; checkoutUrl: string } | null> {
|
||||
const stripe = getStripe()
|
||||
if (!stripe) return null
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: opts.currency.toLowerCase(),
|
||||
unit_amount: opts.amountPence,
|
||||
product_data: {
|
||||
name: `Donation — ${opts.eventName}`,
|
||||
description: `Pledge ref: ${opts.reference} to ${opts.organizationName}`,
|
||||
},
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
customer_email: opts.donorEmail || undefined,
|
||||
metadata: {
|
||||
pledge_id: opts.pledgeId,
|
||||
reference: opts.reference,
|
||||
},
|
||||
success_url: opts.successUrl,
|
||||
cancel_url: opts.cancelUrl,
|
||||
})
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
checkoutUrl: session.url!,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stripe checkout session error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Payment Intent for embedded payment (Stripe Elements).
|
||||
* Returns client secret for frontend confirmation.
|
||||
*/
|
||||
export async function createPaymentIntent(opts: {
|
||||
amountPence: number
|
||||
currency: string
|
||||
pledgeId: string
|
||||
reference: string
|
||||
donorEmail?: string
|
||||
}): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
|
||||
const stripe = getStripe()
|
||||
if (!stripe) return null
|
||||
|
||||
try {
|
||||
const pi = await stripe.paymentIntents.create({
|
||||
amount: opts.amountPence,
|
||||
currency: opts.currency.toLowerCase(),
|
||||
metadata: {
|
||||
pledge_id: opts.pledgeId,
|
||||
reference: opts.reference,
|
||||
},
|
||||
receipt_email: opts.donorEmail || undefined,
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
clientSecret: pi.client_secret!,
|
||||
paymentIntentId: pi.id,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stripe payment intent error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Stripe webhook signature.
|
||||
*/
|
||||
export function constructWebhookEvent(
|
||||
body: string | Buffer,
|
||||
signature: string
|
||||
): Stripe.Event | null {
|
||||
const stripe = getStripe()
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET
|
||||
if (!stripe || !secret || secret === "whsec_REPLACE_ME") return null
|
||||
|
||||
try {
|
||||
return stripe.webhooks.constructEvent(body, signature, secret)
|
||||
} catch (error) {
|
||||
console.error("Stripe webhook signature verification failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
16
pledge-now-pay-later/src/lib/utils.ts
Normal file
16
pledge-now-pay-later/src/lib/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatPence(pence: number): string {
|
||||
return `£${(pence / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
export function formatPenceShort(pence: number): string {
|
||||
const pounds = pence / 100
|
||||
if (pounds >= 1000) return `£${(pounds / 1000).toFixed(pounds % 1000 === 0 ? 0 : 1)}k`
|
||||
return `£${pounds.toFixed(0)}`
|
||||
}
|
||||
48
pledge-now-pay-later/src/lib/validators.ts
Normal file
48
pledge-now-pay-later/src/lib/validators.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createEventSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
eventDate: z.string().datetime().optional(),
|
||||
location: z.string().max(500).optional(),
|
||||
goalAmount: z.number().int().positive().optional(), // pence
|
||||
currency: z.string().default('GBP'),
|
||||
})
|
||||
|
||||
export const createQrSourceSchema = z.object({
|
||||
label: z.string().min(1).max(100),
|
||||
volunteerName: z.string().max(100).optional(),
|
||||
tableName: z.string().max(100).optional(),
|
||||
})
|
||||
|
||||
export const createPledgeSchema = z.object({
|
||||
amountPence: z.number().int().min(100).max(100000000), // £1 to £1M
|
||||
rail: z.enum(['bank', 'gocardless', 'card', 'fpx']),
|
||||
donorName: z.string().max(200).optional().default(''),
|
||||
donorEmail: z.string().max(200).optional().default(''),
|
||||
donorPhone: z.string().max(20).optional().default(''),
|
||||
giftAid: z.boolean().default(false),
|
||||
eventId: z.string(),
|
||||
qrSourceId: z.string().nullable().optional(),
|
||||
}).transform((data) => ({
|
||||
...data,
|
||||
donorEmail: data.donorEmail && data.donorEmail.includes('@') ? data.donorEmail : undefined,
|
||||
donorPhone: data.donorPhone && data.donorPhone.length >= 10 ? data.donorPhone : undefined,
|
||||
donorName: data.donorName || undefined,
|
||||
qrSourceId: data.qrSourceId || undefined,
|
||||
}))
|
||||
|
||||
export const importBankStatementSchema = z.object({
|
||||
columnMapping: z.object({
|
||||
dateCol: z.string(),
|
||||
descriptionCol: z.string(),
|
||||
amountCol: z.string().optional(),
|
||||
creditCol: z.string().optional(),
|
||||
referenceCol: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const updatePledgeStatusSchema = z.object({
|
||||
status: z.enum(['new', 'initiated', 'paid', 'overdue', 'cancelled']),
|
||||
notes: z.string().max(1000).optional(),
|
||||
})
|
||||
Reference in New Issue
Block a user