56 KiB
Pledge Now, Pay Later — Product Specification
Version: 1.0
Last updated: 2026-02-28
Status: Implementation in progress
Table of Contents
- Overview
- User Personas
- Core Flows
- Data Model
- API Contracts
- Payment Reference Design
- Analytics Events
- Lead Qualification
- Integration Points
- 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 40–60% 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_selectedfires 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_selectedfires on tap
Screen 3 — Identity
- Email or phone required (at least one)
- Name optional
- Gift Aid checkbox with eligibility explainer
- Analytics:
identity_submittedfires 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_completedfires 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 │ │
│◀─────────────────────────────────────────────────────│
- Donor transfers money using the unique reference
- Charity exports bank statement as CSV
- Uploads CSV to PNPL reconciliation tool
- PNPL auto-matches references → marks pledges as paid
- 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-statementwithmultipart/form-data- Accepts
.csvfiles, parsed with PapaParse
Step 3 — Configure Column Mapping
- Map bank-specific column names:
dateCol→ transaction datedescriptionCol→ transaction descriptionamountColorcreditCol→ payment amountreferenceCol→ 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,paidAtset - Payment record created (
matchedBy: "auto") - Pending reminders →
skipped
- Pledge status →
- 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 (1–200 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 (1–100 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:
{
"id": "clx...",
"name": "Annual Gala 2026",
"organizationName": "Hope Foundation",
"qrSourceId": "clx...",
"qrSourceLabel": "Table 5"
}
Response 404:
{ "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:
{
"amountPence": 5000,
"rail": "bank",
"donorName": "Sarah Ahmed",
"donorEmail": "sarah@example.com",
"donorPhone": "07700900123",
"giftAid": true,
"eventId": "clx...",
"qrSourceId": "clx..."
}
Validation (Zod):
amountPence: int, 100–100,000,000 (£1–£1M)rail:"bank" | "gocardless" | "card"donorEmailordonorPhone: at least one requiredeventId: required
Response 201 (bank rail):
{
"id": "clx...",
"reference": "PNPL-7K4P-50",
"bankDetails": {
"bankName": "Barclays",
"sortCode": "20-00-00",
"accountNo": "12345678",
"accountName": "Hope Foundation"
}
}
Response 201 (other rails):
{
"id": "clx...",
"reference": "PNPL-7K4P-50"
}
Side effects:
- Generates collision-resistant reference (up to 10 retries)
- Creates
PaymentInstruction(bank rail) - Creates 4
Reminderrecords (T+0, T+2d, T+7d, T+14d) - Tracks
pledge_completedanalytics event
5.3 PATCH /api/pledges/{id} — Update Pledge Status
Request body:
{
"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
paidAtwhen status →paid - Sets
cancelledAtwhen status →cancelled - Skips all pending reminders when status →
paidorcancelled
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:
{ "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:
[
{
"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:
{
"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, 1–200 charsdescription: optional, max 2000 charseventDate: optional, ISO 8601location: optional, max 500 charsgoalAmount: 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:
[
{
"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:
{
"label": "Table 5",
"volunteerName": "Ahmed",
"tableName": "VIP Table"
}
Validation:
label: required, 1–100 charsvolunteerName: optional, max 100 charstableName: 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:
{
"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 filemapping— JSON string with column mapping
Mapping schema:
{
"dateCol": "Date",
"descriptionCol": "Description",
"amountCol": "Amount",
"creditCol": "Credit",
"referenceCol": "Reference"
}
Response 200:
{
"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,paidAtset - 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:
{
"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:
{
"eventType": "amount_selected",
"pledgeId": null,
"eventId": "clx...",
"qrSourceId": "clx...",
"metadata": { "amount": 5000, "preset": true }
}
Response 200:
{ "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 │
└────────┘ └──────┘ └─────┘
1–4 ch 4 ch 1–3 ch
Example: PNPL-7K4P-50
| Segment | Length | Source | Purpose |
|---|---|---|---|
PREFIX |
1–4 ch | Org refPrefix (default "PNPL") |
Identify the charity |
CODE |
4 ch | Random (human-safe alphabet) | Unique pledge identifier |
AMT |
1–3 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:
- Schedule: poll every 5 minutes
- Trigger: new
reminder.dueevents - Action: send email via SendGrid / Mailchimp
- 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 |
| 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:
docker compose up -d
npx prisma migrate deploy
npm run build && npm start
Environment variables (see .env.example):
DATABASE_URL— PostgreSQL connection stringBASE_URL— Public URL for QR codesNEXTAUTH_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 |