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