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

56 KiB
Raw Blame History

Pledge Now, Pay Later — Product Specification

Version: 1.0
Last updated: 2026-02-28
Status: Implementation in progress


Table of Contents

  1. Overview
  2. User Personas
  3. Core Flows
  4. Data Model
  5. API Contracts
  6. Payment Reference Design
  7. Analytics Events
  8. Lead Qualification
  9. Integration Points
  10. Non-Functional Requirements

1. Overview

Pledge Now, Pay Later (PNPL) is a free-forever micro-SaaS that helps UK charities capture donation intent at live events — dinners, auctions, fun runs, Friday prayers — and follow through to actual payment.

The Problem

At charity events, donors say "I'll donate later" and never do. Event teams lose 4060% of pledged income because there's no system to:

  • Capture pledges quickly on mobile
  • Attribute donations to tables, volunteers, or campaigns
  • Follow up automatically
  • Reconcile bank payments against pledges

The Solution

PNPL converts verbal intent into tracked, attributed digital pledges in 15 seconds, then drives payment collection through the donor's preferred method:

Payment Rail Fees Collection Best For
Bank transfer £0 Manual + match Most donors (UK)
Direct Debit ~1% Automatic Recurring/high-value
Card ~1.4% Instant Convenience

Business Model

┌─────────────────────────────────────────────────────┐
│                 FREE FOREVER                        │
│  Event setup · QR codes · Pledge flow · Reminders   │
│  Bank reconciliation · CRM export · Dashboard       │
└──────────────────────┬──────────────────────────────┘
                       │
              Qualified Lead Signal
           (events created + pledges collected)
                       │
                       ▼
┌─────────────────────────────────────────────────────┐
│           FRACTIONAL HEAD OF TECHNOLOGY             │
│  Omair's consultancy — pre-filled application with  │
│  event performance metrics from PNPL usage          │
└─────────────────────────────────────────────────────┘

The product is genuinely free. No tiered pricing, no feature gates. Revenue comes from qualifying charity organisations that need broader technology leadership — PNPL usage data pre-fills the consultancy application with proof of operational maturity.


2. User Personas

2.1 Event Lead / Fundraising Manager

Attribute Detail
Role Creates events, manages QR codes, monitors pledge pipeline
Goal Maximise pledge-to-payment conversion
Pain Spreadsheets, lost pledges, no attribution
Key screens Dashboard, Event setup, Reconciliation, CRM export
Tech comfort Moderate — can upload CSVs, follow guided workflows

2.2 Donor

Attribute Detail
Role Scans QR code at event, makes a pledge, pays later
Goal Pledge quickly without friction, pay when convenient
Pain Long forms, app installs, payment pressure at events
Key screens 3-step pledge flow (Amount → Method → Identity)
Tech comfort Any — mobile web only, no account required

2.3 Finance / Admin

Attribute Detail
Role Reconciles bank statements, exports data for CRM/Gift Aid
Goal Match bank payments to pledges, produce accurate records
Pain Manual bank statement line-matching, Gift Aid declarations
Key screens Reconciliation tool, CRM export, Pledge list
Tech comfort Comfortable with CSV imports/exports

2.4 Volunteer

Attribute Detail
Role Assigned a personal QR code, encourages table donations
Goal Show QR, let donors pledge painlessly
Pain Collecting cash, keeping track of who pledged what
Key screens None — shows printed QR or phone screen to donors
Tech comfort Low — just needs to hold up a QR code

3. Core Flows

3.1 Event Setup Flow

┌──────────────┐    ┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│  1. Create   │───▶│ 2. Add QR    │───▶│ 3. Download  │───▶│ 4. Share     │
│    Event     │    │   Sources    │    │   QR sheets  │    │  Event Link  │
└──────────────┘    └──────────────┘    └──────────────┘    └──────────────┘

Step 1 — Create Event

  • Name (required), date, location, fundraising goal
  • Auto-generates URL slug: {name}-{timestamp_base36}
  • Status starts as active (also: draft, closed, archived)

Step 2 — Add QR Sources

  • Each QR source represents a table, volunteer, or campaign channel
  • Label examples: "Table 5", "Volunteer: Ahmed", "Instagram Story"
  • Each gets a unique 8-character code (human-safe alphabet)
  • QR encodes: {BASE_URL}/p/{code}

