feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later

This commit is contained in:
Azreen Jamal
2026-03-01 23:41:24 +08:00
parent ae242436c9
commit f832b913d5
99 changed files with 20949 additions and 74 deletions

View 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
}
}

View 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,
}
}

View 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,
}
}

View 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
}

View 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
}

View 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

View 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',
})
}

View 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)
}

View 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
}

View 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
}
}

View 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)}`
}

View 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(),
})