# 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 |