Step 3 — Download QR Sheets

  • Individual PNG download per QR source (800×800px)
  • QR colour: org primary colour (default #1e40af)
  • Error correction: Level M (15% damage tolerance)

Step 4 — Share Event Link

  • Direct URL for digital channels (no QR needed)
  • Attribution still tracked via qrSourceId

3.2 Donor Pledge Flow (3 screens, 15 seconds)

  ┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────────┐
  │  Screen 1   │────▶│  Screen 2   │────▶│  Screen 3   │────▶│  Confirmation   │
  │   Amount    │     │   Method    │     │  Identity   │     │  + Instructions │
  └─────────────┘     └─────────────┘     └─────────────┘     └─────────────────┘

  6 presets +          Bank (★)            Email OR phone       Bank → ref + copy
  custom entry         Direct Debit        Name (optional)      DD → mandate link
                       Card                Gift Aid checkbox    Card → checkout

Screen 1 — Amount

  • Six preset buttons (e.g., £10, £25, £50, £100, £250, £500)
  • Custom amount input (min £1, max £1,000,000)
  • Analytics: amount_selected fires on tap

Screen 2 — Payment Method

  • Bank Transfer (recommended) — zero fees, donor sends manually
  • Direct Debit — GoCardless mandate, auto-collected
  • Card — Stripe checkout (future)
  • Bank is visually highlighted as the recommended option
  • Analytics: rail_selected fires on tap

Screen 3 — Identity

  • Email or phone required (at least one)
  • Name optional
  • Gift Aid checkbox with eligibility explainer
  • Analytics: identity_submitted fires on submit

Confirmation — Payment Instructions

  • Varies by rail:
Rail Confirmation Content
Bank Sort code, account number, account name, unique reference with one-tap copy button, "I've paid" button
Direct Debit GoCardless mandate link (redirects to authorisation)
Card Stripe payment link (future)
  • Analytics: pledge_completed fires on render

3.3 Payment Collection Flow

Bank Transfer (Primary)

  Donor                      Charity's Bank              PNPL
    │                              │                       │
    │  Transfer with ref           │                       │
    │  PNPL-7K4P-50              │                       │
    │─────────────────────────────▶│                       │
    │                              │                       │
    │                              │  Export CSV            │
    │                              │──────────────────────▶│
    │                              │                       │
    │                              │                       │ Auto-match by
    │                              │                       │ reference code
    │                              │                       │
    │                              │  Pledge marked "paid" │
    │                              │◀──────────────────────│
    │                              │                       │
    │  Reminders stop              │                       │
    │◀─────────────────────────────────────────────────────│
  1. Donor transfers money using the unique reference
  2. Charity exports bank statement as CSV
  3. Uploads CSV to PNPL reconciliation tool
  4. PNPL auto-matches references → marks pledges as paid
  5. Remaining reminders are skipped

GoCardless Direct Debit

  Donor                      GoCardless                  PNPL
    │                              │                       │
    │  Authorise mandate           │                       │
    │─────────────────────────────▶│                       │
    │                              │                       │
    │                              │  Payment collected     │
    │                              │──────────────────────▶│
    │                              │                       │
    │                              │  Webhook: confirmed    │
    │                              │──────────────────────▶│
    │                              │                       │
    │                              │  Pledge marked "paid" │
    │                              │◀──────────────────────│

Card (Future — Stripe)

  Donor ──▶ Stripe Checkout ──▶ Webhook confirms ──▶ Pledge marked "paid"

3.4 Reminder Sequence

Step Timing Template Key Subject Description
0 T+0 instructions Payment details for your £X pledge Bank details, reference, copy button
1 T+2d gentle_nudge Quick reminder about your pledge Friendly — "if you've already paid, thank you!"
2 T+7d urgency_impact Your pledge is making a difference Impact story + urgency framing
3 T+14d final_reminder Final reminder about your pledge Clear options: pay now or cancel

Stop Rules:

  • Auto-stop on payment match (bank reconciliation or webhook)
  • Auto-stop on manual "mark as paid" by staff
  • Auto-stop on pledge cancellation
  • Donor can self-cancel via link in every reminder

Channel: Email (default). SMS and WhatsApp channels are defined in the schema but not yet implemented.

Delivery: Reminders are exposed via a polling webhook endpoint (GET /api/webhooks). External automation tools (Zapier, Make, n8n) poll for due reminders and handle actual email/SMS delivery.


3.5 Reconciliation Flow

  ┌────────────┐    ┌────────────┐    ┌────────────┐    ┌────────────┐    ┌────────────┐
  │ 1. Export   │───▶│ 2. Upload  │───▶│ 3. Map     │───▶│ 4. Review  │───▶│ 5. Confirm │
  │ Bank CSV    │    │  to PNPL   │    │  Columns   │    │  Matches   │    │  & Apply   │
  └────────────┘    └────────────┘    └────────────┘    └────────────┘    └────────────┘

Step 1 — Export Bank Statement

  • Download CSV from online banking (any UK bank format)

Step 2 — Upload to PNPL

  • POST /api/imports/bank-statement with multipart/form-data
  • Accepts .csv files, parsed with PapaParse

Step 3 — Configure Column Mapping

  • Map bank-specific column names:
    • dateCol → transaction date
    • descriptionCol → transaction description
    • amountCol or creditCol → payment amount
    • referenceCol → payment reference (optional — also searched in description)
  • Only credit rows (positive amounts) are processed

Step 4 — Auto-Match Three matching strategies applied in order:

Priority Strategy Confidence Description
1 Exact reference exact Normalised ref matches reference column
2 Description search exact Normalised ref found within description
3 Partial code match partial 4-char code portion found in description

Normalisation: strip spaces, strip dashes, uppercase. e.g., pnpl 7k4p 50PNPL7K4P50

Step 5 — Confirm & Apply

  • Exact matches are auto-confirmed:
    • Pledge status → paid, paidAt set
    • Payment record created (matchedBy: "auto")
    • Pending reminders → skipped
  • Partial matches flagged for manual review
  • Import record saved with stats (total rows, credits, matches, unmatched)

3.6 CRM Export Flow

Endpoint: GET /api/exports/crm-pack

Downloads a CSV with full pledge attribution. Filterable by ?eventId=.

Export Fields:

Column Description Example
pledge_reference Unique bank-safe reference PNPL-7K4P-50
donor_name Donor's name (if provided) Sarah Ahmed
donor_email Donor's email sarah@example.com
donor_phone Donor's phone 07700900123
amount_gbp Pledge amount in pounds 50.00
payment_method Payment rail bank
status Current pledge status paid
event_name Source event Annual Gala 2026
source_label QR source label Table 5
volunteer_name Volunteer assigned to QR source Ahmed
table_name Table assigned to QR source VIP Table
gift_aid Gift Aid eligibility Yes
pledged_at Pledge creation timestamp (ISO 8601) 2026-03-15T19:32:00Z
paid_at Payment confirmation timestamp 2026-03-17T10:15:00Z
days_to_collect Days between pledge and payment 2

4. Data Model

Ten tables in PostgreSQL 16, managed via Prisma ORM.

Entity Relationship Diagram

┌──────────────────┐
│  Organization    │
│──────────────────│
│  id              │
│  name            │        ┌──────────────────┐
│  slug (unique)   │───────▶│      User        │
│  country         │  1:N   │──────────────────│
│  timezone        │        │  id              │
│  bankName        │        │  email (unique)  │
│  bankSortCode    │        │  name            │
│  bankAccountNo   │        │  hashedPassword  │
│  bankAccountName │        │  role            │
│  refPrefix       │        │  organizationId  │
│  logo            │        └──────────────────┘
│  primaryColor    │
│  gcAccessToken   │
│  gcEnvironment   │
└────────┬─────────┘
         │ 1:N
         ▼
┌──────────────────┐  1:N   ┌──────────────────┐
│     Event        │───────▶│    QrSource      │
│──────────────────│        │──────────────────│
│  id              │        │  id              │
│  name            │        │  label           │
│  slug            │        │  code (unique)   │
│  description     │        │  volunteerName   │
│  eventDate       │        │  tableName       │
│  location        │        │  eventId         │
│  goalAmount      │        │  scanCount       │
│  currency        │        └────────┬─────────┘
│  status          │                 │
│  organizationId  │                 │ 0:N
└────────┬─────────┘                 │
         │ 1:N                       │
         ▼                           │
┌──────────────────┐◀────────────────┘
│     Pledge       │
│──────────────────│
│  id              │  1:1   ┌────────────────────────┐
│  reference       │───────▶│  PaymentInstruction    │
│  amountPence     │        │────────────────────────│
│  currency        │        │  id                    │
│  rail            │        │  pledgeId (unique)     │
│  status          │        │  bankReference         │
│  donorName       │        │  bankDetails (JSON)    │
│  donorEmail      │        │  gcMandateId           │
│  donorPhone      │        │  gcMandateUrl          │
│  giftAid         │        │  sentAt                │
│  iPaidClickedAt  │        └────────────────────────┘
│  eventId         │
│  qrSourceId      │  1:N   ┌──────────────────┐
│  organizationId  │───────▶│    Payment       │
│  paidAt          │        │──────────────────│
│  cancelledAt     │        │  id              │
└────────┬─────────┘        │  pledgeId        │
         │                  │  provider        │
         │                  │  providerRef     │
         │ 1:N              │  amountPence     │
         ▼                  │  status          │
┌──────────────────┐        │  matchedBy       │
│    Reminder      │        │  receivedAt      │
│──────────────────│        │  importId        │
│  id              │        └──────────────────┘
│  pledgeId        │
│  step            │
│  channel         │
│  scheduledAt     │
│  sentAt          │
│  status          │
│  payload (JSON)  │
└──────────────────┘

┌──────────────────┐  1:N   ┌──────────────────┐
│     Import       │───────▶│    Payment       │
│──────────────────│        │  (via importId)  │
│  id              │        └──────────────────┘
│  organizationId  │
│  kind            │
│  fileName        │
│  rowCount        │        ┌──────────────────┐
│  matchedCount    │        │ AnalyticsEvent   │
│  unmatchedCount  │        │──────────────────│
│  mappingConfig   │        │  id              │
│  stats (JSON)    │        │  eventType       │
│  status          │        │  pledgeId        │
└──────────────────┘        │  eventId         │
                            │  qrSourceId      │
                            │  metadata (JSON) │
                            │  createdAt       │
                            └──────────────────┘

Table Details

4.1 Organization

The top-level tenant. All data is scoped to an organization.

Field Type Description
id cuid Primary key
name string Organisation display name
slug string URL-safe unique identifier
country string Default "UK"
timezone string Default "Europe/London"
bankName string? Bank name for payment instructions
bankSortCode string? 6-digit sort code
bankAccountNo string? 8-digit account number
bankAccountName string? Name on the bank account
refPrefix string Reference prefix, default "PNPL", max 4 chars
logo string? Logo URL
primaryColor string Brand colour, default "#1e40af"
gcAccessToken string? GoCardless API token
gcEnvironment string "sandbox" or "live"

4.2 User

Staff/admin accounts for the dashboard.

Field Type Description
id cuid Primary key
email string Unique email address
name string? Display name
hashedPassword string? bcrypt hash (nullable for SSO)
role string super_admin, org_admin, staff, volunteer
organizationId string FK → Organization

4.3 Event

A fundraising event that donors pledge at.

Field Type Description
id cuid Primary key
name string Event name (1200 chars)
slug string URL-safe slug (auto-generated)
description string? Event description (max 2000 chars)
eventDate DateTime? When the event takes place
location string? Venue (max 500 chars)
goalAmount int? Fundraising target in pence
currency string Default "GBP"
status string draft, active, closed, archived
organizationId string FK → Organization

Unique constraint: (organizationId, slug)

4.4 QrSource

An attribution source — a specific QR code tied to a table, volunteer, or channel.

Field Type Description
id cuid Primary key
label string Human-readable label (1100 chars)
code string Unique 8-char token for URLs
volunteerName string? Volunteer's name (for attribution)
tableName string? Table identifier (for attribution)
eventId string FK → Event
scanCount int Number of times QR was scanned (auto-incremented)

4.5 Pledge

The core entity — a donor's promise to pay.

Field Type Description
id cuid Primary key
reference string Unique bank-safe reference (see §6)
amountPence int Pledge amount in pence (min 100 = £1)
currency string Default "GBP"
rail string bank, gocardless, card
status string new, initiated, paid, overdue, cancelled
donorName string? Donor's name
donorEmail string? Donor's email
donorPhone string? Donor's phone
giftAid boolean Gift Aid declaration (default false)
iPaidClickedAt DateTime? When donor clicked "I've paid"
notes string? Staff notes
eventId string FK → Event
qrSourceId string? FK → QrSource (null if direct link)
organizationId string FK → Organization
paidAt DateTime? When payment was confirmed
cancelledAt DateTime? When pledge was cancelled

Validation: donorEmail or donorPhone must be present (enforced by Zod schema).

Status State Machine:

                  ┌──────────────────────┐
                  ▼                      │
  ┌───────┐   "I've paid"   ┌──────────┐│  bank match /
  │  new  │─────────────────▶│initiated ││  webhook
  │       │                  │          ││
  └───┬───┘                  └────┬─────┘│
      │                           │      │
      │  bank match / webhook     │      │
      │                           │      │
      ▼                           ▼      │
  ┌───────┐                  ┌──────────┐│
  │ paid  │◀─────────────────│  paid    ││
  └───────┘                  └──────────┘│
      ▲                                  │
      │         ┌──────────┐             │
      │         │ overdue  │─────────────┘
      │         └────┬─────┘
      │              │
      │              ▼
      │         ┌──────────┐
      └─────────│cancelled │
                └──────────┘

4.6 PaymentInstruction

Bank transfer details stored per pledge. Created automatically for rail: "bank".

Field Type Description
id cuid Primary key
pledgeId string FK → Pledge (unique — 1:1)
bankReference string The reference donor must use
bankDetails JSON {sortCode, accountNo, accountName, bankName}
gcMandateId string? GoCardless mandate ID
gcMandateUrl string? GoCardless mandate authorisation URL
sentAt DateTime? When instructions were first sent

4.7 Payment

A confirmed money movement against a pledge.

Field Type Description
id cuid Primary key
pledgeId string FK → Pledge
provider string bank, gocardless, stripe
providerRef string? External payment/transaction ID
amountPence int Amount received in pence
status string pending, confirmed, failed
matchedBy string? auto (reconciliation) or manual (staff)
receivedAt DateTime? When money was received
importId string? FK → Import (if matched via bank statement)

4.8 Reminder

Scheduled follow-up messages for a pledge.

Field Type Description
id cuid Primary key
pledgeId string FK → Pledge
step int Sequence number: 0, 1, 2, 3
channel string email, sms, whatsapp
scheduledAt DateTime When to send
sentAt DateTime? When actually sent
status string pending, sent, skipped, failed
payload JSON? Template key + subject line

4.9 Import

Record of a bank statement upload and its results.

Field Type Description
id cuid Primary key
organizationId string FK → Organization
kind string bank_statement, gocardless_export, crm_export
fileName string? Original upload filename
rowCount int Total rows in CSV
matchedCount int Rows matched to pledges
unmatchedCount int Rows that didn't match
mappingConfig JSON? Column mapping used
stats JSON? Detailed match statistics
status string pending, processing, completed, failed

4.10 AnalyticsEvent

Append-only event log for funnel tracking.

Field Type Description
id cuid Primary key
eventType string Event name (see §7)
pledgeId string? FK → Pledge (if applicable)
eventId string? FK → Event (if applicable)
qrSourceId string? FK → QrSource (if applicable)
metadata JSON? Arbitrary key-value data
createdAt DateTime Timestamp

5. API Contracts

All endpoints are Next.js API routes. Authentication is via x-org-id header (NextAuth.js integration ready but not yet enforced).

5.1 GET /api/qr/{token} — Resolve QR Code

Resolves a QR source token to event info. Increments scanCount.

Path params: token — 8-char QR source code

Response 200:

{
  "id": "clx...",
  "name": "Annual Gala 2026",
  "organizationName": "Hope Foundation",
  "qrSourceId": "clx...",
  "qrSourceLabel": "Table 5"
}

Response 404:

{ "error": "This pledge link is no longer active" }

5.2 POST /api/pledges — Create Pledge

Creates a pledge with payment instruction and reminder schedule in a single transaction.

Request body:

{
  "amountPence": 5000,
  "rail": "bank",
  "donorName": "Sarah Ahmed",
  "donorEmail": "sarah@example.com",
  "donorPhone": "07700900123",
  "giftAid": true,
  "eventId": "clx...",
  "qrSourceId": "clx..."
}

Validation (Zod):

  • amountPence: int, 100100,000,000 (£1£1M)
  • rail: "bank" | "gocardless" | "card"
  • donorEmail or donorPhone: at least one required
  • eventId: required

Response 201 (bank rail):

{
  "id": "clx...",
  "reference": "PNPL-7K4P-50",
  "bankDetails": {
    "bankName": "Barclays",
    "sortCode": "20-00-00",
    "accountNo": "12345678",
    "accountName": "Hope Foundation"
  }
}

Response 201 (other rails):

{
  "id": "clx...",
  "reference": "PNPL-7K4P-50"
}

Side effects:

  • Generates collision-resistant reference (up to 10 retries)
  • Creates PaymentInstruction (bank rail)
  • Creates 4 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:

{
  "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:

{ "ok": true }

Side effects:

  • Sets status"initiated", iPaidClickedAt → now

5.5 GET /api/events — List Events

Returns all events for the organisation with pledge aggregates.

Headers: x-org-id

Response 200:

[
  {
    "id": "clx...",
    "name": "Annual Gala 2026",
    "slug": "annual-gala-2026-m3k9a",
    "eventDate": "2026-03-15T18:00:00.000Z",
    "location": "Grand Hall, London",
    "goalAmount": 5000000,
    "status": "active",
    "pledgeCount": 47,
    "qrSourceCount": 12,
    "totalPledged": 3750000,
    "totalCollected": 2100000,
    "createdAt": "2026-02-01T10:00:00.000Z"
  }
]

Note: goalAmount, totalPledged, and totalCollected are in pence.


5.6 POST /api/events — Create Event

Headers: x-org-id

Request body:

{
  "name": "Annual Gala 2026",
  "description": "Our biggest fundraiser of the year",
  "eventDate": "2026-03-15T18:00:00.000Z",
  "location": "Grand Hall, London",
  "goalAmount": 5000000,
  "currency": "GBP"
}

Validation:

  • name: required, 1200 chars
  • description: optional, max 2000 chars
  • eventDate: optional, ISO 8601
  • location: optional, max 500 chars
  • goalAmount: optional, positive int (pence)

Response 201: Full event object.

Slug generation: {name_slugified}-{timestamp_base36}


5.7 GET /api/events/{id}/qr — List QR Sources

Returns QR sources for an event with pledge stats.

Response 200:

[
  {
    "id": "clx...",
    "label": "Table 5",
    "code": "abc23def",
    "volunteerName": "Ahmed",
    "tableName": "VIP Table",
    "scanCount": 23,
    "pledgeCount": 8,
    "totalPledged": 450000,
    "createdAt": "2026-02-10T14:00:00.000Z"
  }
]

5.8 POST /api/events/{id}/qr — Create QR Source

Request body:

{
  "label": "Table 5",
  "volunteerName": "Ahmed",
  "tableName": "VIP Table"
}

Validation:

  • label: required, 1100 chars
  • volunteerName: optional, max 100 chars
  • tableName: optional, max 100 chars

Response 201: Full QrSource object including generated code.


5.9 GET /api/events/{id}/qr/{qrId}/download — Download QR PNG

Returns an 800×800px PNG image of the QR code.

Query params: code — QR source code (optional, falls back to qrId)

Response: image/png binary with Content-Disposition: attachment


5.10 GET /api/dashboard — Dashboard Stats

Returns full pipeline data with funnel analytics.

Headers: x-org-id
Query params: eventId (optional — filter to single event)

Response 200:

{
  "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:

{
  "dateCol": "Date",
  "descriptionCol": "Description",
  "amountCol": "Amount",
  "creditCol": "Credit",
  "referenceCol": "Reference"
}

Response 200:

{
  "importId": "clx...",
  "summary": {
    "totalRows": 150,
    "credits": 45,
    "exactMatches": 12,
    "partialMatches": 3,
    "unmatched": 30,
    "autoConfirmed": 12
  },
  "matches": [
    {
      "bankRow": {
        "date": "2026-03-17",
        "description": "PNPL-7K4P-50 S AHMED",
        "amount": 50.00,
        "reference": "PNPL-7K4P-50"
      },
      "pledgeId": "clx...",
      "pledgeReference": "PNPL-7K4P-50",
      "confidence": "exact",
      "matchedAmount": 50.00,
      "autoConfirmed": true
    }
  ]
}

Side effects (exact matches):

  • Pledge status → paid, 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:

{
  "events": [
    {
      "event": "reminder.due",
      "timestamp": "2026-03-17T10:00:00.000Z",
      "data": {
        "reminderId": "clx...",
        "pledgeId": "clx...",
        "step": 1,
        "channel": "email",
        "scheduledAt": "2026-03-17T19:32:00.000Z",
        "donor": {
          "name": "Sarah Ahmed",
          "email": "sarah@example.com",
          "phone": null
        },
        "pledge": {
          "reference": "PNPL-7K4P-50",
          "amount": 5000,
          "rail": "bank"
        },
        "event": "Annual Gala 2026",
        "organization": "Hope Foundation",
        "payload": {
          "templateKey": "gentle_nudge",
          "subject": "Quick reminder about your pledge"
        }
      }
    }
  ],
  "count": 1
}

5.14 POST /api/analytics — Track Event

Fire-and-forget analytics tracking. Never returns errors to avoid breaking donor flow.

Request body:

{
  "eventType": "amount_selected",
  "pledgeId": null,
  "eventId": "clx...",
  "qrSourceId": "clx...",
  "metadata": { "amount": 5000, "preset": true }
}

Response 200:

{ "ok": true }

6. Payment Reference Design

The payment reference is the critical link between a pledge in PNPL and a transaction on a bank statement. It must be simultaneously human-readable, bank-compatible, and collision-resistant.

Format

┌────────┐ ┌──────┐ ┌─────┐
│ PREFIX │─│ CODE │─│ AMT │
└────────┘ └──────┘ └─────┘
  14 ch     4 ch    13 ch

Example: PNPL-7K4P-50
Segment Length Source Purpose
PREFIX 14 ch Org refPrefix (default "PNPL") Identify the charity
CODE 4 ch Random (human-safe alphabet) Unique pledge identifier
AMT 13 ch Last 3 digits of £ amount Aide manual matching

Human-Safe Alphabet

2 3 4 5 6 7 8 9
A B C D E F G H J K L M N P Q R S T U V W X Y Z

Excluded: 0 (confused with O), 1 (confused with I/l), I, O, l

31 characters → 4-char code = 31⁴ = 923,521 combinations per prefix

Constraints

Constraint Limit Reason
Max total length 18 characters UK BACS payment reference field limit
Unique per database Enforced Prisma @unique constraint on reference
Collision retry Up to 10 times Generate new code if collision detected
Overflow protection Truncate prefix If ref > 18 chars, prefix truncated to 4

Matching Normalisation

When matching bank statement descriptions against references:

Input:    "pnpl 7k4p 50"     →  Normalised:  "PNPL7K4P50"
Input:    "PNPL-7K4P-50"     →  Normalised:  "PNPL7K4P50"
Input:    "  Pnpl  7K4P  "   →  Normalised:  "PNPL7K4P"   (trimmed)

Algorithm: strip all whitespace and dashes, uppercase.


7. Analytics Events

Event Types

Event When Fired Metadata
pledge_start Donor opens pledge flow (QR scanned) {eventId, qrSourceId}
amount_selected Donor selects/enters amount {amount, preset: boolean}
rail_selected Donor chooses payment method {rail}
identity_submitted Donor submits contact details {hasEmail, hasPhone, giftAid}
pledge_completed Pledge created successfully {amountPence, rail}
instruction_copy_clicked Donor copies bank reference {reference}
i_paid_clicked Donor clicks "I've paid" {pledgeId}
payment_matched Payment confirmed (reconciliation or webhook) {matchedBy, provider}

Dashboard Metrics

Pledge Funnel:

pledge_start          ████████████████████████████████████  120
amount_selected       ████████████████████████████         95  (79%)
rail_selected         ██████████████████████               80  (67%)
identity_submitted    ██████████████                       55  (46%)
pledge_completed      ████████████                         47  (39%)

Collection Pipeline:

  • Collection rate: totalCollectedPence / totalPledgedPence (percentage)
  • Overdue rate: overdueCount / totalPledges (percentage)
  • Top sources: QR sources ranked by total amount pledged
  • By status: Breakdown across new, initiated, paid, overdue, cancelled
  • By rail: Breakdown across bank, gocardless, card

8. Lead Qualification

PNPL's business model generates qualified leads for Omair's fractional Head of Technology consultancy. The qualification system is passive — it observes usage patterns rather than gating features.

Trigger Conditions

A lead is qualified when any of the following are met:

  • Organisation has created ≥2 events and received ≥20 pledges total
  • Organisation has received ≥£5,000 in total pledged amount
  • Organisation has used reconciliation (≥1 bank statement import)

Qualification Score

Score is calculated from observable usage signals:

Signal Weight Description
Attribution usage High Multiple QR sources per event
Follow-up behaviour High Checking dashboard, updating pledge statuses
Reconciliation imports High Uploading bank statements = operational maturity
Event frequency Medium Creating events regularly
Collection rate Medium Higher rate = engaged with the tool
CRM exports Low Using export = integrating with other systems

Application Flow

When the qualification threshold is met, the dashboard shows an "Apply for Fractional CTO" link at /dashboard/apply.

The application form is pre-filled with:

  • Organisation name and size (from event data)
  • Number of events run
  • Total pledges collected
  • Collection rate
  • Payment rails used
  • Whether reconciliation is active

This gives Omair immediate context on the charity's operational maturity without the applicant needing to self-report.


9. Integration Points

9.1 Webhook Polling (Zapier / Make / n8n)

PNPL does not push webhooks. Instead, external automation tools poll for pending events:

GET /api/webhooks?since=2026-03-17T00:00:00Z&limit=50

Typical Zapier workflow:

  1. Schedule: poll every 5 minutes
  2. Trigger: new reminder.due events
  3. Action: send email via SendGrid / Mailchimp
  4. Action: mark reminder as sent (future endpoint)

Event format: See §5.13.

9.2 CSV Export

GET /api/exports/crm-pack produces a standard CSV importable into:

  • Salesforce
  • HubSpot
  • Beacon CRM
  • Donorfy
  • Any spreadsheet tool

9.3 GoCardless (Direct Debit)

Setting Storage Notes
Access token Organization.gcAccessToken Encrypted at rest
Environment Organization.gcEnvironment "sandbox" or "live"
Mandate ID PaymentInstruction.gcMandateId Stored per pledge
Mandate URL PaymentInstruction.gcMandateUrl Redirect URL for donor

Flow: Pledge created → mandate URL generated → donor authorises → GoCardless collects → webhook confirms → pledge marked paid.

9.4 Future Integrations

Integration Priority Description
Stripe High Card payments via Checkout Sessions
Open Banking Medium Real-time payment initiation (no reference needed)
SMS (Twilio) Medium Reminder delivery via SMS
WhatsApp Low Reminder delivery via WhatsApp Business API
Stripe Identity Low Gift Aid address verification

10. Non-Functional Requirements

10.1 Performance

Metric Target Rationale
API response time < 200ms (p95) Mobile users on 4G at events
Pledge flow completion > 80% 3 screens, 15 seconds
QR scan → first screen < 1s Instant feel on scan
Bank CSV import (500 rows) < 5s Blocking UI operation

10.2 Reliability

Requirement Implementation
Idempotent pledge creation Reference uniqueness check + retry loop (10 attempts)
Analytics never fails POST /api/analytics always returns 200, catches all errors
Transaction safety Pledge + PaymentInstruction + Reminders created in $transaction
Reminder stop guarantee Paid/cancelled status change skips all pending reminders atomically

10.3 Mobile-First Design

  • No account required for donors — scan QR, pledge, done
  • One-tap reference copy — copy button on confirmation screen
  • 6 preset amounts — big tap targets, no typing needed
  • Progressive identity — email OR phone, name optional
  • Responsive — Tailwind CSS, mobile-first breakpoints

10.4 Security

Concern Mitigation
Org data isolation All queries scoped by organizationId
PII handling Donor email/phone stored, not exposed in QR codes
Bank credentials Stored in DB (future: encrypt at rest, vault)
GoCardless tokens gcAccessToken in DB (future: encrypted)
Auth NextAuth.js ready, x-org-id header interim
Rate limiting Not yet implemented (future: Redis-based)
CSRF Next.js built-in protections

10.5 Deployment

┌─────────────────────────────────────────────┐
│            Docker Compose Stack              │
│                                             │
│  ┌───────────────┐  ┌───────────────────┐   │
│  │  PostgreSQL   │  │  Redis            │   │
│  │  16-alpine    │  │  7-alpine         │   │
│  │  port: 5432   │  │  port: 6379       │   │
│  └───────────────┘  └───────────────────┘   │
│                                             │
│  ┌───────────────────────────────────────┐  │
│  │  Next.js App                          │  │
│  │  Node 18+ · port: 3000               │  │
│  │  Prisma ORM · App Router             │  │
│  └───────────────────────────────────────┘  │
└─────────────────────────────────────────────┘

Single command:

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