Files
calvana/pledge-now-pay-later/docs/PRODUCT_SPEC.md

1365 lines
56 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Pledge Now, Pay Later — Product Specification
> **Version:** 1.0
> **Last updated:** 2026-02-28
> **Status:** Implementation in progress
---
## Table of Contents
1. [Overview](#1-overview)
2. [User Personas](#2-user-personas)
3. [Core Flows](#3-core-flows)
4. [Data Model](#4-data-model)
5. [API Contracts](#5-api-contracts)
6. [Payment Reference Design](#6-payment-reference-design)
7. [Analytics Events](#7-analytics-events)
8. [Lead Qualification](#8-lead-qualification)
9. [Integration Points](#9-integration-points)
10. [Non-Functional Requirements](#10-non-functional-requirements)
---
## 1. Overview
**Pledge Now, Pay Later** (PNPL) is a free-forever micro-SaaS that helps UK charities capture donation intent at live events — dinners, auctions, fun runs, Friday prayers — and follow through to actual payment.
### The Problem
At charity events, donors say "I'll donate later" and never do. Event teams lose 4060% of pledged income because there's no system to:
- Capture pledges quickly on mobile
- Attribute donations to tables, volunteers, or campaigns
- Follow up automatically
- Reconcile bank payments against pledges
### The Solution
PNPL converts verbal intent into tracked, attributed digital pledges in **15 seconds**, then drives payment collection through the donor's preferred method:
| Payment Rail | Fees | Collection | Best For |
|-----------------|-------|----------------|--------------------|
| Bank transfer | £0 | Manual + match | Most donors (UK) |
| Direct Debit | ~1% | Automatic | Recurring/high-value|
| Card | ~1.4% | Instant | Convenience |
### Business Model
```
┌─────────────────────────────────────────────────────┐
│ FREE FOREVER │
│ Event setup · QR codes · Pledge flow · Reminders │
│ Bank reconciliation · CRM export · Dashboard │
└──────────────────────┬──────────────────────────────┘
Qualified Lead Signal
(events created + pledges collected)
┌─────────────────────────────────────────────────────┐
│ FRACTIONAL HEAD OF TECHNOLOGY │
│ Omair's consultancy — pre-filled application with │
│ event performance metrics from PNPL usage │
└─────────────────────────────────────────────────────┘
```
The product is genuinely free. No tiered pricing, no feature gates. Revenue comes from qualifying charity organisations that need broader technology leadership — PNPL usage data pre-fills the consultancy application with proof of operational maturity.
---
## 2. User Personas
### 2.1 Event Lead / Fundraising Manager
| Attribute | Detail |
|--------------|---------------------------------------------------------|
| **Role** | Creates events, manages QR codes, monitors pledge pipeline |
| **Goal** | Maximise pledge-to-payment conversion |
| **Pain** | Spreadsheets, lost pledges, no attribution |
| **Key screens** | Dashboard, Event setup, Reconciliation, CRM export |
| **Tech comfort** | Moderate — can upload CSVs, follow guided workflows |
### 2.2 Donor
| Attribute | Detail |
|--------------|---------------------------------------------------------|
| **Role** | Scans QR code at event, makes a pledge, pays later |
| **Goal** | Pledge quickly without friction, pay when convenient |
| **Pain** | Long forms, app installs, payment pressure at events |
| **Key screens** | 3-step pledge flow (Amount → Method → Identity) |
| **Tech comfort** | Any — mobile web only, no account required |
### 2.3 Finance / Admin
| Attribute | Detail |
|--------------|---------------------------------------------------------|
| **Role** | Reconciles bank statements, exports data for CRM/Gift Aid |
| **Goal** | Match bank payments to pledges, produce accurate records |
| **Pain** | Manual bank statement line-matching, Gift Aid declarations |
| **Key screens** | Reconciliation tool, CRM export, Pledge list |
| **Tech comfort** | Comfortable with CSV imports/exports |
### 2.4 Volunteer
| Attribute | Detail |
|--------------|---------------------------------------------------------|
| **Role** | Assigned a personal QR code, encourages table donations |
| **Goal** | Show QR, let donors pledge painlessly |
| **Pain** | Collecting cash, keeping track of who pledged what |
| **Key screens** | None — shows printed QR or phone screen to donors |
| **Tech comfort** | Low — just needs to hold up a QR code |
---
## 3. Core Flows
### 3.1 Event Setup Flow
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. Create │───▶│ 2. Add QR │───▶│ 3. Download │───▶│ 4. Share │
│ Event │ │ Sources │ │ QR sheets │ │ Event Link │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
```
**Step 1 — Create Event**
- Name (required), date, location, fundraising goal
- Auto-generates URL slug: `{name}-{timestamp_base36}`
- Status starts as `active` (also: `draft`, `closed`, `archived`)
**Step 2 — Add QR Sources**
- Each QR source represents a table, volunteer, or campaign channel
- Label examples: `"Table 5"`, `"Volunteer: Ahmed"`, `"Instagram Story"`
- Each gets a unique 8-character code (human-safe alphabet)
- QR encodes: `{BASE_URL}/p/{code}`
**Step 3 — Download QR Sheets**
- Individual PNG download per QR source (800×800px)
- QR colour: org primary colour (default `#1e40af`)
- Error correction: Level M (15% damage tolerance)
**Step 4 — Share Event Link**
- Direct URL for digital channels (no QR needed)
- Attribution still tracked via `qrSourceId`
---
### 3.2 Donor Pledge Flow (3 screens, 15 seconds)
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐
│ Screen 1 │────▶│ Screen 2 │────▶│ Screen 3 │────▶│ Confirmation │
│ Amount │ │ Method │ │ Identity │ │ + Instructions │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────────┘
6 presets + Bank (★) Email OR phone Bank → ref + copy
custom entry Direct Debit Name (optional) DD → mandate link
Card Gift Aid checkbox Card → checkout
```
**Screen 1 — Amount**
- Six preset buttons (e.g., £10, £25, £50, £100, £250, £500)
- Custom amount input (min £1, max £1,000,000)
- Analytics: `amount_selected` fires on tap
**Screen 2 — Payment Method**
- **Bank Transfer** (recommended) — zero fees, donor sends manually
- **Direct Debit** — GoCardless mandate, auto-collected
- **Card** — Stripe checkout (future)
- Bank is visually highlighted as the recommended option
- Analytics: `rail_selected` fires on tap
**Screen 3 — Identity**
- Email **or** phone required (at least one)
- Name optional
- Gift Aid checkbox with eligibility explainer
- Analytics: `identity_submitted` fires on submit
**Confirmation — Payment Instructions**
- Varies by rail:
| Rail | Confirmation Content |
|------|---------------------|
| **Bank** | Sort code, account number, account name, **unique reference** with one-tap copy button, "I've paid" button |
| **Direct Debit** | GoCardless mandate link (redirects to authorisation) |
| **Card** | Stripe payment link (future) |
- Analytics: `pledge_completed` fires on render
---
### 3.3 Payment Collection Flow
#### Bank Transfer (Primary)
```
Donor Charity's Bank PNPL
│ │ │
│ Transfer with ref │ │
│ PNPL-7K4P-50 │ │
│─────────────────────────────▶│ │
│ │ │
│ │ Export CSV │
│ │──────────────────────▶│
│ │ │
│ │ │ Auto-match by
│ │ │ reference code
│ │ │
│ │ Pledge marked "paid" │
│ │◀──────────────────────│
│ │ │
│ Reminders stop │ │
│◀─────────────────────────────────────────────────────│
```
1. Donor transfers money using the unique reference
2. Charity exports bank statement as CSV
3. Uploads CSV to PNPL reconciliation tool
4. PNPL auto-matches references → marks pledges as paid
5. Remaining reminders are skipped
#### GoCardless Direct Debit
```
Donor GoCardless PNPL
│ │ │
│ Authorise mandate │ │
│─────────────────────────────▶│ │
│ │ │
│ │ Payment collected │
│ │──────────────────────▶│
│ │ │
│ │ Webhook: confirmed │
│ │──────────────────────▶│
│ │ │
│ │ Pledge marked "paid" │
│ │◀──────────────────────│
```
#### Card (Future — Stripe)
```
Donor ──▶ Stripe Checkout ──▶ Webhook confirms ──▶ Pledge marked "paid"
```
---
### 3.4 Reminder Sequence
| Step | Timing | Template Key | Subject | Description |
|------|--------|-------------------|----------------------------------|-------------------------------------------------------|
| 0 | T+0 | `instructions` | Payment details for your £X pledge | Bank details, reference, copy button |
| 1 | T+2d | `gentle_nudge` | Quick reminder about your pledge | Friendly — "if you've already paid, thank you!" |
| 2 | T+7d | `urgency_impact` | Your pledge is making a difference | Impact story + urgency framing |
| 3 | T+14d | `final_reminder` | Final reminder about your pledge | Clear options: pay now or cancel |
**Stop Rules:**
- Auto-stop on payment match (bank reconciliation or webhook)
- Auto-stop on manual "mark as paid" by staff
- Auto-stop on pledge cancellation
- Donor can self-cancel via link in every reminder
**Channel:** Email (default). SMS and WhatsApp channels are defined in the schema but not yet implemented.
**Delivery:** Reminders are exposed via a polling webhook endpoint (`GET /api/webhooks`). External automation tools (Zapier, Make, n8n) poll for due reminders and handle actual email/SMS delivery.
---
### 3.5 Reconciliation Flow
```
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ 1. Export │───▶│ 2. Upload │───▶│ 3. Map │───▶│ 4. Review │───▶│ 5. Confirm │
│ Bank CSV │ │ to PNPL │ │ Columns │ │ Matches │ │ & Apply │
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
```
**Step 1 — Export Bank Statement**
- Download CSV from online banking (any UK bank format)
**Step 2 — Upload to PNPL**
- `POST /api/imports/bank-statement` with `multipart/form-data`
- Accepts `.csv` files, parsed with PapaParse
**Step 3 — Configure Column Mapping**
- Map bank-specific column names:
- `dateCol` → transaction date
- `descriptionCol` → transaction description
- `amountCol` or `creditCol` → payment amount
- `referenceCol` → payment reference (optional — also searched in description)
- Only credit rows (positive amounts) are processed
**Step 4 — Auto-Match**
Three matching strategies applied in order:
| Priority | Strategy | Confidence | Description |
|----------|--------------------|-----------|--------------------------------------------|
| 1 | Exact reference | `exact` | Normalised ref matches `reference` column |
| 2 | Description search | `exact` | Normalised ref found within `description` |
| 3 | Partial code match | `partial` | 4-char code portion found in `description` |
Normalisation: strip spaces, strip dashes, uppercase. e.g., `pnpl 7k4p 50``PNPL7K4P50`
**Step 5 — Confirm & Apply**
- Exact matches are auto-confirmed:
- Pledge status → `paid`, `paidAt` set
- Payment record created (`matchedBy: "auto"`)
- Pending reminders → `skipped`
- Partial matches flagged for manual review
- Import record saved with stats (total rows, credits, matches, unmatched)
---
### 3.6 CRM Export Flow
**Endpoint:** `GET /api/exports/crm-pack`
Downloads a CSV with full pledge attribution. Filterable by `?eventId=`.
**Export Fields:**
| Column | Description | Example |
|--------------------|------------------------------------------|-----------------------|
| `pledge_reference` | Unique bank-safe reference | `PNPL-7K4P-50` |
| `donor_name` | Donor's name (if provided) | `Sarah Ahmed` |
| `donor_email` | Donor's email | `sarah@example.com` |
| `donor_phone` | Donor's phone | `07700900123` |
| `amount_gbp` | Pledge amount in pounds | `50.00` |
| `payment_method` | Payment rail | `bank` |
| `status` | Current pledge status | `paid` |
| `event_name` | Source event | `Annual Gala 2026` |
| `source_label` | QR source label | `Table 5` |
| `volunteer_name` | Volunteer assigned to QR source | `Ahmed` |
| `table_name` | Table assigned to QR source | `VIP Table` |
| `gift_aid` | Gift Aid eligibility | `Yes` |
| `pledged_at` | Pledge creation timestamp (ISO 8601) | `2026-03-15T19:32:00Z`|
| `paid_at` | Payment confirmation timestamp | `2026-03-17T10:15:00Z`|
| `days_to_collect` | Days between pledge and payment | `2` |
---
## 4. Data Model
Ten tables in PostgreSQL 16, managed via Prisma ORM.
### Entity Relationship Diagram
```
┌──────────────────┐
│ Organization │
│──────────────────│
│ id │
│ name │ ┌──────────────────┐
│ slug (unique) │───────▶│ User │
│ country │ 1:N │──────────────────│
│ timezone │ │ id │
│ bankName │ │ email (unique) │
│ bankSortCode │ │ name │
│ bankAccountNo │ │ hashedPassword │
│ bankAccountName │ │ role │
│ refPrefix │ │ organizationId │
│ logo │ └──────────────────┘
│ primaryColor │
│ gcAccessToken │
│ gcEnvironment │
└────────┬─────────┘
│ 1:N
┌──────────────────┐ 1:N ┌──────────────────┐
│ Event │───────▶│ QrSource │
│──────────────────│ │──────────────────│
│ id │ │ id │
│ name │ │ label │
│ slug │ │ code (unique) │
│ description │ │ volunteerName │
│ eventDate │ │ tableName │
│ location │ │ eventId │
│ goalAmount │ │ scanCount │
│ currency │ └────────┬─────────┘
│ status │ │
│ organizationId │ │ 0:N
└────────┬─────────┘ │
│ 1:N │
▼ │
┌──────────────────┐◀────────────────┘
│ Pledge │
│──────────────────│
│ id │ 1:1 ┌────────────────────────┐
│ reference │───────▶│ PaymentInstruction │
│ amountPence │ │────────────────────────│
│ currency │ │ id │
│ rail │ │ pledgeId (unique) │
│ status │ │ bankReference │
│ donorName │ │ bankDetails (JSON) │
│ donorEmail │ │ gcMandateId │
│ donorPhone │ │ gcMandateUrl │
│ giftAid │ │ sentAt │
│ iPaidClickedAt │ └────────────────────────┘
│ eventId │
│ qrSourceId │ 1:N ┌──────────────────┐
│ organizationId │───────▶│ Payment │
│ paidAt │ │──────────────────│
│ cancelledAt │ │ id │
└────────┬─────────┘ │ pledgeId │
│ │ provider │
│ │ providerRef │
│ 1:N │ amountPence │
▼ │ status │
┌──────────────────┐ │ matchedBy │
│ Reminder │ │ receivedAt │
│──────────────────│ │ importId │
│ id │ └──────────────────┘
│ pledgeId │
│ step │
│ channel │
│ scheduledAt │
│ sentAt │
│ status │
│ payload (JSON) │
└──────────────────┘
┌──────────────────┐ 1:N ┌──────────────────┐
│ Import │───────▶│ Payment │
│──────────────────│ │ (via importId) │
│ id │ └──────────────────┘
│ organizationId │
│ kind │
│ fileName │
│ rowCount │ ┌──────────────────┐
│ matchedCount │ │ AnalyticsEvent │
│ unmatchedCount │ │──────────────────│
│ mappingConfig │ │ id │
│ stats (JSON) │ │ eventType │
│ status │ │ pledgeId │
└──────────────────┘ │ eventId │
│ qrSourceId │
│ metadata (JSON) │
│ createdAt │
└──────────────────┘
```
### Table Details
#### 4.1 `Organization`
The top-level tenant. All data is scoped to an organization.
| Field | Type | Description |
|-----------------|----------|--------------------------------------------------|
| `id` | `cuid` | Primary key |
| `name` | `string` | Organisation display name |
| `slug` | `string` | URL-safe unique identifier |
| `country` | `string` | Default `"UK"` |
| `timezone` | `string` | Default `"Europe/London"` |
| `bankName` | `string?`| Bank name for payment instructions |
| `bankSortCode` | `string?`| 6-digit sort code |
| `bankAccountNo` | `string?`| 8-digit account number |
| `bankAccountName`| `string?`| Name on the bank account |
| `refPrefix` | `string` | Reference prefix, default `"PNPL"`, max 4 chars |
| `logo` | `string?`| Logo URL |
| `primaryColor` | `string` | Brand colour, default `"#1e40af"` |
| `gcAccessToken` | `string?`| GoCardless API token |
| `gcEnvironment` | `string` | `"sandbox"` or `"live"` |
#### 4.2 `User`
Staff/admin accounts for the dashboard.
| Field | Type | Description |
|-----------------|----------|--------------------------------------------------|
| `id` | `cuid` | Primary key |
| `email` | `string` | Unique email address |
| `name` | `string?`| Display name |
| `hashedPassword`| `string?`| bcrypt hash (nullable for SSO) |
| `role` | `string` | `super_admin`, `org_admin`, `staff`, `volunteer` |
| `organizationId`| `string` | FK → Organization |
#### 4.3 `Event`
A fundraising event that donors pledge at.
| Field | Type | Description |
|-----------------|------------|------------------------------------------------|
| `id` | `cuid` | Primary key |
| `name` | `string` | Event name (1200 chars) |
| `slug` | `string` | URL-safe slug (auto-generated) |
| `description` | `string?` | Event description (max 2000 chars) |
| `eventDate` | `DateTime?`| When the event takes place |
| `location` | `string?` | Venue (max 500 chars) |
| `goalAmount` | `int?` | Fundraising target **in pence** |
| `currency` | `string` | Default `"GBP"` |
| `status` | `string` | `draft`, `active`, `closed`, `archived` |
| `organizationId`| `string` | FK → Organization |
Unique constraint: `(organizationId, slug)`
#### 4.4 `QrSource`
An attribution source — a specific QR code tied to a table, volunteer, or channel.
| Field | Type | Description |
|----------------|----------|---------------------------------------------------|
| `id` | `cuid` | Primary key |
| `label` | `string` | Human-readable label (1100 chars) |
| `code` | `string` | Unique 8-char token for URLs |
| `volunteerName`| `string?`| Volunteer's name (for attribution) |
| `tableName` | `string?`| Table identifier (for attribution) |
| `eventId` | `string` | FK → Event |
| `scanCount` | `int` | Number of times QR was scanned (auto-incremented) |
#### 4.5 `Pledge`
The core entity — a donor's promise to pay.
| Field | Type | Description |
|-----------------|------------|------------------------------------------------|
| `id` | `cuid` | Primary key |
| `reference` | `string` | Unique bank-safe reference (see §6) |
| `amountPence` | `int` | Pledge amount in pence (min 100 = £1) |
| `currency` | `string` | Default `"GBP"` |
| `rail` | `string` | `bank`, `gocardless`, `card` |
| `status` | `string` | `new`, `initiated`, `paid`, `overdue`, `cancelled` |
| `donorName` | `string?` | Donor's name |
| `donorEmail` | `string?` | Donor's email |
| `donorPhone` | `string?` | Donor's phone |
| `giftAid` | `boolean` | Gift Aid declaration (default `false`) |
| `iPaidClickedAt`| `DateTime?`| When donor clicked "I've paid" |
| `notes` | `string?` | Staff notes |
| `eventId` | `string` | FK → Event |
| `qrSourceId` | `string?` | FK → QrSource (null if direct link) |
| `organizationId`| `string` | FK → Organization |
| `paidAt` | `DateTime?`| When payment was confirmed |
| `cancelledAt` | `DateTime?`| When pledge was cancelled |
Validation: `donorEmail` or `donorPhone` must be present (enforced by Zod schema).
**Status State Machine:**
```
┌──────────────────────┐
▼ │
┌───────┐ "I've paid" ┌──────────┐│ bank match /
│ new │─────────────────▶│initiated ││ webhook
│ │ │ ││
└───┬───┘ └────┬─────┘│
│ │ │
│ bank match / webhook │ │
│ │ │
▼ ▼ │
┌───────┐ ┌──────────┐│
│ paid │◀─────────────────│ paid ││
└───────┘ └──────────┘│
▲ │
│ ┌──────────┐ │
│ │ overdue │─────────────┘
│ └────┬─────┘
│ │
│ ▼
│ ┌──────────┐
└─────────│cancelled │
└──────────┘
```
#### 4.6 `PaymentInstruction`
Bank transfer details stored per pledge. Created automatically for `rail: "bank"`.
| Field | Type | Description |
|----------------|----------|---------------------------------------------------|
| `id` | `cuid` | Primary key |
| `pledgeId` | `string` | FK → Pledge (unique — 1:1) |
| `bankReference`| `string` | The reference donor must use |
| `bankDetails` | `JSON` | `{sortCode, accountNo, accountName, bankName}` |
| `gcMandateId` | `string?`| GoCardless mandate ID |
| `gcMandateUrl` | `string?`| GoCardless mandate authorisation URL |
| `sentAt` | `DateTime?`| When instructions were first sent |
#### 4.7 `Payment`
A confirmed money movement against a pledge.
| Field | Type | Description |
|--------------|------------|--------------------------------------------------|
| `id` | `cuid` | Primary key |
| `pledgeId` | `string` | FK → Pledge |
| `provider` | `string` | `bank`, `gocardless`, `stripe` |
| `providerRef`| `string?` | External payment/transaction ID |
| `amountPence`| `int` | Amount received in pence |
| `status` | `string` | `pending`, `confirmed`, `failed` |
| `matchedBy` | `string?` | `auto` (reconciliation) or `manual` (staff) |
| `receivedAt` | `DateTime?`| When money was received |
| `importId` | `string?` | FK → Import (if matched via bank statement) |
#### 4.8 `Reminder`
Scheduled follow-up messages for a pledge.
| Field | Type | Description |
|--------------|------------|--------------------------------------------------|
| `id` | `cuid` | Primary key |
| `pledgeId` | `string` | FK → Pledge |
| `step` | `int` | Sequence number: `0`, `1`, `2`, `3` |
| `channel` | `string` | `email`, `sms`, `whatsapp` |
| `scheduledAt`| `DateTime` | When to send |
| `sentAt` | `DateTime?`| When actually sent |
| `status` | `string` | `pending`, `sent`, `skipped`, `failed` |
| `payload` | `JSON?` | Template key + subject line |
#### 4.9 `Import`
Record of a bank statement upload and its results.
| Field | Type | Description |
|----------------|------------|------------------------------------------------|
| `id` | `cuid` | Primary key |
| `organizationId`| `string` | FK → Organization |
| `kind` | `string` | `bank_statement`, `gocardless_export`, `crm_export` |
| `fileName` | `string?` | Original upload filename |
| `rowCount` | `int` | Total rows in CSV |
| `matchedCount` | `int` | Rows matched to pledges |
| `unmatchedCount`| `int` | Rows that didn't match |
| `mappingConfig`| `JSON?` | Column mapping used |
| `stats` | `JSON?` | Detailed match statistics |
| `status` | `string` | `pending`, `processing`, `completed`, `failed` |
#### 4.10 `AnalyticsEvent`
Append-only event log for funnel tracking.
| Field | Type | Description |
|-------------|------------|---------------------------------------------------|
| `id` | `cuid` | Primary key |
| `eventType` | `string` | Event name (see §7) |
| `pledgeId` | `string?` | FK → Pledge (if applicable) |
| `eventId` | `string?` | FK → Event (if applicable) |
| `qrSourceId`| `string?` | FK → QrSource (if applicable) |
| `metadata` | `JSON?` | Arbitrary key-value data |
| `createdAt` | `DateTime` | Timestamp |
---
## 5. API Contracts
All endpoints are Next.js API routes. Authentication is via `x-org-id` header (NextAuth.js integration ready but not yet enforced).
### 5.1 `GET /api/qr/{token}` — Resolve QR Code
Resolves a QR source token to event info. Increments `scanCount`.
**Path params:** `token` — 8-char QR source code
**Response `200`:**
```json
{
"id": "clx...",
"name": "Annual Gala 2026",
"organizationName": "Hope Foundation",
"qrSourceId": "clx...",
"qrSourceLabel": "Table 5"
}
```
**Response `404`:**
```json
{ "error": "This pledge link is no longer active" }
```
---
### 5.2 `POST /api/pledges` — Create Pledge
Creates a pledge with payment instruction and reminder schedule in a single transaction.
**Request body:**
```json
{
"amountPence": 5000,
"rail": "bank",
"donorName": "Sarah Ahmed",
"donorEmail": "sarah@example.com",
"donorPhone": "07700900123",
"giftAid": true,
"eventId": "clx...",
"qrSourceId": "clx..."
}
```
**Validation (Zod):**
- `amountPence`: int, 100100,000,000 (£1£1M)
- `rail`: `"bank" | "gocardless" | "card"`
- `donorEmail` or `donorPhone`: at least one required
- `eventId`: required
**Response `201` (bank rail):**
```json
{
"id": "clx...",
"reference": "PNPL-7K4P-50",
"bankDetails": {
"bankName": "Barclays",
"sortCode": "20-00-00",
"accountNo": "12345678",
"accountName": "Hope Foundation"
}
}
```
**Response `201` (other rails):**
```json
{
"id": "clx...",
"reference": "PNPL-7K4P-50"
}
```
**Side effects:**
- Generates collision-resistant reference (up to 10 retries)
- Creates `PaymentInstruction` (bank rail)
- Creates 4 `Reminder` records (T+0, T+2d, T+7d, T+14d)
- Tracks `pledge_completed` analytics event
---
### 5.3 `PATCH /api/pledges/{id}` — Update Pledge Status
**Request body:**
```json
{
"status": "paid",
"notes": "Confirmed via bank statement"
}
```
**Validation:**
- `status`: `"new" | "initiated" | "paid" | "overdue" | "cancelled"`
- `notes`: optional, max 1000 chars
**Response `200`:** Full pledge object.
**Side effects:**
- Sets `paidAt` when status → `paid`
- Sets `cancelledAt` when status → `cancelled`
- Skips all pending reminders when status → `paid` or `cancelled`
---
### 5.4 `POST /api/pledges/{id}/mark-initiated` — Donor "I've Paid"
Called when donor taps the "I've paid" button on the confirmation screen.
**Request body:** None
**Response `200`:**
```json
{ "ok": true }
```
**Side effects:**
- Sets `status``"initiated"`, `iPaidClickedAt` → now
---
### 5.5 `GET /api/events` — List Events
Returns all events for the organisation with pledge aggregates.
**Headers:** `x-org-id`
**Response `200`:**
```json
[
{
"id": "clx...",
"name": "Annual Gala 2026",
"slug": "annual-gala-2026-m3k9a",
"eventDate": "2026-03-15T18:00:00.000Z",
"location": "Grand Hall, London",
"goalAmount": 5000000,
"status": "active",
"pledgeCount": 47,
"qrSourceCount": 12,
"totalPledged": 3750000,
"totalCollected": 2100000,
"createdAt": "2026-02-01T10:00:00.000Z"
}
]
```
Note: `goalAmount`, `totalPledged`, and `totalCollected` are in **pence**.
---
### 5.6 `POST /api/events` — Create Event
**Headers:** `x-org-id`
**Request body:**
```json
{
"name": "Annual Gala 2026",
"description": "Our biggest fundraiser of the year",
"eventDate": "2026-03-15T18:00:00.000Z",
"location": "Grand Hall, London",
"goalAmount": 5000000,
"currency": "GBP"
}
```
**Validation:**
- `name`: required, 1200 chars
- `description`: optional, max 2000 chars
- `eventDate`: optional, ISO 8601
- `location`: optional, max 500 chars
- `goalAmount`: optional, positive int (pence)
**Response `201`:** Full event object.
**Slug generation:** `{name_slugified}-{timestamp_base36}`
---
### 5.7 `GET /api/events/{id}/qr` — List QR Sources
Returns QR sources for an event with pledge stats.
**Response `200`:**
```json
[
{
"id": "clx...",
"label": "Table 5",
"code": "abc23def",
"volunteerName": "Ahmed",
"tableName": "VIP Table",
"scanCount": 23,
"pledgeCount": 8,
"totalPledged": 450000,
"createdAt": "2026-02-10T14:00:00.000Z"
}
]
```
---
### 5.8 `POST /api/events/{id}/qr` — Create QR Source
**Request body:**
```json
{
"label": "Table 5",
"volunteerName": "Ahmed",
"tableName": "VIP Table"
}
```
**Validation:**
- `label`: required, 1100 chars
- `volunteerName`: optional, max 100 chars
- `tableName`: optional, max 100 chars
**Response `201`:** Full QrSource object including generated `code`.
---
### 5.9 `GET /api/events/{id}/qr/{qrId}/download` — Download QR PNG
Returns an 800×800px PNG image of the QR code.
**Query params:** `code` — QR source code (optional, falls back to `qrId`)
**Response:** `image/png` binary with `Content-Disposition: attachment`
---
### 5.10 `GET /api/dashboard` — Dashboard Stats
Returns full pipeline data with funnel analytics.
**Headers:** `x-org-id`
**Query params:** `eventId` (optional — filter to single event)
**Response `200`:**
```json
{
"summary": {
"totalPledges": 47,
"totalPledgedPence": 3750000,
"totalCollectedPence": 2100000,
"collectionRate": 56,
"overdueRate": 12
},
"byStatus": {
"new": 10,
"initiated": 5,
"paid": 25,
"overdue": 4,
"cancelled": 3
},
"byRail": {
"bank": 38,
"gocardless": 7,
"card": 2
},
"topSources": [
{ "label": "Table 5", "count": 8, "amount": 450000 }
],
"funnel": {
"pledge_start": 120,
"amount_selected": 95,
"rail_selected": 80,
"identity_submitted": 55,
"pledge_completed": 47
},
"pledges": [
{
"id": "clx...",
"reference": "PNPL-7K4P-50",
"amountPence": 5000,
"status": "paid",
"rail": "bank",
"donorName": "Sarah Ahmed",
"donorEmail": "sarah@example.com",
"donorPhone": null,
"eventName": "Annual Gala 2026",
"source": "Table 5",
"volunteerName": "Ahmed",
"giftAid": true,
"createdAt": "2026-03-15T19:32:00.000Z",
"paidAt": "2026-03-17T10:15:00.000Z",
"nextReminder": null,
"lastTouch": "2026-03-15T19:32:00.000Z"
}
]
}
```
---
### 5.11 `POST /api/imports/bank-statement` — Upload & Match Bank CSV
**Content-Type:** `multipart/form-data`
**Form fields:**
- `file` — CSV file
- `mapping` — JSON string with column mapping
**Mapping schema:**
```json
{
"dateCol": "Date",
"descriptionCol": "Description",
"amountCol": "Amount",
"creditCol": "Credit",
"referenceCol": "Reference"
}
```
**Response `200`:**
```json
{
"importId": "clx...",
"summary": {
"totalRows": 150,
"credits": 45,
"exactMatches": 12,
"partialMatches": 3,
"unmatched": 30,
"autoConfirmed": 12
},
"matches": [
{
"bankRow": {
"date": "2026-03-17",
"description": "PNPL-7K4P-50 S AHMED",
"amount": 50.00,
"reference": "PNPL-7K4P-50"
},
"pledgeId": "clx...",
"pledgeReference": "PNPL-7K4P-50",
"confidence": "exact",
"matchedAmount": 50.00,
"autoConfirmed": true
}
]
}
```
**Side effects (exact matches):**
- Pledge status → `paid`, `paidAt` set
- Payment record created (`provider: "bank"`, `matchedBy: "auto"`)
- Pending reminders → `skipped`
---
### 5.12 `GET /api/exports/crm-pack` — Download CRM CSV
**Headers:** `x-org-id`
**Query params:** `eventId` (optional)
**Response:** `text/csv` with `Content-Disposition: attachment; filename="crm-export-YYYY-MM-DD.csv"`
See §3.6 for field definitions.
---
### 5.13 `GET /api/webhooks` — Poll Pending Reminders
Polling endpoint for external automation (Zapier, Make, n8n).
**Query params:**
- `since` — ISO 8601 timestamp (only reminders scheduled after this time)
- `limit` — max results (default 50)
**Response `200`:**
```json
{
"events": [
{
"event": "reminder.due",
"timestamp": "2026-03-17T10:00:00.000Z",
"data": {
"reminderId": "clx...",
"pledgeId": "clx...",
"step": 1,
"channel": "email",
"scheduledAt": "2026-03-17T19:32:00.000Z",
"donor": {
"name": "Sarah Ahmed",
"email": "sarah@example.com",
"phone": null
},
"pledge": {
"reference": "PNPL-7K4P-50",
"amount": 5000,
"rail": "bank"
},
"event": "Annual Gala 2026",
"organization": "Hope Foundation",
"payload": {
"templateKey": "gentle_nudge",
"subject": "Quick reminder about your pledge"
}
}
}
],
"count": 1
}
```
---
### 5.14 `POST /api/analytics` — Track Event
Fire-and-forget analytics tracking. Never returns errors to avoid breaking donor flow.
**Request body:**
```json
{
"eventType": "amount_selected",
"pledgeId": null,
"eventId": "clx...",
"qrSourceId": "clx...",
"metadata": { "amount": 5000, "preset": true }
}
```
**Response `200`:**
```json
{ "ok": true }
```
---
## 6. Payment Reference Design
The payment reference is the critical link between a pledge in PNPL and a transaction on a bank statement. It must be simultaneously human-readable, bank-compatible, and collision-resistant.
### Format
```
┌────────┐ ┌──────┐ ┌─────┐
│ PREFIX │─│ CODE │─│ AMT │
└────────┘ └──────┘ └─────┘
14 ch 4 ch 13 ch
Example: PNPL-7K4P-50
```
| Segment | Length | Source | Purpose |
|----------|---------|-------------------------------------|----------------------------|
| `PREFIX` | 14 ch | Org `refPrefix` (default `"PNPL"`) | Identify the charity |
| `CODE` | 4 ch | Random (human-safe alphabet) | Unique pledge identifier |
| `AMT` | 13 ch | Last 3 digits of `£` amount | Aide manual matching |
### Human-Safe Alphabet
```
2 3 4 5 6 7 8 9
A B C D E F G H J K L M N P Q R S T U V W X Y Z
```
**Excluded:** `0` (confused with `O`), `1` (confused with `I`/`l`), `I`, `O`, `l`
31 characters → 4-char code = 31⁴ = **923,521 combinations per prefix**
### Constraints
| Constraint | Limit | Reason |
|---------------------|----------------|---------------------------------------------|
| Max total length | 18 characters | UK BACS payment reference field limit |
| Unique per database | Enforced | Prisma `@unique` constraint on `reference` |
| Collision retry | Up to 10 times | Generate new code if collision detected |
| Overflow protection | Truncate prefix| If ref > 18 chars, prefix truncated to 4 |
### Matching Normalisation
When matching bank statement descriptions against references:
```
Input: "pnpl 7k4p 50" → Normalised: "PNPL7K4P50"
Input: "PNPL-7K4P-50" → Normalised: "PNPL7K4P50"
Input: " Pnpl 7K4P " → Normalised: "PNPL7K4P" (trimmed)
```
Algorithm: strip all whitespace and dashes, uppercase.
---
## 7. Analytics Events
### Event Types
| Event | When Fired | Metadata |
|----------------------------|---------------------------------------------------|-------------------------------|
| `pledge_start` | Donor opens pledge flow (QR scanned) | `{eventId, qrSourceId}` |
| `amount_selected` | Donor selects/enters amount | `{amount, preset: boolean}` |
| `rail_selected` | Donor chooses payment method | `{rail}` |
| `identity_submitted` | Donor submits contact details | `{hasEmail, hasPhone, giftAid}` |
| `pledge_completed` | Pledge created successfully | `{amountPence, rail}` |
| `instruction_copy_clicked` | Donor copies bank reference | `{reference}` |
| `i_paid_clicked` | Donor clicks "I've paid" | `{pledgeId}` |
| `payment_matched` | Payment confirmed (reconciliation or webhook) | `{matchedBy, provider}` |
### Dashboard Metrics
**Pledge Funnel:**
```
pledge_start ████████████████████████████████████ 120
amount_selected ████████████████████████████ 95 (79%)
rail_selected ██████████████████████ 80 (67%)
identity_submitted ██████████████ 55 (46%)
pledge_completed ████████████ 47 (39%)
```
**Collection Pipeline:**
- **Collection rate:** `totalCollectedPence / totalPledgedPence` (percentage)
- **Overdue rate:** `overdueCount / totalPledges` (percentage)
- **Top sources:** QR sources ranked by total amount pledged
- **By status:** Breakdown across `new`, `initiated`, `paid`, `overdue`, `cancelled`
- **By rail:** Breakdown across `bank`, `gocardless`, `card`
---
## 8. Lead Qualification
PNPL's business model generates qualified leads for Omair's fractional Head of Technology consultancy. The qualification system is passive — it observes usage patterns rather than gating features.
### Trigger Conditions
A lead is qualified when **any** of the following are met:
- Organisation has created ≥2 events **and** received ≥20 pledges total
- Organisation has received ≥£5,000 in total pledged amount
- Organisation has used reconciliation (≥1 bank statement import)
### Qualification Score
Score is calculated from observable usage signals:
| Signal | Weight | Description |
|--------------------------------|--------|------------------------------------------|
| Attribution usage | High | Multiple QR sources per event |
| Follow-up behaviour | High | Checking dashboard, updating pledge statuses |
| Reconciliation imports | High | Uploading bank statements = operational maturity |
| Event frequency | Medium | Creating events regularly |
| Collection rate | Medium | Higher rate = engaged with the tool |
| CRM exports | Low | Using export = integrating with other systems |
### Application Flow
When the qualification threshold is met, the dashboard shows an "Apply for Fractional CTO" link at `/dashboard/apply`.
The application form is **pre-filled** with:
- Organisation name and size (from event data)
- Number of events run
- Total pledges collected
- Collection rate
- Payment rails used
- Whether reconciliation is active
This gives Omair immediate context on the charity's operational maturity without the applicant needing to self-report.
---
## 9. Integration Points
### 9.1 Webhook Polling (Zapier / Make / n8n)
PNPL does not push webhooks. Instead, external automation tools **poll** for pending events:
```
GET /api/webhooks?since=2026-03-17T00:00:00Z&limit=50
```
**Typical Zapier workflow:**
1. Schedule: poll every 5 minutes
2. Trigger: new `reminder.due` events
3. Action: send email via SendGrid / Mailchimp
4. Action: mark reminder as sent (future endpoint)
**Event format:** See §5.13.
### 9.2 CSV Export
`GET /api/exports/crm-pack` produces a standard CSV importable into:
- Salesforce
- HubSpot
- Beacon CRM
- Donorfy
- Any spreadsheet tool
### 9.3 GoCardless (Direct Debit)
| Setting | Storage | Notes |
|------------------|--------------------------------|-------------------------------|
| Access token | `Organization.gcAccessToken` | Encrypted at rest |
| Environment | `Organization.gcEnvironment` | `"sandbox"` or `"live"` |
| Mandate ID | `PaymentInstruction.gcMandateId` | Stored per pledge |
| Mandate URL | `PaymentInstruction.gcMandateUrl` | Redirect URL for donor |
**Flow:** Pledge created → mandate URL generated → donor authorises → GoCardless collects → webhook confirms → pledge marked paid.
### 9.4 Future Integrations
| Integration | Priority | Description |
|-----------------|----------|--------------------------------------------------|
| Stripe | High | Card payments via Checkout Sessions |
| Open Banking | Medium | Real-time payment initiation (no reference needed)|
| SMS (Twilio) | Medium | Reminder delivery via SMS |
| WhatsApp | Low | Reminder delivery via WhatsApp Business API |
| Stripe Identity | Low | Gift Aid address verification |
---
## 10. Non-Functional Requirements
### 10.1 Performance
| Metric | Target | Rationale |
|-------------------------|-------------------|----------------------------------------|
| API response time | < 200ms (p95) | Mobile users on 4G at events |
| Pledge flow completion | > 80% | 3 screens, 15 seconds |
| QR scan → first screen | < 1s | Instant feel on scan |
| Bank CSV import (500 rows) | < 5s | Blocking UI operation |
### 10.2 Reliability
| Requirement | Implementation |
|--------------------------|---------------------------------------------------|
| Idempotent pledge creation | Reference uniqueness check + retry loop (10 attempts) |
| Analytics never fails | `POST /api/analytics` always returns `200`, catches all errors |
| Transaction safety | Pledge + PaymentInstruction + Reminders created in `$transaction` |
| Reminder stop guarantee | Paid/cancelled status change skips all pending reminders atomically |
### 10.3 Mobile-First Design
- **No account required** for donors — scan QR, pledge, done
- **One-tap reference copy** — copy button on confirmation screen
- **6 preset amounts** — big tap targets, no typing needed
- **Progressive identity** — email OR phone, name optional
- **Responsive** — Tailwind CSS, mobile-first breakpoints
### 10.4 Security
| Concern | Mitigation |
|----------------------|-------------------------------------------------------|
| Org data isolation | All queries scoped by `organizationId` |
| PII handling | Donor email/phone stored, not exposed in QR codes |
| Bank credentials | Stored in DB (future: encrypt at rest, vault) |
| GoCardless tokens | `gcAccessToken` in DB (future: encrypted) |
| Auth | NextAuth.js ready, `x-org-id` header interim |
| Rate limiting | Not yet implemented (future: Redis-based) |
| CSRF | Next.js built-in protections |
### 10.5 Deployment
```
┌─────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌───────────────┐ ┌───────────────────┐ │
│ │ PostgreSQL │ │ Redis │ │
│ │ 16-alpine │ │ 7-alpine │ │
│ │ port: 5432 │ │ port: 6379 │ │
│ └───────────────┘ └───────────────────┘ │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Next.js App │ │
│ │ Node 18+ · port: 3000 │ │
│ │ Prisma ORM · App Router │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
**Single command:**
```bash
docker compose up -d
npx prisma migrate deploy
npm run build && npm start
```
**Environment variables** (see `.env.example`):
- `DATABASE_URL` — PostgreSQL connection string
- `BASE_URL` — Public URL for QR codes
- `NEXTAUTH_SECRET` — Auth session secret
- GoCardless credentials (when ready)
### 10.6 Observability
| Layer | Tool | Notes |
|---------------|-------------------------|------------------------------------|
| Error logging | `console.error` | Structured in API routes |
| Analytics | `AnalyticsEvent` table | Queryable funnel data |
| Import audits | `Import` table | Full history of reconciliation runs|
| Scan tracking | `QrSource.scanCount` | QR engagement metric |
---
## Appendix A: Glossary
| Term | Definition |
|--------------------|-------------------------------------------------------------------|
| **Pledge** | A donor's declared intent to pay a specific amount |
| **Rail** | Payment method — bank transfer, Direct Debit, or card |
| **Reference** | Human-safe, bank-compatible unique code for matching payments |
| **QR Source** | An attribution point (table, volunteer, channel) with a unique QR |
| **Reconciliation** | Process of matching bank statement transactions to pledges |
| **Collection rate** | Percentage of pledged amount that has been confirmed as paid |
| **Initiated** | Donor has clicked "I've paid" but payment not yet confirmed |
## Appendix B: Tech Stack Summary
| Layer | Technology | Version |
|------------|--------------------------|---------|
| Framework | Next.js (App Router) | 14 |
| Language | TypeScript | — |
| Styling | Tailwind CSS + shadcn/ui | — |
| Database | PostgreSQL | 16 |
| ORM | Prisma | — |
| Validation | Zod | — |
| QR Codes | `qrcode` (node) | — |
| CSV | PapaParse | — |
| ID Gen | `nanoid` | — |
| Icons | Lucide React | — |
| Auth | NextAuth.js (ready) | — |
| Cache | Redis | 7 |