1365 lines
56 KiB
Markdown
1365 lines
56 KiB
Markdown
# 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 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_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 (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`:**
|
||
```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, 100–100,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, 1–200 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, 1–100 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 │
|
||
└────────┘ └──────┘ └─────┘
|
||
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:**
|
||
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 |
|