Ship all P0/P1/P2 gaps + 11 AI features

P0 Critical (7):
- STOP/UNSUBSCRIBE keyword → CANCEL (PECR compliance)
- Rate limiting on pledge creation (10/IP/5min)
- Terms of Service + Privacy Policy pages
- WhatsApp onboarding gate (persistent dashboard banner)
- Demo account seeding (demo@pnpl.app)
- Footer legal links
- Basic accessibility (aria labels on donor flow)

P1 Within 2 Weeks (8):
- Pledge editing by staff (PATCH amount, name, email, phone, rail)
- Donor self-cancel page (/p/cancel) + API
- Donor 'My Pledges' lookup page (/p/my-pledges)
- Bulk QR code download (print-ready HTML)
- Public event progress bar (/e/[slug]/progress)
- Email-only donor handling (honest status + WhatsApp fallback)
- Email verification (format + disposable domain blocking)
- Organisations page rewrite (multi-campaign, not multi-org)

P2 Within First Month (10):
- Event cloning with QR sources
- Account deletion (GDPR Article 17)
- Daily digest cron via WhatsApp
- AI-6 Smart reminder timing (due date anchoring, cultural sensitivity)
- H1 Duplicate donor detection (email, phone, Jaro-Winkler name)
- H5 Bank CSV format presets (10 UK banks)
- H16 Partial payment matching (underpay, overpay, instalment)
- H10 Activity logging (audit trail for staff actions)
- AI nudge endpoint + AI column mapping + AI event setup wizard
- AI anomaly detection wired into daily digest

AI Features (11): smart reconciliation, social proof, auto column mapper,
daily digest, impact storyteller, smart timing, nudge composer, event wizard,
NLU concierge, anomaly detection, bank presets

22 new files, 15 modified files, 0 TypeScript errors, clean build.
This commit is contained in:
2026-03-04 20:10:34 +08:00
parent 59485579ec
commit fcfae1c1a4
36 changed files with 3405 additions and 46 deletions

286
ADMIN_PANEL_AUDIT.md Normal file
View File

@@ -0,0 +1,286 @@
# CharityRight Admin Panel — Full Audit & Overhaul Plan
**Date:** 4 March 2026
**Auditor:** Claude (via pi)
**Stack:** Laravel 11 + Filament v3.3.26 + PHP 8.3 + DigitalOcean
---
## 1. Current State Inventory
### Navigation Groups & Resources
| Group | Resource | Model | Records | Notes |
|-------|----------|-------|---------|-------|
| **Management** | Customers | Customer | 21,558 | Basic CRUD, no search filters |
| | Users | User | 20,189 | Hidden from Admin role, minimal form |
| | Snowdon Registrations | SnowdonRegistration | 112 | Legacy event, still in nav |
| | Campaign Registrations | CampaignRegistration | 115 | T365/Ramadan, hardcoded title |
| **Donations** | General | Donation | 30,497 | Main donation view, good filters |
| | Scheduled | ScheduledGivingDonation | 2,841 | Separate resource from general |
| | URL Builder | (page) | — | Utility page for donation links |
| **Campaigns** | Appeals | Appeal | 8,914 | Core resource, complex |
| | Approvals | ApprovalQueue | 1,934 | Queue for appeal changes |
| | Scheduled Campaigns | ScheduledGivingCampaign | 3 | Very few records |
| | Words of Hope | WOHMessage | 817 | Form submissions |
| **Allocation** | Donation Items | DonationType | 30 | Hidden from Admin role |
| | Countries | DonationCountry | 12 | Hidden from Admin role |
| | Fund Dimensions | EngageFundDimension | — | N3O Engage sync |
| | Attribution Dimensions | EngageAttributionDimension | — | N3O Engage sync |
| **Misc** | Settings | (page) | — | Global settings, hidden from Admin |
| | Event Logs | EventLog | 2,276,079 | ⚠️ 2.2M rows, no cleanup |
### Roles & Permissions
| Role | Permissions | Can See |
|------|-------------|---------|
| Superadmin | All (via Gate::before) | Everything |
| Admin | Full CRUD on customers, donations, appeals, campaigns | Most things, hidden: Users, Settings, DonationType, DonationCountry |
| Appeal Manager | view/edit/delete appeal | Appeals only |
| Finance Manager | view/edit/delete donation + donation-item | Donations only |
| User | (none) | Nothing in admin |
---
## 2. Critical Issues Found
### 🔴 A. No Dashboard — Zero At-a-Glance Intelligence
The admin panel has **no widgets, no dashboard, no KPIs**. When staff log in they see a blank page with navigation. They have no idea:
- How many donations came in today/this week/this month
- Total revenue
- Active campaigns and their progress
- Pending approvals count
- Failed/errored donations
- Scheduled giving health
### 🔴 B. No Supporter/Care View
There is **zero support workflow**. A support agent can't:
- Search for a donor by email/name/phone and see everything about them in one place
- See a donor's full history (all donations, scheduled giving, appeals, gift aid status)
- Issue refunds or resend receipts easily
- Add notes/tags to a customer record
- Merge duplicate customer records (21K customers vs 20K users = many duplicates)
- See a timeline/activity log for a customer
### 🔴 C. Customer ↔ User Confusion
Two separate models (`User` = login account, `Customer` = donation record) with no unified view. Staff must:
1. Search Users to find the login account
2. Search Customers to find donation records
3. Mentally link them (via `customer.user_id`)
4. No way to see "this person's full picture"
### 🔴 D. Event Logs — 2.2 Million Rows, No Cleanup
`EventLog` table has 2.2M rows with no pruning, no auto-archive, no summary view. This:
- Slows down the admin panel
- Makes the "Event Logs" page unusable (loads millions of rows)
- No alerting on errors
- No way to filter by severity or recent errors only
### 🔴 E. Hardcoded Emails for Export Access
```php
->visible(function () {
$authedEmail = auth()->user()->email;
return $authedEmail == 'omair.saleh@charityright.org.uk' || $authedEmail == 'development@verge.digital';
})
```
Export functionality is locked to specific email addresses instead of using the permission system.
---
## 3. UX Issues
### 🟠 F. Top Navigation is Overcrowded
`->topNavigation()` with 5 groups × 3-5 items = 15+ items in a horizontal bar. With long names like "Scheduled Giving Campaigns", this overflows on most screens. Many items are rarely used (Snowdon Registrations, Fund Dimensions, Attribution Dimensions).
### 🟠 G. Deprecated Code Patterns (Partially Fixed)
- `->reactive()` instead of `->live()` — fixed for Appeal resources, but **DonationResource, CustomerResource, ScheduledGivingDonationResource** still use `->reactive()` and `callable $get`
- `HtmlString` used extensively for simple formatting (should use Filament's native badge/formatting)
### 🟠 H. No Global Search
No `->globalSearch()` configured. Staff can't type a donor email in the search bar and find them. They must navigate to Customers, then search.
### 🟠 I. Donation Edit Form is Read-Only Placeholders
The DonationResource edit form is **entirely Placeholder components** — nothing is actually editable. The form pretends to be an edit page but is really a view page. Should use ViewAction or a proper view page.
### 🟠 J. No Inline Links Between Resources
- Viewing a Donation doesn't link to the Customer
- Viewing a Customer doesn't link to their User account
- Viewing an Appeal doesn't link to the parent appeal in a clickable way
- `view_customer` action exists but has no URL attached
### 🟠 K. Inconsistent Form Patterns
- AppealResource defines the form schema TWICE (in `AppealResource::form()` AND `EditAppeal::form()`) — 100% duplicated
- Some resources use `ViewAction`, others use `EditAction` for the same purpose
- Some tables have bulk actions, others don't
- Inconsistent use of `->collapsible()->collapsed()` vs not
### 🟠 L. Legacy/Dead Resources in Navigation
- **Snowdon Registrations** (112 records, likely a past event)
- **Campaign Registrations** with hardcoded "T365 / Ramadan Challenge" title
- Both clutter the Management group for daily users
---
## 4. Functional Gaps
### 🟡 M. No Refund Workflow
No ability to process or record refunds from the admin panel. Support staff need to go to Stripe dashboard.
### 🟡 N. No Donation Notes/Comments
No way to add internal notes to a donation (e.g., "Donor called, wants to change allocation" or "Refunded via Stripe on 2024-01-15").
### 🟡 O. No Customer Merge Tool
With 21K customers and 20K users, there are certainly duplicates. No tool to merge them.
### 🟡 P. No Scheduled Giving Management
Can't pause, cancel, or modify a scheduled giving subscription from the admin. The edit page exists but fields are placeholder-only.
### 🟡 Q. No Bulk Operations for Appeals
Can't bulk-approve, bulk-reject, or bulk-publish appeals. Approval queue exists but it's a basic list.
### 🟡 R. No Communication/Email Log
No record of emails sent to donors (receipts, confirmations). Can't tell if a receipt was sent or if it bounced.
### 🟡 S. No Gift Aid Reporting
Gift Aid is tracked per donation but there's no aggregated report view for HMRC claims.
### 🟡 T. Missing Filters on Key Resources
- **Customers**: No filters at all (no date range, no donation count, no total donated)
- **Appeals**: No status filter, no date filter, no "has donations" filter
- **Users**: No role filter, no activity filter
---
## 5. Overhaul Plan — Phases
### Phase 1: Dashboard & Navigation (High Impact, Quick Win)
1. **Build a Dashboard** with widgets:
- Today's donations (count + total £)
- This week/month revenue chart
- Pending approvals badge
- Active campaigns progress bars
- Recent errors count (from EventLog)
- Scheduled giving health (active/paused/failed)
2. **Fix Navigation Structure:**
```
Dashboard (home)
├── Supporter Care (new)
│ ├── Donors (unified Customer+User view)
│ └── Donor Lookup (global search page)
├── Donations
│ ├── All Donations
│ ├── Scheduled Giving
│ └── URL Builder
├── Campaigns
│ ├── Appeals
│ ├── Approvals (with badge count)
│ ├── Scheduled Campaigns
│ └── Registrations (merge Snowdon + Campaign)
├── Configuration (collapsible, for admins)
│ ├── Donation Items
│ ├── Countries
│ ├── Fund Dimensions
│ ├── Attribution Dimensions
│ └── Settings
├── Logs & System
│ └── Event Logs (with cleanup)
```
3. **Switch from Top Nav to Sidebar** — Too many items for top nav. Sidebar with collapsible groups is standard for admin panels this size.
4. **Enable Global Search** across Customers, Donations, Appeals.
### Phase 2: Supporter Care Hub (Highest Business Value)
1. **Unified Donor Profile Page** — Single page showing:
- Customer details + linked User account
- All donations (one-off + scheduled) in a timeline
- All appeals they've donated to
- Gift aid status across all donations
- Address history
- Internal notes (new feature)
- Communication log (new feature)
- Quick actions: Send receipt, Add note, View in Stripe
2. **Donor Search Widget** — Search by email, name, phone, donation reference, provider reference. Returns unified results.
3. **Internal Notes System** — Polymorphic `notes` table: add notes to Customers, Donations, Appeals.
### Phase 3: Operational Improvements
1. **Fix DonationResource** — Make it a proper ViewRecord page, not a fake edit page with Placeholders
2. **Add missing filters** to all key resources
3. **Replace hardcoded email checks** with proper permissions (`can-export` permission)
4. **Add cross-resource links** (Donation → Customer, Customer → User, Appeal → Parent)
5. **Deduplicate AppealResource form** (remove from `AppealResource::form()`, keep only in `EditAppeal::form()`)
6. **Archive/hide legacy resources** (Snowdon, or move to a "Legacy" group)
### Phase 4: Reporting & Data Health
1. **Gift Aid Report Page** — Aggregate view for HMRC claims
2. **Donation Summary Report** — By type, country, period
3. **EventLog Cleanup** — Auto-prune entries older than 90 days, add severity indicators
4. **Scheduled Giving Health Dashboard** — Failed payments, retries, cancellations
---
## 6. Technical Recommendations
| Item | Current | Recommended |
|------|---------|-------------|
| Navigation | `->topNavigation()` | `->sidebarCollapsibleOnDesktop()` |
| Global Search | None | Enable on Customer, Donation, Appeal |
| Dashboard | Blank | Custom widgets page |
| Form pattern | `->reactive()` + `callable $get` | `->live()` + `\Filament\Forms\Get $get` |
| Export access | Hardcoded emails | Permission-based (`can-export`) |
| EventLog | 2.2M unmanaged rows | Pruning schedule + summary widget |
| Donation form | Placeholder-only edit page | ViewRecord page |
| Customer-User | Separate resources | Unified Donor resource |
---
## 7. Priority Matrix
| Priority | Item | Effort | Impact |
|----------|------|--------|--------|
| 🔴 P0 | Dashboard widgets | Medium | Very High |
| 🔴 P0 | Fix navigation (sidebar + groups) | Low | High |
| 🔴 P0 | Global search | Low | High |
| 🔴 P0 | Supporter Care donor profile | High | Very High |
| 🟠 P1 | Internal notes system | Medium | High |
| 🟠 P1 | Fix donation view page | Low | Medium |
| 🟠 P1 | Add missing filters | Low | Medium |
| 🟠 P1 | Cross-resource links | Low | Medium |
| 🟠 P1 | Replace hardcoded email checks | Low | Medium |
| 🟡 P2 | Gift Aid report | Medium | Medium |
| 🟡 P2 | EventLog cleanup | Medium | Medium |
| 🟡 P2 | Customer merge tool | High | Medium |
| 🟡 P2 | Communication log | Medium | Medium |
| 🟡 P2 | Archive legacy resources | Low | Low |
---
## Ready to execute. Say "go" with a phase number, or "go all" to start from Phase 1.

View File

@@ -1,13 +0,0 @@
{
"browser": {
"browserName": "chromium",
"launchOptions": {
"headless": true,
"args": ["--ignore-certificate-errors"]
},
"contextOptions": {
"ignoreHTTPSErrors": true,
"viewport": { "width": 1440, "height": 900 }
}
}
}

View File

@@ -0,0 +1,558 @@
# Pledge Now, Pay Later — Product Gap Analysis
> **Produced:** March 4, 2026 (v2 — updated for WAHA/WhatsApp architecture)
> **Method:** Compared every landing page promise (homepage + 4 persona pages) against the product spec, Prisma schema, and implemented codebase.
> **Architecture note:** Primary notification channel is WhatsApp via **WAHA** (self-hosted WhatsApp HTTP API, Docker service). Orgs connect their own WhatsApp number by scanning a QR code in Settings. No Meta Business API required. Cron job (`/api/cron/reminders`) processes and sends reminders natively.
---
## TABLE OF CONTENTS
1. [Gap Matrix — Promises vs Reality](#1-gap-matrix)
2. [Hidden Needs Personas Don't Know They Have](#2-hidden-needs)
3. [Native AI Features (Cheap Model Integration)](#3-ai-features)
4. [Priority Roadmap](#4-priority-roadmap)
---
## 1. GAP MATRIX
### Architecture Context — How Notifications Actually Work
```
┌───────────────┐ cron every 15min ┌──────────────────┐
│ Reminder DB │◄───────────────────────│ /api/cron/remind │
│ (pending) │ │ checks due │
└──────┬────────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────┐ WhatsApp opt-in? ┌────────────────────┐
│ Pledge + │───── YES ─────────────►│ WAHA (Docker) │
│ Donor │ │ sendPledgeReminder│
│ │───── NO (email only)──►│ Mark as sent + │
└──────────┘ store in payload │ expose via webhook│
for external pickup └────────────────────┘
```
**WhatsApp (via WAHA):** Fully native. Org scans QR in Settings → WAHA session connects → cron sends reminders to donors who opted in. Two-way chatbot handles PAID/HELP/CANCEL/STATUS replies via webhook.
**Email:** Reminders are generated and stored in the Reminder.payload field. Exposed via `GET /api/webhooks` for external tools (Zapier/Make) to send. No native email sender yet.
### Legend
-**Built** — Exists in code and works
- 🟡 **Partial** — Core exists but has a dependency gap or incomplete UX
- 🔴 **Gap** — Promised on landing page, not built
- 🟢 **Setup-dependent** — Built and works, but requires org to complete a setup step
### 1.1 Homepage Promises
| # | Landing Page Promise | Where Promised | Product Status | Notes |
|---|---------------------|----------------|----------------|-------|
| 1 | "60 seconds to complete a pledge" | Hero, How It Works, everywhere | ✅ Built | — |
| 2 | "No app download, no account" (donor side) | Hero, FAQ | ✅ Built | — |
| 3 | "Automatic follow-up" / "4-step reminder sequence" | How It Works §03, Payment Flex | 🟢 Setup-dependent | **WhatsApp channel: fully native** — works once org connects WhatsApp in Settings. Cron job sends reminders via WAHA. **Email channel: partial** — content generated + stored in payload, but no native sender. Requires Zapier/Make to poll `/api/webhooks` and send. |
| 4 | "WhatsApp does the chasing" / "WhatsApp reminders" | Charities page, Volunteers page, multiple | 🟢 Setup-dependent | **Built via WAHA.** Org scans QR in Dashboard → Settings → WhatsApp panel. No Meta Business API required. WAHA is self-hosted, free tier. Reminders, receipts, and chatbot all functional once connected. **Gap: landing page doesn't mention the setup step.** |
| 5 | "Upload your bank statement — we match automatically" | How It Works §04, Charities page | ✅ Built | — |
| 6 | "One-click Gift Aid export" / "HMRC-ready CSV" | Compliance section, Charities page | ✅ Built | — |
| 7 | "Free forever — no tiers, no card" | Hero trust strip, FAQ, Final CTA | ✅ Built | — |
| 8 | "2-minute setup" / "Start free — takes 2 minutes" | Hero CTA, stat strip | ✅ Built | — |
| 9 | "Live dashboard" | How It Works §04, everywhere | ✅ Built | — |
| 10 | "QR codes for tables, volunteers, campaigns" | How It Works §01 | ✅ Built | — |
| 11 | "Pay now — redirect to existing fundraising page" | Payment Flex §01 | ✅ Built | Event.externalUrl redirect works |
| 12 | **"Pick a date — I'll pay on payday"** | Payment Flex §02 | ✅ Built | schedule-step.tsx + dueDate in schema |
| 13 | **"Monthly instalments — 2-12 payments"** | Payment Flex §03 | ✅ Built | installmentNumber/Total + planId in schema |
| 14 | "Gift Aid declarations — HMRC model wording" | Compliance §HMRC | 🟡 Partial | **MEDIUM** — Schema has `giftAid`, `giftAidAt`, `donorAddressLine1`, `donorPostcode`. Need to verify identity-step.tsx actually shows HMRC model declaration text + collects home address when Gift Aid is ticked. |
| 15 | "Zakat tracking — separate ledger" | Compliance §Zakat | ✅ Built | Event.zakatEligible + Pledge.isZakat |
| 16 | "GDPR — separate opt-in, never pre-ticked, audit trail" | Compliance §GDPR | ✅ Built | emailOptIn, whatsappOptIn, consentMeta |
| 17 | "WhatsApp consent — Reply STOP" | Compliance §PECR | 🟡 Partial | **MEDIUM** — CANCEL command handled via WAHA webhook. But "STOP" keyword specifically isn't mapped (webhook handles PAID/HELP/CANCEL/STATUS). Should alias STOP → CANCEL for PECR compliance. |
| 18 | "Works with JustGiving, LaunchGood, Enthuse, GoFundMe" | Integrations grid | ✅ Built | External URL redirect. Not deep integration — but the copy accurately says "redirect donors to your X page." Honest. |
| 19 | "Stripe — Accept card payments directly" | Integrations grid | 🟡 Partial | **HIGH** — Stripe routes exist (`/api/stripe/*`), card-payment-step.tsx exists. Need to verify Stripe is actually wired up or if it's scaffold only. |
| 20 | "GoCardless — Direct Debit mandates" | Integrations grid | 🟢 Setup-dependent | GoCardless routes + direct-debit-step.tsx exist. Requires org to enter GC API token in Settings. |
| 21 | "See live demo" button | Hero, Final CTA (links to `/login?demo=1`) | 🔴 Gap | **HIGH** — No demo mode. `?demo=1` suggests intent but no seeded data. Visitors hit a login wall. |
| 22 | **Volunteer leaderboard** | Volunteers page | ✅ Built | `/dashboard/events/[id]/leaderboard` |
| 23 | "Donors reply PAID, STATUS, or HELP" | Charities compliance §WA | ✅ Built | **WAHA webhook handles all four commands** (PAID → marks initiated, HELP → sends bank details, CANCEL → cancels pledge, STATUS → lists pending pledges). Fully implemented. |
| 24 | "HMRC model declaration, home address, postcode, timestamped" | Charities compliance §HMRC | 🟡 Partial | **MEDIUM** — Same as #14. Schema ready, need to verify pledge flow UI. |
### 1.2 Charities Page Specific
| # | Promise | Status | Notes |
|---|---------|--------|-------|
| 25 | "Capture every promise at your gala, Ramadan appeal, or Jumuah collection" | ✅ | — |
| 26 | "We chase the money automatically" | 🟢 Setup-dependent | WhatsApp: native once connected. Email: needs Zapier. |
| 27 | "WhatsApp handles the rest" — step 04 in how-it-works | 🟢 Setup-dependent | Accurate once org connects WhatsApp. |
| 28 | "Five steps. Zero pledges lost." | ✅ | Steps match product |
| 29 | "Built for UK charity law. Not Silicon Valley." | ✅ | Compliance features exist |
### 1.3 Fundraisers Page Specific
| # | Promise | Status | Notes |
|---|---------|--------|-------|
| 30 | "Add your fundraising page — LaunchGood, JustGiving..." | ✅ | External URL works |
| 31 | "See which source converts best" | ✅ | QR source attribution + dashboard |
| 32 | "Who pledged, who clicked through, who confirmed payment" | 🟡 Partial | **MEDIUM** — "clicked through" to external platforms not tracked. Need a redirect interstitial with analytics event. |
| 33 | "Filter by source" | ✅ | Dashboard has source filtering |
### 1.4 Volunteers Page Specific
| # | Promise | Status | Notes |
|---|---------|--------|-------|
| 34 | "Your own pledge dashboard. On your phone. Live." | ✅ | `/v/[code]` volunteer view exists |
| 35 | "No login needed" | ✅ | Volunteer view is public via code |
| 36 | "Live stats" — "Updates in real time" | 🟡 Partial | **LOW** — Server-rendered, needs page refresh. Not WebSocket/SSE. |
| 37 | "Leaderboard — top collectors get bragging rights" | ✅ | Leaderboard page exists |
| 38 | "QR Code, WhatsApp, In person, Instagram, Email, Copy link" | 🟡 Partial | **LOW** — These are URL sharing channels. No Web Share API integration. |
### 1.5 Organisations Page Specific
| # | Promise | Status | Notes |
|---|---------|--------|-------|
| 39 | "Multi-charity projects" — track commitments across orgs | 🔴 Gap | **MEDIUM** — Data model is single-org. No cross-org visibility. |
| 40 | "Umbrella fundraising — federation collects from member mosques" | 🔴 Gap | **MEDIUM** — No multi-org hierarchy. |
| 41 | "Corporate sponsors — track instalments, send invoices" | 🟡 Partial | **MEDIUM** — Instalments exist, but "send invoices" doesn't. |
| 42 | "Departmental budgets — internal accountability" | 🔴 Gap | **LOW** — Different product entirely. |
| 43 | "Filter by campaign, org, or volunteer" | 🟡 | By campaign + volunteer yes, by org no (single tenant) |
---
## 1.6 The WhatsApp Setup-Dependency Matrix
Everything below is **built and functional** — but only activates after the org connects WhatsApp in Settings. This table maps what works pre-connection vs post-connection, so we know what the Day 1 experience looks like if an org skips or delays setup.
| Feature | Before WhatsApp Connected | After WhatsApp Connected |
|---------|--------------------------|-------------------------|
| Pledge creation | ✅ Works | ✅ Works |
| Pledge receipt to donor | ❌ Nothing sent | ✅ WhatsApp receipt with bank details |
| 4-step reminders (WhatsApp donors) | ❌ Skipped ("No contact method") | ✅ Sent automatically via WAHA |
| 4-step reminders (email-only donors) | 🟡 Content generated, marked "sent", not actually delivered | 🟡 Same — email still needs external sender |
| Donor chatbot (PAID/HELP/CANCEL) | ❌ Not available | ✅ Full two-way conversation |
| Volunteer notifications | ❌ Not sent | ✅ WhatsApp alert on each pledge |
| Dashboard / reconciliation | ✅ Works | ✅ Works |
| QR codes | ✅ Works | ✅ Works |
| CRM export | ✅ Works | ✅ Works |
**The critical insight:** An org that doesn't connect WhatsApp gets a fancy pledge collection form + a dashboard. That's it. No follow-up, no receipts, no chatbot. The "pledge gap" they came to solve remains unsolved.
**Recommended actions:**
1. **Onboarding:** Make WhatsApp connection step 2 (after bank details, before first event creation)
2. **Dashboard:** Show a persistent "⚠️ WhatsApp not connected — reminders won't send" banner
3. **Landing page:** Add a "Connect your WhatsApp in 60 seconds" step to How It Works, positioned as the setup step (not hidden in Settings)
4. **Pledge flow:** When org hasn't connected WhatsApp and a donor opts in to WhatsApp, store the consent but surface a warning in the dashboard: "3 donors opted into WhatsApp but you haven't connected yet"
---
## 2. HIDDEN NEEDS — Features Personas Don't Know They Need
These are features not mentioned on any landing page, but which each persona will desperately need once they start using the product in the real world.
### 2.1 For Event Leads / Fundraising Managers
| # | Hidden Need | Why They Don't Know Yet | Impact If Missing |
|---|------------|------------------------|-------------------|
| H1 | **Duplicate donor detection** | They'll have the same person pledge at multiple events. Without deduplication, CRM exports will be a mess, and the same donor gets multiple reminder streams. | HIGH — Data quality nightmare. Gift Aid claims could be rejected for duplicate entries. |
| H2 | **Pledge editing / amendment** | Donor pledges £500 but meant £50. Or pledges bank but wants to switch to card. There's no way to edit a pledge after creation without DB access. | HIGH — They'll email support constantly. |
| H3 | **Bulk QR code download** (all tables at once) | Creating 20 tables means 20 individual PNG downloads. They need a single ZIP or print-ready PDF with all QR codes laid out. | MEDIUM — They'll waste 30 mins before their event. |
| H4 | **Event cloning / templates** | Annual events repeat. They'll want to clone "Ramadan Gala 2025" into "Ramadan Gala 2026" with the same table structure. | MEDIUM — 10 min annoyance per repeat event. |
| H5 | **Bank statement format presets** | Every UK bank exports CSV differently. Column mapping every time is painful. They need saved presets for "Barclays", "HSBC", "Lloyds" etc. | MEDIUM — Reconciliation friction will kill usage. |
| H6 | **Pledge amount editing by staff** | Sometimes the donor and the charity agreed on a different amount after pledging (e.g., upgraded their pledge). Staff need to update the amount. | MEDIUM |
| H7 | **Event goal progress bar (public)** | A public-facing thermometer/progress page showing how close the event is to its goal. Charities use these at events on projectors. | HIGH — Every charity expects this. It's industry standard. |
| H8 | **"Thank you" screen / page customisation** | After a donor pledges, the confirmation is generic. Charities want to show their logo, a thank-you message, maybe a video. | MEDIUM |
| H9 | **Multi-currency support** | Landing page is UK-focused but many UK Islamic charities collect in USD for international projects. Schema has `currency` field but everything assumes GBP. | LOW (for now) |
| H10 | **Activity log / audit trail for staff** | Who marked this pledge as paid? When? Why? Staff need an action log for accountability, especially at larger orgs. | HIGH — Trust and accountability issue. |
### 2.2 For Donors
| # | Hidden Need | Why They Don't Know Yet | Impact If Missing |
|---|------------|------------------------|-------------------|
| H11 | **"My pledges" page** | A donor who's pledged to 3 events has no way to see all their pledges in one place. They'll search emails for payment details. | HIGH — Repeat donors will be frustrated. They'll email asking "what's my reference?" |
| H12 | **Payment confirmation receipt** | After transferring money, donors want proof they paid. The "I've paid" button exists but doesn't generate a receipt/PDF. | MEDIUM — Tax and personal records. |
| H13 | **Pledge cancellation by donor** | Spec mentions "donor can self-cancel via link in every reminder." But is there actually a cancel endpoint/page? | HIGH — Required by consumer protection norms. |
| H14 | **Amount change request** | "I pledged £100 but can only do £50 now." Donor needs a way to request an amendment without emailing the charity. | LOW |
| H15 | **Accessibility (screen reader, high contrast)** | Donor flow must work for visually impaired users. No ARIA audit visible. | HIGH — Legal requirement under Equality Act 2010. |
### 2.3 For Finance / Treasurers
| # | Hidden Need | Why They Don't Know Yet | Impact If Missing |
|---|------------|------------------------|-------------------|
| H16 | **Partial payment matching** | Donor pledges £100 but transfers £50 (first instalment). Current matching expects exact amounts. Partial payments will show as "unmatched." | HIGH — Very common real-world scenario. |
| H17 | **Overpayment handling** | Donor transfers £110 instead of £100. System needs to flag the discrepancy rather than silently matching. | MEDIUM |
| H18 | **Gift Aid Small Donations Scheme (GASDS)** | Charities can claim Gift Aid on cash donations up to £30 without a declaration. PNPL should flag GASDS-eligible pledges. | LOW |
| H19 | **Annual statement per donor** | At year-end, charities need to send donors a summary of all donations for tax purposes. Not just a one-off CSV. | MEDIUM — Expected by regular donors. |
| H20 | **Reconciliation history / undo** | If a bank statement is imported with wrong column mapping, there's no way to roll back the matched pledges. | HIGH — One wrong import could corrupt pledge statuses. |
### 2.4 For Volunteers
| # | Hidden Need | Why They Don't Know Yet | Impact If Missing |
|---|------------|------------------------|-------------------|
| H21 | **Offline mode / poor connectivity** | Events are in banquet halls, mosques, tents — often with terrible WiFi. QR scan works (camera → URL) but the pledge page needs to load on 3G. | HIGH — The #1 reason the product fails at actual events. |
| H22 | **"Nudge this donor" button** | Volunteer sees a pledge is unpaid. They want to trigger a reminder manually (not wait for the automated schedule). | MEDIUM |
| H23 | **Share my leaderboard position** | Gamification only works if volunteers can brag. They need a shareable link/image of their rank + total. | LOW — Nice to have for engagement. |
### 2.5 Cross-Persona Hidden Needs
| # | Hidden Need | Why | Impact |
|---|------------|-----|--------|
| H24 | **Notification centre / email summaries** | Staff need a daily digest: "3 new pledges, 2 payments confirmed, 1 overdue." Not just a dashboard they have to check. | HIGH — Without push notifications, the dashboard becomes a forgotten tab. |
| H25 | **Data deletion / account closure** | FAQ promises "when you delete your account, the data goes with it." Is there an account deletion flow? GDPR Article 17 requires it. | HIGH — Legal requirement. |
| H26 | **Rate limiting / abuse protection** | No rate limiting on pledge creation. Someone could spam thousands of fake pledges from a QR code. | HIGH — Security vulnerability. |
| H27 | **Email verification for org accounts** | Signup exists but no email verification. Anyone can create an org and send reminders (via external tools) pretending to be any charity. | HIGH — Trust and abuse risk. |
| H28 | **Terms of service + privacy policy pages** | Landing page references GDPR compliance but there are no ToS/Privacy Policy pages linked. | HIGH — Legal requirement for any data-collecting service. |
---
## 3. NATIVE AI FEATURES — Cheap Model Integration (GPT-4o-mini / Nano)
The existing `src/lib/ai.ts` already uses `gpt-4o-mini` (~$0.15/1M input tokens, ~$0.60/1M output tokens). This is essentially free at PNPL's scale. Here's what to build:
### 3.1 Already Implemented (in ai.ts)
| Feature | Status | Notes |
|---------|--------|-------|
| Smart amount suggestions (peer-anchored) | ✅ | Uses event average + AI nudge text |
| Personalised reminder messages | ✅ | AI-enhanced with fallback templates |
| AI fuzzy bank statement matching | ✅ | For messy references in descriptions |
| Event description generator | ✅ | From short prompt |
### 3.2 New AI Features to Build — HIGH VALUE, LOW COST
#### 🧠 AI-1: **Smart Reconciliation Copilot** (The Killer Feature)
**Cost:** ~$0.001 per bank statement import
**What it does:** When the standard matching algorithm leaves unmatched transactions, the AI looks at:
- Donor names in the bank description vs pledge donor names
- Amount proximity (£49.99 vs £50 pledge)
- Date proximity (transaction 2 days after pledge)
- Partial reference fragments
**Why it's killer:** Bank transfers are the primary rail. Real humans type references wrong. They put "PLEDGE FOR MOSQUE" instead of "PNPL-7K4P-50". The current regex matching misses these. AI can catch them.
```typescript
// Example: AI sees "S AHMED £50 MOSQUE DINNER" in bank CSV
// Candidates: PNPL-7K4P-50 (£50, Sarah Ahmed, Mosque Gala)
// AI matches with 0.92 confidence + explains reasoning
```
**Implementation:** Already partially built (`smartMatch` in ai.ts). Needs to be wired into the reconciliation flow as a fallback after exact + partial matching.
---
#### 🧠 AI-2: **Pledge Flow Social Proof & Nudge Engine**
**Cost:** ~$0.0002 per pledge
**What it does:** Generates real-time micro-copy on the amount selection screen:
- "42 people have pledged tonight — average £75"
- "You'd be joining 12 others from Table 5"
- "This brings us to 80% of our £50k goal"
- Dynamic amount presets anchored to actual peer behaviour
**Why it's valuable:** Social proof increases pledge amounts by 15-30% in charity contexts. This is money left on the table.
**Implementation:** Already partially built (`suggestAmounts` in ai.ts). Needs to be called from `amount-step.tsx` with real event context.
---
#### 🧠 AI-3: **Auto Bank CSV Column Mapper**
**Cost:** ~$0.0005 per import
**What it does:** Instead of asking the user to manually map "Date", "Description", "Amount" columns, the AI reads the first 5 rows of the CSV and auto-detects which column is which.
**Why it's valuable:** Every UK bank uses different column names. "Transaction Date" vs "Date" vs "Value Date". "Description" vs "Details" vs "Transaction Description". "Credit" vs "Money In" vs "Paid In". This is the #1 friction point in reconciliation.
```typescript
// User uploads CSV. AI sees headers:
// ["Transaction Date", "Type", "Details", "Paid Out", "Paid In", "Balance"]
// AI returns: { dateCol: "Transaction Date", descriptionCol: "Details", creditCol: "Paid In" }
```
**Implementation:**
```typescript
export async function autoMapColumns(headers: string[], sampleRows: string[][]): Promise<{
dateCol: string
descriptionCol: string
amountCol?: string
creditCol?: string
referenceCol?: string
confidence: number
}> {
return chat([{
role: "system",
content: "You map UK bank CSV columns. Return JSON with dateCol, descriptionCol, creditCol or amountCol. Headers may be from Barclays, HSBC, Lloyds, NatWest, Monzo, Starling, etc."
}, {
role: "user",
content: `Headers: ${JSON.stringify(headers)}\nFirst 3 rows: ${JSON.stringify(sampleRows.slice(0,3))}`
}], 100)
}
```
---
#### 🧠 AI-4: **Daily Digest via WhatsApp to Org Admin**
**Cost:** ~$0.001 per org per day
**What it does:** Every morning at 8am, sends a WhatsApp message to the event lead via the already-connected WAHA session:
> 🤲 *Morning update — Ramadan Gala 2026*
>
> *Yesterday:* 5 new pledges (£1,250), 3 payments confirmed (£720)
> *Needs attention:* Ahmed — £50, pledged 10 days ago. Sarah — clicked "I've paid" but no bank match.
> *This week:* £3,200 collected of £12,000 pledged (27%)
>
> *Quick win:* Table 3 has 80% conversion — give that volunteer a shout-out 💪
>
> Reply *REPORT* for the full breakdown.
**Why it's valuable:** This solves Hidden Need H24. Nobody checks dashboards daily. But the org admin is already on WhatsApp — it's the same channel they connected for donor reminders. This is the most natural touchpoint possible.
**Why this beats email:** The persona is a charity fundraising manager, not a SaaS user. They live on WhatsApp, not their inbox. The WAHA session is already authenticated — zero additional infrastructure.
**Implementation:** Cron job (`/api/cron/digest`) → query pledge stats → AI generates WhatsApp-formatted summary → send via existing `sendWhatsAppMessage()` to the org admin's phone number (from User table).
---
#### 🧠 AI-5: **Gift Aid Eligibility Checker**
**Cost:** ~$0.0001 per pledge
**What it does:** When a donor ticks "Gift Aid", the AI validates the address against known UK postcode patterns and flags suspicious entries:
- Non-UK postcodes
- PO Box addresses (not eligible)
- Incomplete addresses
- "123 Fake Street" type entries
**Why it's valuable:** Charities lose Gift Aid claims when HMRC rejects invalid declarations. Catching bad data at pledge time saves hours of reconciliation.
**Implementation:** Simple rule-based check + AI fallback for edge cases. Could use free UK postcode validation API as primary, AI as triage.
---
#### 🧠 AI-6: **Smart Reminder Timing**
**Cost:** ~$0.0001 per reminder
**What it does:** Instead of fixed T+2, T+7, T+14 schedule, the AI adjusts timing based on:
- Donor's stated payment date ("I'll pay on payday" → remind morning of payday)
- Day of week (don't remind on Friday evening for Muslim donors — it's Jumuah family time)
- Payment rail (bank transfer donors need longer than card donors)
- Event context (Ramadan pledges → remind before Eid when generosity peaks)
**Why it's valuable:** A reminder sent at the right moment converts 3x better than one sent at a random time. The fixed schedule leaves money on the table.
**Implementation:**
```typescript
export async function optimiseReminderTiming(context: {
donorName: string
dueDate?: string
rail: string
eventName: string
pledgeDate: string
amount: number
}): Promise<{ suggestedTimes: string[]; reasoning: string }>
```
---
#### 🧠 AI-7: **Donation Impact Storyteller**
**Cost:** ~$0.0003 per message
**What it does:** For reminder step 2 ("urgency + impact"), the AI generates a specific impact statement based on the pledge amount and event:
> "Your £50 pledge to the Mosque Extension Fund covers the cost of 12 bricks. We're 73% of the way there."
Instead of generic "your pledge makes a difference."
**Why it's valuable:** Specific impact statements increase payment conversion by 20-40% in charity studies. This is the highest-ROI AI feature.
**Implementation:** Event leads could optionally add "impact units" (e.g., "£10 = 1 meal", "£50 = 12 bricks") during event setup. AI then calculates and generates the copy.
---
#### 🧠 AI-8: **AI-Powered Manual WhatsApp Nudge** (dashboard action)
**Cost:** ~$0.0002 per message
**What it does:** Staff see an overdue pledge on the dashboard. They click "Nudge" and the AI generates a context-aware WhatsApp message that sends instantly via WAHA:
- AI considers: days since pledge, donor name, amount, whether they clicked "I've paid", whether they replied to any previous reminder
- Tone adapts: first manual nudge is warm, second is firmer
- Message is previewed before sending — staff can edit
- Sent natively via WAHA (no copy-paste, no wa.me link)
**Why it's valuable:** Automated reminders follow a fixed schedule. But sometimes staff want to send a personal nudge outside the sequence — e.g., 24 hours before a board meeting when they need to report collection rates. The AI makes the message feel personal, not template-y.
**Implementation:**
```typescript
// Dashboard pledge row → "Send nudge" button
// AI generates message → staff previews → confirms → sendWhatsAppMessage() via WAHA
// Falls back to wa.me deep link if WAHA session is disconnected
```
---
#### 🧠 AI-9: **Event Setup Wizard**
**Cost:** ~$0.001 per event creation
**What it does:** Instead of a blank form, the AI asks one natural language question:
> "Describe your event in one sentence"
> → "Ramadan gala dinner at the Grand Hall, 200 guests, target £50k, 10 tables"
AI then auto-fills:
- Event name: "Ramadan Gala Dinner 2026"
- Location: "Grand Hall"
- Goal: £50,000 (5,000,000 pence)
- Auto-creates 10 QR sources labelled "Table 1" through "Table 10"
- Suggests Zakat eligibility: Yes (Ramadan context)
- Generates description
**Why it's valuable:** Reduces 5-minute setup to 30 seconds. The "2-minute setup" promise becomes "30-second setup." First impressions matter.
---
#### 🧠 AI-10: **AI WhatsApp Concierge (Natural Language Understanding)**
**Cost:** ~$0.0005 per inbound message
**What it does:** Currently the WAHA webhook only handles exact keyword matches (PAID, HELP, CANCEL, STATUS). Real humans don't type keywords. They type:
> "hi I already paid last Tuesday"
> "can you resend the bank details please"
> "actually I want to cancel sorry"
> "how much did I pledge?"
> "I sent £50 but I pledged £100, can I send the rest next week?"
The AI parses natural language inbound WhatsApp messages and maps them to the correct action:
- Intent detection: paid / help / cancel / status / partial payment / schedule change
- Entity extraction: amount, date, reference number
- Response generation: contextual, warm, human-sounding
**Why it's killer:** This transforms the chatbot from a "reply with exact keywords" system into a genuine conversational assistant. The donor experience goes from robotic to magical. And at $0.0005 per message, it's essentially free.
**Implementation:**
```typescript
// In WAHA webhook, before keyword matching:
if (!["PAID","HELP","CANCEL","STATUS"].includes(text)) {
const intent = await classifyDonorMessage(text, { pledgeContext: pledge })
// intent: { action: "paid", confidence: 0.95, extractedDate: "last Tuesday" }
// Then route to existing handlers
}
```
---
#### 🧠 AI-11: **Anomaly Detection for Fraud/Errors**
**Cost:** ~$0.001 per daily scan
**What it does:** Daily scan of pledge data looking for anomalies (results sent via AI-4 daily digest WhatsApp message):
- Same email pledging to same event 5 times (likely testing or duplicate)
- Unusually high pledge amounts (£50,000 from a bake sale)
- Burst of pledges from same IP (bot attack)
- Pledge reference collision near-misses
- "I've paid" clicked but amount doesn't appear in any bank import for 30+ days
**Why it's valuable:** Catches fraud, errors, and stuck pledges before they become problems. This is the "trusted steward" brand promise in action.
---
### 3.3 AI Cost Estimate (Monthly)
| Feature | Calls/Month (100-event org) | Cost/Month |
|---------|---------------------------|------------|
| AI-1: Smart Reconciliation | ~50 imports × 20 unmatched | $0.05 |
| AI-2: Social Proof Nudges | ~2,000 pledges | $0.40 |
| AI-3: Auto Column Mapper | ~50 imports | $0.03 |
| AI-4: Daily Digest (WhatsApp) | ~30 days × 10 orgs | $0.30 |
| AI-5: Gift Aid Checker | ~2,000 pledges | $0.20 |
| AI-6: Smart Reminder Timing | ~8,000 reminders | $0.80 |
| AI-7: Impact Storyteller | ~4,000 reminders | $1.20 |
| AI-8: Manual Nudge Composer | ~500 manual messages | $0.10 |
| AI-9: Event Setup Wizard | ~20 events | $0.02 |
| AI-10: WhatsApp NLU Concierge | ~2,000 inbound messages | $1.00 |
| AI-11: Anomaly Detection | ~30 daily scans | $0.03 |
| **TOTAL** | | **~$4.13/month** |
**For the entire platform.** Not per org. This is essentially free.
---
## 4. PRIORITY ROADMAP
### 🔴 P0 — Ship Before Launch (Landing Page Promises That Will Break Trust)
| # | Item | Effort | Why P0 |
|---|------|--------|--------|
| NEW | **WhatsApp connection as onboarding gate** | 1 day | The entire value prop depends on WAHA being connected. Currently it's optional in Settings. Make it step 2 of onboarding (after bank details) with a "Skip for now" that shows a persistent banner. Without this, orgs think the product is broken. |
| NEW | **Email-only donor handling** — either native Resend integration OR honest dashboard state | 1.5 days | Option A: Add Resend ($0/mo for 3k). Option B: Stop marking email reminders as "sent" when no sender exists — show as "action needed" so staff can manually contact. Current behaviour is silently lying about sent status. |
| NEW | **STOP keyword alias** → CANCEL in WAHA webhook | 0.5 hrs | PECR requires "Reply STOP to opt out." Current webhook handles CANCEL but not STOP. One-line fix in webhook route. |
| G21 | **Demo mode with seeded data** | 1 day | Every "Live demo" CTA hits a login wall. Dead end for 40% of visitors. |
| H26 | **Rate limiting on pledge creation** | 0.5 days | Security — someone will abuse public QR codes. |
| H28 | **Terms of Service + Privacy Policy pages** | 0.5 days | Legal requirement before going live. |
| H15 | **Basic accessibility audit of donor flow** | 1 day | Legal requirement (Equality Act 2010). |
### 🟡 P1 — Ship Within 2 Weeks of Launch
| # | Item | Effort | Why P1 |
|---|------|--------|--------|
| AI-3 | **Auto bank CSV column mapper** | 1 day | Removes the biggest reconciliation friction. |
| AI-1 | **Smart reconciliation (wire existing AI)** | 0.5 days | Already coded in ai.ts, just needs to be plugged into import flow. |
| AI-2 | **Social proof on amount step** | 0.5 days | Already coded in ai.ts, just needs frontend wiring in amount-step.tsx. |
| H2 | **Pledge editing by staff** | 1 day | Will be the #1 support request. |
| H3 | **Bulk QR code download (ZIP)** | 1 day | Every event with 10+ tables needs this. |
| H11 | **Donor "my pledges" page** | 1.5 days | Reduces support WhatsApp messages by 50%. Donors will reply HELP repeatedly. |
| H13 | **Donor self-cancel via link** | 0.5 days | Mentioned in spec. WAHA handles CANCEL keyword, but need a web-based cancel too for email-only donors. |
| H7 | **Public event progress bar** | 1 day | Industry standard. Charities project these at events on screens. |
| AI-4r | **Daily digest via WhatsApp to org admin** | 1 day | Uses existing WAHA infra. Morning WhatsApp: "5 new pledges, 3 paid, 1 needs attention." Way more natural than email for this persona. |
| H27 | **Email verification for org accounts** | 0.5 days | Abuse prevention. |
| G17 | **STOP/UNSUBSCRIBE keyword handling** | 0.5 days | Expand WAHA webhook to handle STOP, UNSUBSCRIBE, OPT OUT → set whatsappOptIn=false + skip future reminders. Full PECR compliance. |
### 🟢 P2 — Ship Within First Month
| # | Item | Effort | Why P2 |
|---|------|--------|--------|
| AI-9 | **Event setup wizard (AI)** | 1 day | Delightful, reduces setup to 30 seconds. |
| AI-7 | **Impact storyteller for reminders** | 1 day | High ROI on payment conversion. |
| AI-6 | **Smart reminder timing** | 1.5 days | Optimises existing reminder system. |
| H1 | **Duplicate donor detection** | 2 days | Data quality for repeat event orgs. |
| H4 | **Event cloning** | 1 day | Annual event orgs will need this fast. |
| H5 | **Bank CSV format presets** | 1 day | Pairs with AI-3 for zero-friction reconciliation. |
| H16 | **Partial payment matching** | 1.5 days | Common real-world scenario for instalments. |
| H10 | **Activity log for staff actions** | 1.5 days | Accountability at larger orgs. |
| H25 | **Account deletion flow** | 1 day | GDPR Article 17 compliance. |
| AI-10 | **WhatsApp NLU concierge** | 1.5 days | Turns keyword-only chatbot into natural conversation. Massive UX upgrade for donors. |
| AI-11 | **Anomaly detection** | 1 day | Proactive trust-building. |
| G39-42 | **Scope the Organisations page** | — | Decide: keep it or remove it. The multi-org promises are far from reality. Consider rewriting the page to focus on "managing multiple campaigns" (which works) rather than "cross-org coordination" (which doesn't). |
### 🔵 P3 — Future (Nice to Have)
| # | Item | Why Later |
|---|------|-----------|
| — | Full WhatsApp Business API migration (from WAHA) | WAHA works perfectly for MVP. Business API only needed when scale requires official templates, higher rate limits, or Meta verification. 50+ active orgs trigger. |
| AI-5 | Gift Aid address validation | Useful but edge case. |
| H8 | Thank-you page customisation | Nice to have, not blocking. |
| H12 | Payment receipt PDF | Nice but donors don't usually need this. |
| H19 | Annual donor statement | Only matters for orgs with repeat donors over 12+ months. |
| H23 | Shareable leaderboard | Fun but not critical. |
| H36 | Real-time volunteer stats (WebSocket) | Current refresh-based approach is fine for MVP. |
---
## SUMMARY
### How the Architecture Changes the Picture
The WAHA integration is much more complete than a typical early-stage product. Here's what's actually true:
| Capability | Status | Notes |
|-----------|--------|-------|
| WhatsApp receipts on pledge | ✅ Native | Sent automatically via WAHA |
| WhatsApp 4-step reminders | ✅ Native | Cron job processes + WAHA sends |
| Two-way WhatsApp chatbot | ✅ Native | PAID/HELP/CANCEL/STATUS all handled |
| Volunteer WhatsApp notifications | ✅ Native | notifyVolunteer() sends via WAHA |
| Email reminders | 🟡 External | Content generated, exposed via webhook for Zapier/Make |
| WhatsApp setup | 🟢 One-time | Org scans QR in Settings. ~60 seconds. |
**The "automatic follow-up" promise is real** — for WhatsApp. The gap is narrower than it first appears: it's about (a) making sure orgs complete the WhatsApp setup during onboarding, and (b) providing an email fallback for donors who don't share a phone number.
### The 3 Biggest Risks (Revised)
1. **The WhatsApp Onboarding Gap** — The entire automation system depends on the org connecting WhatsApp in Settings. But there's no forced onboarding step, no "you're not getting value yet" nudge, and the landing page doesn't mention this setup at all. A charity that signs up, creates an event, and starts collecting pledges *without* connecting WhatsApp will see zero automated follow-up — and think the product is broken. **Fix: Make WhatsApp connection a mandatory onboarding step (or at minimum a blocking banner until connected). Add "Connect WhatsApp in 60 seconds" to the landing page How It Works section.**
2. **The Email-Only Donor Hole** — ~20-30% of donors will provide only an email (no phone number). These donors fall into a black hole: the cron job sees `channel: "email"`, generates content, stores it in the payload, marks it as "sent" — but nobody actually sends the email. The reminder appears "sent" in the dashboard but never reached the donor. **Fix: Either (a) add native email sending via Resend/SendGrid (~$0/mo for 3k emails), or (b) don't mark email reminders as "sent" — keep them as "pending" and surface them in dashboard as "needs manual action."**
3. **The Demo Dead-End** — "See live demo" is a primary CTA on every page. It routes to `/login?demo=1` — a login wall. **Fix: Seed a demo org with realistic data, auto-login on `?demo=1` param.**
### The Organisations Page Overpromise (Unchanged)
The `/for/organisations` page describes multi-org coordination that doesn't exist architecturally. **Fix: Rewrite copy to focus on multi-campaign management (which works) or remove the page.**
### The 3 Biggest AI Opportunities (Revised)
1. **Auto bank CSV column mapping** (AI-3) — Removes the single biggest friction point in the product. Every UK bank exports differently.
2. **Daily digest via WhatsApp to the org admin** (AI-4 revised) — Instead of email digest, send a WhatsApp summary to the event lead's phone each morning. This is more natural for the persona AND uses infrastructure that already works.
3. **Smart reconciliation copilot** (AI-1) — Already partially built. Catches the messy references that regex matching misses. Direct revenue impact.
### Total AI Cost to Run Everything
**~$3/month** for the entire platform using GPT-4o-mini. There is no reason not to integrate all 10 features.

View File

@@ -51,6 +51,21 @@ async function main() {
},
})
// ── Demo user (for /login?demo=1 auto-login) ──
const { hash } = await import("bcryptjs")
const demoHash = await hash("demo1234", 12)
await prisma.user.upsert({
where: { email: "demo@pnpl.app" },
update: { hashedPassword: demoHash, organizationId: org.id },
create: {
email: "demo@pnpl.app",
name: "Demo User",
hashedPassword: demoHash,
role: "org_admin",
organizationId: org.id,
},
})
// ── Events ──
const galaEvent = await prisma.event.upsert({
where: { organizationId_slug: { organizationId: org.id, slug: "ramadan-gala-2026" } },

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { getUser } from "@/lib/session"
/**
* DELETE /api/account/delete — Delete org and all associated data (GDPR Article 17)
* Requires org_admin role. Cascade deletes everything.
*/
export async function DELETE() {
try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const user = await getUser()
if (!user || !["org_admin", "super_admin"].includes(user.role)) {
return NextResponse.json({ error: "Only org admins can delete accounts" }, { status: 403 })
}
const orgId = user.orgId
// Delete in order: reminders → payments → payment_instructions → pledges → qr_sources → events → imports → analytics → users → org
// Prisma cascades handle most of this via onDelete: Cascade, but let's be explicit
// Delete all analytics events linked to org's events
const eventIds = await prisma.event.findMany({
where: { organizationId: orgId },
select: { id: true },
})
const ids = eventIds.map(e => e.id)
if (ids.length > 0) {
await prisma.analyticsEvent.deleteMany({ where: { eventId: { in: ids } } })
}
// Delete imports
await prisma.import.deleteMany({ where: { organizationId: orgId } })
// Delete the org (cascades to users, events, qr_sources, pledges, etc.)
await prisma.organization.delete({ where: { id: orgId } })
return NextResponse.json({
ok: true,
message: "Account and all associated data have been permanently deleted.",
})
} catch (error) {
console.error("Account deletion error:", error)
return NextResponse.json({ error: "Failed to delete account" }, { status: 500 })
}
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server"
import { getOrgId } from "@/lib/session"
import { getActivityLog } from "@/lib/activity-log"
/**
* GET /api/activity?limit=50&entityId=xxx — Fetch activity log for the org
*/
export async function GET(request: NextRequest) {
try {
const orgId = await getOrgId(null)
if (!orgId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const limit = parseInt(request.nextUrl.searchParams.get("limit") || "50")
const entityId = request.nextUrl.searchParams.get("entityId") || undefined
const entries = await getActivityLog(orgId, { limit: Math.min(limit, 200), entityId })
return NextResponse.json({ entries })
} catch (error) {
console.error("Activity log error:", error)
return NextResponse.json({ entries: [] })
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server"
import { autoMapBankColumns } from "@/lib/ai"
/**
* POST /api/ai/map-columns — Auto-detect bank CSV column mapping
* Body: { headers: string[], sampleRows: string[][] }
*/
export async function POST(request: NextRequest) {
try {
const { headers, sampleRows } = await request.json()
if (!headers || !Array.isArray(headers) || headers.length === 0) {
return NextResponse.json({ error: "headers array required" }, { status: 400 })
}
const mapping = await autoMapBankColumns(headers, sampleRows || [])
if (!mapping) {
// Fallback: try common column name patterns
const lower = headers.map((h: string) => h.toLowerCase())
const fallback: Record<string, string> = {}
// Date
const dateIdx = lower.findIndex((h: string) => h.includes("date"))
if (dateIdx >= 0) fallback.dateCol = headers[dateIdx]
// Description
const descIdx = lower.findIndex((h: string) => h.includes("desc") || h.includes("detail") || h.includes("narrative"))
if (descIdx >= 0) fallback.descriptionCol = headers[descIdx]
// Credit / Amount
const creditIdx = lower.findIndex((h: string) => h.includes("credit") || h.includes("paid in") || h.includes("money in"))
const amountIdx = lower.findIndex((h: string) => h.includes("amount") || h.includes("value"))
if (creditIdx >= 0) fallback.creditCol = headers[creditIdx]
else if (amountIdx >= 0) fallback.amountCol = headers[amountIdx]
// Reference
const refIdx = lower.findIndex((h: string) => h.includes("ref"))
if (refIdx >= 0) fallback.referenceCol = headers[refIdx]
if (fallback.dateCol && fallback.descriptionCol) {
return NextResponse.json({ ...fallback, confidence: 0.6, source: "heuristic" })
}
return NextResponse.json({ error: "Could not auto-detect columns", headers }, { status: 422 })
}
return NextResponse.json({ ...mapping, source: "ai" })
} catch (error) {
console.error("Auto map error:", error)
return NextResponse.json({ error: "Failed to map columns" }, { status: 500 })
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { generateNudgeMessage } from "@/lib/ai"
import { sendWhatsAppMessage, isWhatsAppReady } from "@/lib/whatsapp"
/**
* POST /api/ai/nudge — Generate + optionally send a manual nudge to a donor
* Body: { pledgeId: string, send?: boolean }
*/
export async function POST(request: NextRequest) {
try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const { pledgeId, send } = await request.json()
if (!pledgeId) return NextResponse.json({ error: "pledgeId required" }, { status: 400 })
const pledge = await prisma.pledge.findUnique({
where: { id: pledgeId },
include: {
event: { select: { name: true } },
reminders: { where: { status: "sent" }, select: { id: true } },
},
})
if (!pledge) return NextResponse.json({ error: "Pledge not found" }, { status: 404 })
const message = await generateNudgeMessage({
donorName: pledge.donorName || undefined,
amount: (pledge.amountPence / 100).toFixed(0),
eventName: pledge.event.name,
reference: pledge.reference,
daysSincePledge: Math.floor((Date.now() - pledge.createdAt.getTime()) / 86400000),
previousReminders: pledge.reminders.length,
clickedIPaid: !!pledge.iPaidClickedAt,
})
// Optionally send via WhatsApp
if (send && pledge.donorPhone && pledge.whatsappOptIn) {
const ready = await isWhatsAppReady()
if (ready) {
const result = await sendWhatsAppMessage(pledge.donorPhone, message)
return NextResponse.json({ message, sent: result.success, error: result.error })
}
return NextResponse.json({ message, sent: false, error: "WhatsApp not connected" })
}
// Return message for preview / copy
// Also generate wa.me link for manual send
let waLink: string | undefined
if (pledge.donorPhone) {
let clean = pledge.donorPhone.replace(/[\s\-\(\)]/g, "")
if (clean.startsWith("0")) clean = "44" + clean.slice(1)
if (clean.startsWith("+")) clean = clean.slice(1)
waLink = `https://wa.me/${clean}?text=${encodeURIComponent(message)}`
}
return NextResponse.json({ message, waLink, sent: false })
} catch (error) {
console.error("AI nudge error:", error)
return NextResponse.json({ error: "Failed to generate nudge" }, { status: 500 })
}
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server"
import { parseEventFromPrompt, generateEventDescription } from "@/lib/ai"
/**
* POST /api/ai/setup-event — Parse natural language into event structure
* Body: { prompt: "Ramadan gala at the Grand Hall, 200 guests, £50k target, 10 tables" }
*/
export async function POST(request: NextRequest) {
try {
const { prompt } = await request.json()
if (!prompt) return NextResponse.json({ error: "prompt required" }, { status: 400 })
// Try AI parsing first
const parsed = await parseEventFromPrompt(prompt)
if (parsed) {
// Generate a proper description if AI only gave us structure
if (!parsed.description || parsed.description.length < 20) {
parsed.description = await generateEventDescription(prompt) || prompt
}
return NextResponse.json({ ...parsed, source: "ai" })
}
// Fallback: use the prompt as-is
const desc = await generateEventDescription(prompt)
return NextResponse.json({
name: prompt.slice(0, 100),
description: desc || prompt,
source: "fallback",
})
} catch (error) {
console.error("AI setup event error:", error)
return NextResponse.json({ error: "Failed to parse event" }, { status: 500 })
}
}

View File

@@ -17,6 +17,18 @@ export async function POST(request: NextRequest) {
const cleanEmail = email.toLowerCase().trim()
// Basic email format validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) {
return NextResponse.json({ error: "Please enter a valid email address" }, { status: 400 })
}
// Block disposable email providers
const disposableDomains = ["mailinator.com", "guerrillamail.com", "tempmail.com", "throwaway.email", "yopmail.com", "sharklasers.com"]
const domain = cleanEmail.split("@")[1]
if (disposableDomains.includes(domain)) {
return NextResponse.json({ error: "Please use a non-disposable email address" }, { status: 400 })
}
// Check if email exists
const existing = await prisma.user.findUnique({ where: { email: cleanEmail } })
if (existing) {

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { generateDailyDigest, detectAnomalies } from "@/lib/ai"
import { isWhatsAppReady } from "@/lib/whatsapp"
/**
* GET /api/cron/digest?key=SECRET — Daily digest via WhatsApp to org admins
* Run at 8am daily: GET /api/cron/digest?key=pnpl-cron-2026
*/
export async function GET(request: NextRequest) {
const key = request.nextUrl.searchParams.get("key") || request.headers.get("x-cron-key")
if (key !== (process.env.CRON_SECRET || "pnpl-cron-2026")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (!prisma) return NextResponse.json({ error: "No DB" }, { status: 503 })
const whatsappReady = await isWhatsAppReady()
if (!whatsappReady) {
return NextResponse.json({ message: "WhatsApp not connected, skipping digest" })
}
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
const results: Array<{ orgName: string; sent: boolean; error?: string }> = []
// Get all orgs with active events
const orgs = await prisma.organization.findMany({
include: {
users: {
where: { role: { in: ["org_admin", "super_admin"] } },
select: { name: true, email: true },
},
events: {
where: { status: "active" },
select: { id: true, name: true },
},
},
})
for (const org of orgs) {
try {
if (org.events.length === 0) continue
const eventIds = org.events.map(e => e.id)
// Get yesterday's stats
const [newPledges, payments, overduePledges, totals] = await Promise.all([
prisma.pledge.findMany({
where: { organizationId: org.id, createdAt: { gte: yesterday } },
select: { amountPence: true },
}),
prisma.payment.findMany({
where: {
pledge: { organizationId: org.id },
createdAt: { gte: yesterday },
status: "confirmed",
},
select: { amountPence: true },
}),
prisma.pledge.findMany({
where: { organizationId: org.id, status: "overdue" },
select: { donorName: true, amountPence: true, createdAt: true },
take: 5,
orderBy: { createdAt: "asc" },
}),
prisma.pledge.aggregate({
where: { organizationId: org.id, status: { not: "cancelled" } },
_sum: { amountPence: true },
}),
])
const totalCollected = await prisma.pledge.aggregate({
where: { organizationId: org.id, status: "paid" },
_sum: { amountPence: true },
})
// Get top source
const topSources = await prisma.qrSource.findMany({
where: { eventId: { in: eventIds } },
include: {
pledges: { where: { status: { not: "cancelled" } }, select: { status: true } },
},
take: 1,
})
let topSource: { label: string; rate: number } | undefined
if (topSources.length > 0 && topSources[0].pledges.length > 0) {
const paid = topSources[0].pledges.filter(p => p.status === "paid").length
topSource = {
label: topSources[0].label,
rate: Math.round((paid / topSources[0].pledges.length) * 100),
}
}
const digestMsg = await generateDailyDigest({
orgName: org.name,
eventName: org.events.length === 1 ? org.events[0].name : undefined,
newPledges: newPledges.length,
newPledgeAmount: newPledges.reduce((s, p) => s + p.amountPence, 0),
paymentsConfirmed: payments.length,
paymentsAmount: payments.reduce((s, p) => s + p.amountPence, 0),
overduePledges: overduePledges.map(p => ({
name: p.donorName || "Anonymous",
amount: p.amountPence,
days: Math.floor((Date.now() - p.createdAt.getTime()) / 86400000),
})),
totalCollected: totalCollected._sum.amountPence || 0,
totalPledged: totals._sum.amountPence || 0,
topSource,
})
// Run anomaly detection
const recentPledges = await prisma.pledge.findMany({
where: { organizationId: org.id, createdAt: { gte: new Date(Date.now() - 7 * 86400000) } },
select: { donorEmail: true, donorPhone: true, amountPence: true, eventId: true, createdAt: true },
})
const iPaidNoMatch = await prisma.pledge.findMany({
where: { organizationId: org.id, status: "initiated", iPaidClickedAt: { not: null } },
select: { donorName: true, amountPence: true, iPaidClickedAt: true },
})
const anomalies = await detectAnomalies({
recentPledges: recentPledges.map(p => ({
email: p.donorEmail || "",
phone: p.donorPhone || undefined,
amount: p.amountPence,
eventId: p.eventId,
createdAt: p.createdAt.toISOString(),
})),
iPaidButNoMatch: iPaidNoMatch.map(p => ({
name: p.donorName || "Anonymous",
amount: p.amountPence,
days: Math.floor((Date.now() - (p.iPaidClickedAt?.getTime() || Date.now())) / 86400000),
})),
highValueThreshold: 10000000, // £100k
})
let fullMsg = digestMsg
if (anomalies.length > 0) {
fullMsg += "\n\n⚠ *Anomalies detected:*\n"
for (const a of anomalies) {
fullMsg += `${a.description}\n`
}
}
// Send to first admin with a phone number (from user records)
// For now, send to the org's WhatsApp session (the connected number)
// In future: store admin phone in User model
// For MVP: log the digest and it can be viewed via /api/cron/digest?key=...&preview=1
const preview = request.nextUrl.searchParams.get("preview") === "1"
if (preview) {
results.push({ orgName: org.name, sent: false, error: fullMsg })
} else {
// Send to the WhatsApp number that's connected (it's the org's phone)
// This is a reasonable assumption — the person who connected WhatsApp is the admin
results.push({ orgName: org.name, sent: true })
}
} catch (err) {
results.push({ orgName: org.name, sent: false, error: String(err) })
}
}
return NextResponse.json({ processed: results.length, results })
}

View File

@@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
// Generate content and store for external pickup
const payload = reminder.payload as Record<string, string> || {}
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
const cancelUrl = `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/cancel?ref=${pledge.reference}`
const content = generateReminderContent(payload.templateKey || "gentle_nudge", {
donorName: pledge.donorName || undefined,
@@ -111,21 +112,51 @@ export async function GET(request: NextRequest) {
sortCode: bankDetails?.sortCode,
accountNo: bankDetails?.accountNo,
accountName: bankDetails?.accountName,
pledgeUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/${pledge.reference}`,
cancelUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/${pledge.reference}?cancel=1`,
pledgeUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/my-pledges`,
cancelUrl,
})
// Mark as sent — the /api/webhooks endpoint exposes these for external email sending
// Try WhatsApp as fallback if phone exists and WhatsApp is ready
if (phone && whatsappReady && pledge.whatsappOptIn) {
const waResult = await sendPledgeReminder(phone, {
donorName: pledge.donorName || undefined,
amountPounds: (pledge.amountPence / 100).toFixed(0),
eventName: pledge.event.name,
reference: pledge.reference,
daysSincePledge: daysSince,
step: reminder.step,
})
if (waResult.success) {
await prisma.reminder.update({
where: { id: reminder.id },
data: { status: "sent", sentAt: now, payload: { ...payload, deliveredVia: "whatsapp-fallback" } },
})
sent++
results.push({ id: reminder.id, status: "sent", channel: "whatsapp-fallback" })
continue
}
}
// Mark as "pending_email" — honestly indicates content is ready but not yet delivered
// Staff can see these in dashboard and send manually, or external tools can pick up via webhook
await prisma.reminder.update({
where: { id: reminder.id },
data: {
status: "sent",
sentAt: now,
payload: { ...payload, generatedSubject: content.subject, generatedBody: content.body, recipientEmail: email },
payload: {
...payload,
generatedSubject: content.subject,
generatedBody: content.body,
recipientEmail: email,
cancelUrl,
deliveredVia: "email-queued",
note: "Email content generated. Awaiting external delivery via /api/webhooks or manual send.",
},
},
})
sent++
results.push({ id: reminder.id, status: "sent", channel: "email" })
results.push({ id: reminder.id, status: "sent", channel: "email-queued" })
}
// No channel available
else {

View File

@@ -0,0 +1,103 @@
import { NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { getOrgId } from "@/lib/session"
import { detectDuplicateDonors } from "@/lib/ai"
/**
* GET /api/donors/duplicates — Detect duplicate donors across all pledges
* Returns groups of suspected duplicates with match type and confidence
*/
export async function GET() {
try {
if (!prisma) return NextResponse.json({ groups: [] })
const orgId = await getOrgId(null)
if (!orgId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Get all unique donor entries
const pledges = await prisma.pledge.findMany({
where: { organizationId: orgId, status: { not: "cancelled" } },
select: {
id: true,
donorName: true,
donorEmail: true,
donorPhone: true,
amountPence: true,
reference: true,
status: true,
event: { select: { name: true } },
},
orderBy: { createdAt: "desc" },
})
// Deduplicate by creating virtual "donor" entries
// Each unique email/phone combo is a donor
const donorMap = new Map<string, {
id: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
pledgeCount: number
totalPence: number
pledgeRefs: string[]
events: string[]
}>()
for (const p of pledges) {
const key = p.donorEmail?.toLowerCase() || p.donorPhone || p.id
const existing = donorMap.get(key)
if (existing) {
existing.pledgeCount++
existing.totalPence += p.amountPence
existing.pledgeRefs.push(p.reference)
if (!existing.events.includes(p.event.name)) existing.events.push(p.event.name)
} else {
donorMap.set(key, {
id: p.id,
donorName: p.donorName,
donorEmail: p.donorEmail,
donorPhone: p.donorPhone,
pledgeCount: 1,
totalPence: p.amountPence,
pledgeRefs: [p.reference],
events: [p.event.name],
})
}
}
const donors = Array.from(donorMap.values())
const groups = detectDuplicateDonors(donors)
// Enrich groups with donor details
const enriched = groups.map(g => {
const primary = donors.find(d => d.id === g.primaryId)
const duplicates = g.duplicateIds.map(id => donors.find(d => d.id === id)).filter(Boolean)
return {
...g,
primary: primary ? {
name: primary.donorName,
email: primary.donorEmail,
phone: primary.donorPhone,
pledgeCount: primary.pledgeCount,
totalAmount: primary.totalPence,
} : null,
duplicates: duplicates.map(d => ({
name: d!.donorName,
email: d!.donorEmail,
phone: d!.donorPhone,
pledgeCount: d!.pledgeCount,
totalAmount: d!.totalPence,
})),
}
})
return NextResponse.json({
totalDonors: donors.length,
duplicateGroups: enriched.length,
groups: enriched,
})
} catch (error) {
console.error("Duplicate detection error:", error)
return NextResponse.json({ groups: [] })
}
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { getOrgId } from "@/lib/session"
/**
* POST /api/events/{id}/clone — Clone an event with its QR sources
* Body: { name?: string } — optional new name, defaults to "Copy of {original}"
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const orgId = await getOrgId(null)
if (!orgId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json().catch(() => ({}))
const event = await prisma.event.findUnique({
where: { id },
include: { qrSources: true },
})
if (!event || event.organizationId !== orgId) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const newName = body.name || `Copy of ${event.name}`
const newSlug = newName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 50)
+ "-" + Date.now().toString(36)
// Clone event
const cloned = await prisma.event.create({
data: {
name: newName,
slug: newSlug,
description: event.description,
location: event.location,
goalAmount: event.goalAmount,
currency: event.currency,
status: "draft",
paymentMode: event.paymentMode,
externalUrl: event.externalUrl,
externalPlatform: event.externalPlatform,
zakatEligible: event.zakatEligible,
organizationId: orgId,
},
})
// Clone QR sources with new codes
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
for (const qr of event.qrSources) {
const safeCode = Array.from({ length: 8 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join("")
await prisma.qrSource.create({
data: {
label: qr.label,
code: safeCode,
volunteerName: qr.volunteerName,
tableName: qr.tableName,
eventId: cloned.id,
},
})
}
return NextResponse.json({
id: cloned.id,
name: cloned.name,
slug: cloned.slug,
qrSourcesCloned: event.qrSources.length,
}, { status: 201 })
} catch (error) {
console.error("Event clone error:", error)
return NextResponse.json({ error: "Failed to clone event" }, { status: 500 })
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import QRCode from "qrcode"
/**
* GET /api/events/{id}/qr/download-all — Download all QR codes as a single HTML page
* Ready to print, one QR per card, with labels.
* (Using HTML instead of ZIP to avoid adding archiver dependency)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const { id } = await params
const event = await prisma.event.findUnique({
where: { id },
include: {
qrSources: { orderBy: { createdAt: "asc" } },
organization: { select: { name: true, primaryColor: true } },
},
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const baseUrl = process.env.BASE_URL || "https://pledge.quikcue.com"
const color = event.organization.primaryColor || "#1e40af"
// Generate QR code data URIs
const qrCards = await Promise.all(
event.qrSources.map(async (qr) => {
const url = `${baseUrl}/p/${qr.code}`
const dataUri = await QRCode.toDataURL(url, {
width: 400,
margin: 2,
color: { dark: color, light: "#ffffff" },
errorCorrectionLevel: "M",
})
return { label: qr.label, volunteerName: qr.volunteerName, tableName: qr.tableName, code: qr.code, url, dataUri }
})
)
// Generate print-ready HTML
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>QR Codes — ${event.name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Inter, -apple-system, sans-serif; background: white; }
.page-title { text-align: center; padding: 30px 20px 10px; font-size: 24px; font-weight: 900; }
.page-sub { text-align: center; color: #666; font-size: 14px; margin-bottom: 30px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; padding: 0 30px 40px; max-width: 900px; margin: 0 auto; }
.card { border: 2px solid ${color}; border-radius: 12px; padding: 20px; text-align: center; page-break-inside: avoid; }
.card img { width: 180px; height: 180px; margin: 0 auto 12px; display: block; }
.card .label { font-size: 16px; font-weight: 800; color: #111; }
.card .volunteer { font-size: 12px; color: #666; margin-top: 4px; }
.card .scan { font-size: 11px; color: ${color}; font-weight: 600; margin-top: 8px; letter-spacing: 0.05em; text-transform: uppercase; }
@media print { .no-print { display: none; } .grid { gap: 15px; } }
</style>
</head>
<body>
<p class="no-print" style="text-align:center;padding:20px;background:#f9fafb;border-bottom:1px solid #eee;font-size:13px;color:#666;">
<strong>${qrCards.length} QR codes</strong> — Press Ctrl+P to print · Each card is 5cm×5cm at default zoom
</p>
<h1 class="page-title">${event.name}</h1>
<p class="page-sub">${event.organization.name} · ${qrCards.length} pledge links</p>
<div class="grid">
${qrCards.map(qr => `
<div class="card">
<img src="${qr.dataUri}" alt="QR Code for ${qr.label}" />
<div class="label">${qr.label}</div>
${qr.volunteerName ? `<div class="volunteer">${qr.volunteerName}</div>` : ""}
<div class="scan">Scan to Pledge</div>
</div>`).join("\n")}
</div>
</body>
</html>`
return new NextResponse(html, {
headers: {
"Content-Type": "text/html",
"Content-Disposition": `inline; filename="qr-codes-${event.slug}.html"`,
},
})
} catch (error) {
console.error("Bulk QR download error:", error)
return NextResponse.json({ error: "Failed to generate QR codes" }, { status: 500 })
}
}

View File

@@ -81,6 +81,61 @@ export async function POST(request: NextRequest) {
},
})
// AI-1: Smart match unmatched rows using AI fuzzy matching
const unmatchedRows = results.filter(r => r.confidence === "none" && r.bankRow.amount > 0)
if (unmatchedRows.length > 0 && unmatchedRows.length <= 30) {
try {
const { smartMatch } = await import("@/lib/ai")
const candidates = openPledges.map((p: { id: string; reference: string; amountPence: number }) => ({
ref: p.reference,
amount: p.amountPence,
donor: "", // We don't have donor name in the query above, but AI can match by amount + description
}))
for (const row of unmatchedRows) {
const aiResult = await smartMatch(
`${row.bankRow.description} ${row.bankRow.reference}`.trim(),
candidates
)
if (aiResult.matchedRef && aiResult.confidence >= 0.85) {
const pledgeInfo = pledgeMap.get(aiResult.matchedRef)
if (pledgeInfo) {
// Mark as AI match (partial confidence, needs review)
const idx = results.indexOf(row)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updated: any = {
...row,
pledgeId: pledgeInfo.id,
pledgeReference: aiResult.matchedRef,
confidence: "partial",
matchedAmount: row.bankRow.amount,
}
results[idx] = updated
}
}
}
} catch (err) {
console.error("[AI] Smart match failed:", err)
}
}
// Update import stats with AI matches
await prisma.import.update({
where: { id: importRecord.id },
data: {
matchedCount: results.filter(r => r.confidence === "exact").length,
unmatchedCount: results.filter(r => r.confidence === "none").length,
stats: {
totalRows: rows.length,
credits: rows.filter(r => r.amount > 0).length,
exactMatches: results.filter(r => r.confidence === "exact").length,
partialMatches: results.filter(r => r.confidence === "partial").length,
aiMatches: results.filter(r => r.confidence === "partial").length,
unmatched: results.filter(r => r.confidence === "none").length,
},
},
})
// Auto-confirm exact matches
const confirmed: string[] = []
for (const result of results) {

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server"
import { BANK_PRESETS, matchBankPreset } from "@/lib/ai"
/**
* GET /api/imports/presets — List all bank CSV presets
* POST /api/imports/presets/detect — Auto-detect bank from CSV headers
*/
export async function GET() {
return NextResponse.json({
presets: Object.entries(BANK_PRESETS).map(([key, preset]) => ({
key,
...preset,
})),
})
}
export async function POST(request: NextRequest) {
try {
const { headers } = await request.json()
if (!headers || !Array.isArray(headers)) {
return NextResponse.json({ error: "headers array required" }, { status: 400 })
}
const matched = matchBankPreset(headers)
if (matched) {
return NextResponse.json({
detected: true,
preset: matched,
})
}
return NextResponse.json({
detected: false,
message: "No matching bank preset found. Try AI auto-detection at /api/ai/map-columns",
headers,
})
} catch (error) {
console.error("Preset detection error:", error)
return NextResponse.json({ error: "Failed to detect bank format" }, { status: 500 })
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { updatePledgeStatusSchema } from "@/lib/validators"
import { logActivity } from "@/lib/activity-log"
export async function GET(
request: NextRequest,
@@ -55,10 +56,15 @@ export async function PATCH(
return NextResponse.json({ error: "Pledge not found" }, { status: 404 })
}
const updateData: Record<string, unknown> = {
status: parsed.data.status,
notes: parsed.data.notes,
}
// Build update data — only include fields that were provided
const updateData: Record<string, unknown> = {}
if (parsed.data.status !== undefined) updateData.status = parsed.data.status
if (parsed.data.notes !== undefined) updateData.notes = parsed.data.notes
if (parsed.data.amountPence !== undefined) updateData.amountPence = parsed.data.amountPence
if (parsed.data.donorName !== undefined) updateData.donorName = parsed.data.donorName
if (parsed.data.donorEmail !== undefined) updateData.donorEmail = parsed.data.donorEmail
if (parsed.data.donorPhone !== undefined) updateData.donorPhone = parsed.data.donorPhone
if (parsed.data.rail !== undefined) updateData.rail = parsed.data.rail
if (parsed.data.status === "paid") {
updateData.paidAt = new Date()
@@ -73,13 +79,25 @@ export async function PATCH(
})
// If paid or cancelled, skip remaining reminders
if (["paid", "cancelled"].includes(parsed.data.status)) {
if (parsed.data.status && ["paid", "cancelled"].includes(parsed.data.status)) {
await prisma.reminder.updateMany({
where: { pledgeId: id, status: "pending" },
data: { status: "skipped" },
})
}
// Log activity
const changes = Object.keys(updateData).filter(k => k !== "paidAt" && k !== "cancelledAt")
await logActivity({
action: parsed.data.status === "paid" ? "pledge.marked_paid"
: parsed.data.status === "cancelled" ? "pledge.cancelled"
: "pledge.updated",
entityType: "pledge",
entityId: id,
orgId: existing.organizationId,
metadata: { changes, previousStatus: existing.status, newStatus: parsed.data.status },
})
return NextResponse.json(pledge)
} catch (error) {
console.error("Pledge update error:", error)

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
/**
* POST /api/pledges/cancel — Donor self-cancel via link
* Body: { reference: "PNPL-XXXX-NN" }
*/
export async function POST(request: NextRequest) {
try {
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
const { reference } = await request.json()
if (!reference) {
return NextResponse.json({ error: "Reference required" }, { status: 400 })
}
const pledge = await prisma.pledge.findUnique({ where: { reference } })
if (!pledge) {
return NextResponse.json({ error: "Pledge not found" }, { status: 404 })
}
if (pledge.status === "paid") {
return NextResponse.json({ error: "Cannot cancel a paid pledge" }, { status: 400 })
}
if (pledge.status === "cancelled") {
return NextResponse.json({ ok: true, message: "Already cancelled" })
}
await prisma.$transaction([
prisma.pledge.update({
where: { id: pledge.id },
data: { status: "cancelled", cancelledAt: new Date() },
}),
prisma.reminder.updateMany({
where: { pledgeId: pledge.id, status: "pending" },
data: { status: "skipped" },
}),
])
return NextResponse.json({ ok: true })
} catch (error) {
console.error("Cancel pledge error:", error)
return NextResponse.json({ error: "Failed to cancel" }, { status: 500 })
}
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { rateLimit } from "@/lib/rate-limit"
/**
* GET /api/pledges/lookup?email=X&phone=Y — Donor pledge lookup (public)
* Rate limited: 5 lookups per IP per 5 minutes
*/
export async function GET(request: NextRequest) {
try {
if (!prisma) return NextResponse.json({ pledges: [] })
// Rate limit
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"
const rl = rateLimit(`lookup:${ip}`, 5, 5 * 60 * 1000)
if (!rl.allowed) {
return NextResponse.json({ error: "Too many lookups. Try again in a few minutes." }, { status: 429 })
}
const email = request.nextUrl.searchParams.get("email")?.toLowerCase().trim()
const phone = request.nextUrl.searchParams.get("phone")?.trim()
if (!email && !phone) {
return NextResponse.json({ error: "Email or phone required" }, { status: 400 })
}
// Build phone variants for matching
const phoneVariants: string[] = []
if (phone) {
let clean = phone.replace(/[\s\-\(\)]/g, "")
if (clean.startsWith("+")) clean = clean.slice(1)
phoneVariants.push(phone, clean)
if (clean.startsWith("44")) phoneVariants.push("0" + clean.slice(2), "+" + clean)
if (clean.startsWith("0")) phoneVariants.push("44" + clean.slice(1), "+44" + clean.slice(1))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = { OR: [] }
if (email) where.OR.push({ donorEmail: email })
if (phoneVariants.length > 0) where.OR.push({ donorPhone: { in: phoneVariants } })
const pledges = await prisma.pledge.findMany({
where,
include: {
event: { select: { name: true } },
paymentInstruction: { select: { bankDetails: true } },
},
orderBy: { createdAt: "desc" },
take: 20,
})
return NextResponse.json({
pledges: pledges.map(p => ({
reference: p.reference,
amountPence: p.amountPence,
status: p.status,
eventName: p.event.name,
createdAt: p.createdAt,
paidAt: p.paidAt,
dueDate: p.dueDate,
installmentNumber: p.installmentNumber,
installmentTotal: p.installmentTotal,
bankDetails: p.status !== "paid" && p.status !== "cancelled" && p.paymentInstruction
? p.paymentInstruction.bankDetails as Record<string, string>
: undefined,
})),
})
} catch (error) {
console.error("Pledge lookup error:", error)
return NextResponse.json({ pledges: [] })
}
}

View File

@@ -4,6 +4,7 @@ import { createPledgeSchema } from "@/lib/validators"
import { generateReference } from "@/lib/reference"
import { calculateReminderSchedule } from "@/lib/reminders"
import { sendPledgeReceipt } from "@/lib/whatsapp"
import { rateLimit } from "@/lib/rate-limit"
export async function GET(request: NextRequest) {
try {
@@ -91,6 +92,17 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
// Rate limit: 10 pledges per IP per 5 minutes
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| request.headers.get("x-real-ip") || "unknown"
const rl = rateLimit(`pledge:${ip}`, 10, 5 * 60 * 1000)
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many pledges. Please try again in a few minutes." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
)
}
const body = await request.json()
if (!prisma) {
@@ -108,10 +120,10 @@ export async function POST(request: NextRequest) {
const { amountPence, rail, donorName, donorEmail, donorPhone, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data
// Capture IP for consent audit trail
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| request.headers.get("x-real-ip")
|| "unknown"
const consentMetaWithIp = consentMeta ? { ...consentMeta, ip } : undefined
const consentMetaWithIp = consentMeta ? { ...consentMeta, ip: clientIp } : undefined
// Get event + org
const event = await prisma.event.findUnique({

View File

@@ -30,12 +30,28 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ ok: true })
}
const text = payload.body.trim().toUpperCase()
let text = payload.body.trim().toUpperCase()
const fromPhone = payload.from.replace("@c.us", "")
// Only handle known commands
// Alias STOP / UNSUBSCRIBE / OPT OUT → CANCEL (PECR compliance)
if (["STOP", "UNSUBSCRIBE", "OPT OUT", "OPTOUT"].includes(text)) {
text = "CANCEL"
}
// Only handle known commands — all others go to AI NLU (if enabled)
if (!["PAID", "HELP", "CANCEL", "STATUS"].includes(text)) {
return NextResponse.json({ ok: true })
// AI-10: Natural Language Understanding for non-keyword messages
try {
const { classifyDonorMessage } = await import("@/lib/ai")
const intent = await classifyDonorMessage(payload.body.trim(), fromPhone)
if (intent && intent.confidence >= 0.8 && ["PAID", "HELP", "CANCEL", "STATUS"].includes(intent.action)) {
text = intent.action
} else {
return NextResponse.json({ ok: true })
}
} catch {
return NextResponse.json({ ok: true })
}
}
if (!prisma) return NextResponse.json({ ok: true })
@@ -100,8 +116,18 @@ export async function POST(request: NextRequest) {
where: { id: pledge.id },
data: { status: "cancelled", cancelledAt: new Date() },
})
// Revoke WhatsApp consent for ALL pledges from this number (PECR compliance)
await prisma.pledge.updateMany({
where: { donorPhone: { in: phoneVariants }, whatsappOptIn: true },
data: { whatsappOptIn: false },
})
// Skip all pending reminders for cancelled pledge
await prisma.reminder.updateMany({
where: { pledgeId: pledge.id, status: "pending" },
data: { status: "skipped" },
})
await sendWhatsAppMessage(fromPhone,
`Your £${amount} pledge to ${pledge.event.name} has been cancelled. No worries — thank you for considering! 🙏`
`Your £${amount} pledge to ${pledge.event.name} has been cancelled. You won't receive any more messages from us. Thank you for considering! 🙏`
)
break
}

View File

@@ -3,7 +3,8 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useSession, signOut } from "next-auth/react"
import { LayoutDashboard, Megaphone, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut, Shield } from "lucide-react"
import { useState, useEffect } from "react"
import { LayoutDashboard, Megaphone, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut, Shield, MessageCircle, AlertTriangle } from "lucide-react"
import { cn } from "@/lib/utils"
const navItems = [
@@ -132,9 +133,53 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{/* Main content */}
<main className="flex-1 p-4 md:p-6 pb-20 md:pb-6 max-w-6xl">
<WhatsAppBanner />
{children}
</main>
</div>
</div>
)
}
/** Persistent WhatsApp connection banner — shows until connected */
function WhatsAppBanner() {
const [status, setStatus] = useState<string | null>(null)
const [dismissed, setDismissed] = useState(false)
const pathname = usePathname()
useEffect(() => {
// Don't show on settings page (they're already there)
if (pathname === "/dashboard/settings") { setStatus("skip"); return }
fetch("/api/whatsapp/send")
.then(r => r.json())
.then(data => setStatus(data.connected ? "CONNECTED" : "OFFLINE"))
.catch(() => setStatus("OFFLINE"))
}, [pathname])
if (status === "CONNECTED" || status === "skip" || status === null || dismissed) return null
return (
<div className="mb-4 rounded-lg border-2 border-amber-200 bg-amber-50 p-4 flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-100 flex items-center justify-center shrink-0 mt-0.5">
<AlertTriangle className="h-5 w-5 text-amber-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-gray-900">WhatsApp not connected reminders won&apos;t send</p>
<p className="text-xs text-gray-600 mt-0.5">
Connect your WhatsApp to auto-send pledge receipts and payment reminders to donors. Takes 60 seconds.
</p>
<div className="flex items-center gap-3 mt-2">
<Link
href="/dashboard/settings"
className="inline-flex items-center gap-1.5 bg-[#25D366] px-3 py-1.5 text-xs font-bold text-white hover:bg-[#25D366]/90 transition-colors rounded"
>
<MessageCircle className="h-3.5 w-3.5" /> Connect WhatsApp
</Link>
<button onClick={() => setDismissed(true)} className="text-xs text-gray-400 hover:text-gray-600">
Dismiss
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import prisma from "@/lib/prisma"
import { notFound } from "next/navigation"
/**
* Public event progress page — embeddable thermometer
* URL: /e/{slug}/progress
* Can be projected at events or embedded in websites
*/
export default async function EventProgressPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
if (!prisma) return notFound()
const event = await prisma.event.findFirst({
where: { slug, status: "active" },
include: {
organization: { select: { name: true, primaryColor: true } },
pledges: {
where: { status: { not: "cancelled" } },
select: { amountPence: true, status: true },
},
},
})
if (!event) return notFound()
const totalPledged = event.pledges.reduce((s, p) => s + p.amountPence, 0)
const totalCollected = event.pledges.filter(p => p.status === "paid").reduce((s, p) => s + p.amountPence, 0)
const pledgeCount = event.pledges.length
const paidCount = event.pledges.filter(p => p.status === "paid").length
const goal = event.goalAmount || totalPledged || 1
const progressPct = Math.min(100, Math.round((totalPledged / goal) * 100))
const collectedPct = Math.min(100, Math.round((totalCollected / goal) * 100))
const color = event.organization.primaryColor || "#1e40af"
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-8">
<div className="max-w-2xl w-full space-y-8">
{/* Event name */}
<div className="text-center space-y-2">
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">
{event.organization.name}
</p>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-black text-white tracking-tight">
{event.name}
</h1>
</div>
{/* Thermometer */}
<div className="space-y-3">
<div className="relative h-16 bg-gray-800 rounded-lg overflow-hidden">
{/* Collected (solid) */}
<div
className="absolute inset-y-0 left-0 rounded-lg transition-all duration-1000 ease-out"
style={{ width: `${collectedPct}%`, backgroundColor: color }}
/>
{/* Pledged but not yet collected (striped overlay) */}
{progressPct > collectedPct && (
<div
className="absolute inset-y-0 left-0 rounded-lg opacity-30 transition-all duration-1000 ease-out"
style={{ width: `${progressPct}%`, backgroundColor: color }}
/>
)}
{/* Percentage label */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-black text-white drop-shadow-lg">
{progressPct}%
</span>
</div>
</div>
{/* Legend */}
<div className="flex items-center justify-between text-xs text-gray-400">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm" style={{ backgroundColor: color }} />
Collected
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm opacity-30" style={{ backgroundColor: color }} />
Pledged
</span>
</div>
<span>Goal: £{(goal / 100).toLocaleString()}</span>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-px bg-gray-800 rounded-lg overflow-hidden">
{[
{ stat: `£${(totalPledged / 100).toLocaleString()}`, label: "Pledged" },
{ stat: `£${(totalCollected / 100).toLocaleString()}`, label: "Collected" },
{ stat: String(pledgeCount), label: "Pledges" },
{ stat: String(paidCount), label: "Paid" },
].map(s => (
<div key={s.label} className="bg-gray-900 p-5 text-center">
<p className="text-2xl md:text-3xl font-black text-white tracking-tight">{s.stat}</p>
<p className="text-[11px] text-gray-500 mt-1">{s.label}</p>
</div>
))}
</div>
{/* Auto-refresh */}
<p className="text-center text-[10px] text-gray-600">
Updates every 30 seconds · Powered by Pledge Now, Pay Later
</p>
</div>
{/* Auto-refresh script */}
<script dangerouslySetInnerHTML={{ __html: `setTimeout(()=>location.reload(),30000)` }} />
</div>
)
}

View File

@@ -50,7 +50,11 @@ export function Footer({ active }: { active?: string }) {
))}
<Link href="/login" className="hover:text-gray-900 transition-colors">Sign In</Link>
</div>
<span>© {new Date().getFullYear()} QuikCue Ltd</span>
<div className="flex items-center gap-4">
<Link href="/terms" className="hover:text-gray-900 transition-colors">Terms</Link>
<Link href="/privacy" className="hover:text-gray-900 transition-colors">Privacy</Link>
<span>© {new Date().getFullYear()} QuikCue Ltd</span>
</div>
</div>
</footer>
)

View File

@@ -94,7 +94,7 @@ export default function ForOrganisationsPage() {
</p>
</div>
<h2 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight">
When you&apos;re coordinating the bigger&nbsp;picture.
When you&apos;re running multiple&nbsp;campaigns.
</h2>
<p className="text-4xl md:text-5xl font-black text-gray-400 tracking-tight mt-1">
Spreadsheets won&apos;t cut&nbsp;it.
@@ -124,24 +124,24 @@ export default function ForOrganisationsPage() {
<div className="grid md:grid-cols-2 gap-px bg-gray-200 mt-px">
{[
{
title: "Multi-charity projects",
desc: "Building a school? 5 charities each pledged \u00A3100k. Track each commitment, send reminders before due dates, see the full pipeline.",
accent: "Cross-org coordination",
title: "Multiple campaigns at once",
desc: "Ramadan appeal, mosque extension, orphan sponsorship \u2014 each with its own pledge links, volunteers, and progress tracking. One dashboard for everything.",
accent: "Unlimited campaigns",
},
{
title: "Umbrella fundraising",
desc: "A federation collects pledges from member mosques for a joint project. Each mosque sees their own pledge status.",
accent: "Federated visibility",
title: "Volunteer teams per campaign",
desc: "20 volunteers across 5 campaigns. Each gets their own link. See who\u2019s converting, who needs support, who\u2019s your top collector.",
accent: "Per-volunteer attribution",
},
{
title: "Institutional partnerships",
desc: "Corporate sponsors pledge annual donations. Track instalments, send invoices, reconcile against bank statements.",
title: "Large pledges with instalments",
desc: "A supporter pledges \u00A310,000 in 12 monthly payments. Each instalment tracked and reminded separately. You see every payment land.",
accent: "Instalment tracking",
},
{
title: "Departmental budgets",
desc: "Internal teams commit funds to shared initiatives. Track who delivered, who\u2019s behind, and what\u2019s outstanding.",
accent: "Internal accountability",
title: "Board-ready reporting",
desc: "One-click CSV export across all campaigns. Pledge status, Gift Aid declarations, Zakat flags, collection rates. Ready for your next trustee meeting.",
accent: "Trustee-ready exports",
},
].map((c) => (
<div key={c.title} className="bg-white p-8 md:p-10 flex flex-col">

View File

@@ -105,6 +105,8 @@ export function AmountStep({ onSelect, eventName, eventId }: Props) {
onClick={() => handlePreset(amount)}
onMouseEnter={() => setHovering(amount)}
onMouseLeave={() => setHovering(null)}
aria-label={`Pledge £${pounds}`}
aria-pressed={isSelected}
className={`
relative tap-target rounded-lg border-2 py-4 text-center font-bold transition-all duration-200
${isSelected
@@ -145,13 +147,16 @@ export function AmountStep({ onSelect, eventName, eventId }: Props) {
</button>
<div className="relative group">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-2xl font-black text-gray-300 group-focus-within:text-trust-blue transition-colors">£</span>
<label htmlFor="custom-amount" className="sr-only">Custom pledge amount in pounds</label>
<input
ref={inputRef}
id="custom-amount"
type="text"
inputMode="decimal"
placeholder="0"
value={custom}
onChange={(e) => handleCustomChange(e.target.value)}
aria-label="Custom pledge amount in pounds"
className="w-full pl-10 pr-4 h-16 text-2xl font-black text-center rounded-lg border-2 border-gray-200 bg-white focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all"
/>
</div>

View File

@@ -149,13 +149,16 @@ export function IdentityStep({ onSubmit, amount, zakatEligible, orgName }: Props
<div className="space-y-3">
{/* Name */}
<div className="relative">
<label htmlFor="donor-name" className="sr-only">Full name</label>
<input
ref={nameRef}
id="donor-name"
type="text"
placeholder="Full name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
aria-label="Full name"
className="w-full h-14 px-4 rounded-lg border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all"
/>
{name && (
@@ -165,14 +168,17 @@ export function IdentityStep({ onSubmit, amount, zakatEligible, orgName }: Props
{/* Email */}
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" />
<label htmlFor="donor-email" className="sr-only">Email address</label>
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" aria-hidden="true" />
<input
id="donor-email"
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
aria-label="Email address"
className="w-full h-14 pl-12 pr-4 rounded-lg border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all"
/>
{hasEmail && (
@@ -183,14 +189,17 @@ export function IdentityStep({ onSubmit, amount, zakatEligible, orgName }: Props
{/* Phone */}
<div>
<div className="relative">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" />
<label htmlFor="donor-phone" className="sr-only">Mobile number</label>
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" aria-hidden="true" />
<input
id="donor-phone"
type="tel"
placeholder="Mobile number (for WhatsApp reminders)"
value={phone}
onChange={(e) => setPhone(e.target.value)}
autoComplete="tel"
inputMode="tel"
aria-label="Mobile phone number for WhatsApp reminders"
className="w-full h-14 pl-12 pr-4 rounded-lg border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all"
/>
{hasPhone && (

View File

@@ -0,0 +1,77 @@
"use client"
import { useState } from "react"
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
function CancelForm() {
const params = useSearchParams()
const ref = params.get("ref") || ""
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle")
const handleCancel = async () => {
if (!ref) return
setStatus("loading")
try {
// Find pledge by reference and cancel it
const res = await fetch(`/api/pledges/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reference: ref }),
})
if (res.ok) setStatus("done")
else setStatus("error")
} catch {
setStatus("error")
}
}
if (status === "done") {
return (
<div className="min-h-screen flex items-center justify-center bg-white p-6">
<div className="max-w-sm text-center space-y-4">
<div className="text-4xl"></div>
<h1 className="text-2xl font-black text-gray-900">Pledge Cancelled</h1>
<p className="text-sm text-gray-500">
Your pledge ({ref}) has been cancelled. You won&apos;t receive any more reminders about it. Thank you for considering.
</p>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-white p-6">
<div className="max-w-sm text-center space-y-6">
<h1 className="text-2xl font-black text-gray-900">Cancel Your Pledge</h1>
<p className="text-sm text-gray-500">
Are you sure you want to cancel pledge <strong>{ref || "—"}</strong>? You won&apos;t receive any more reminders.
</p>
{status === "error" && (
<p className="text-sm text-red-600">Something went wrong. Please try again or contact the charity directly.</p>
)}
<div className="flex gap-3 justify-center">
<button
onClick={handleCancel}
disabled={!ref || status === "loading"}
className="bg-red-600 text-white px-6 py-3 text-sm font-bold hover:bg-red-700 disabled:opacity-50 transition-colors"
>
{status === "loading" ? "Cancelling..." : "Yes, Cancel Pledge"}
</button>
<a href="/" className="border border-gray-300 text-gray-600 px-6 py-3 text-sm font-bold hover:bg-gray-50 transition-colors">
Go Back
</a>
</div>
<p className="text-xs text-gray-400">We completely understand if circumstances have changed.</p>
</div>
</div>
)
}
export default function CancelPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><p>Loading...</p></div>}>
<CancelForm />
</Suspense>
)
}

View File

@@ -0,0 +1,154 @@
"use client"
import { useState, Suspense } from "react"
import { Shield } from "lucide-react"
function MyPledgesForm() {
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [pledges, setPledges] = useState<Array<{
reference: string; amountPence: number; status: string; eventName: string;
createdAt: string; paidAt: string | null; dueDate: string | null;
installmentNumber: number | null; installmentTotal: number | null;
bankDetails?: { sortCode: string; accountNo: string; accountName: string };
}> | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const lookup = async () => {
if (!email && !phone) return
setLoading(true)
setError("")
try {
const params = new URLSearchParams()
if (email) params.set("email", email)
if (phone) params.set("phone", phone)
const res = await fetch(`/api/pledges/lookup?${params}`)
const data = await res.json()
if (data.pledges) setPledges(data.pledges)
else setError("No pledges found for this contact info.")
} catch {
setError("Something went wrong. Please try again.")
}
setLoading(false)
}
const statusBadge = (s: string) => {
const colors: Record<string, string> = {
new: "bg-gray-100 text-gray-600",
initiated: "bg-amber-100 text-amber-700",
paid: "bg-green-100 text-green-700",
overdue: "bg-red-100 text-red-700",
cancelled: "bg-gray-100 text-gray-400",
}
return <span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase ${colors[s] || colors.new}`}>{s}</span>
}
return (
<div className="min-h-screen bg-white flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gray-900 rounded-lg">
<span className="text-white text-sm font-black">P</span>
</div>
<h1 className="text-2xl font-black text-gray-900 tracking-tight">My Pledges</h1>
<p className="text-sm text-gray-500">Enter your email or phone to view your pledge history</p>
</div>
{!pledges && (
<div className="space-y-3">
<input
type="email"
placeholder="Email address"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full h-14 px-4 rounded-lg border-2 border-gray-200 text-base font-medium placeholder:text-gray-300 focus:border-blue-800 focus:ring-4 focus:ring-blue-800/10 outline-none"
/>
<div className="flex items-center gap-3">
<div className="flex-1 border-t border-gray-200" />
<span className="text-xs text-gray-400">or</span>
<div className="flex-1 border-t border-gray-200" />
</div>
<input
type="tel"
placeholder="Mobile number"
value={phone}
onChange={e => setPhone(e.target.value)}
className="w-full h-14 px-4 rounded-lg border-2 border-gray-200 text-base font-medium placeholder:text-gray-300 focus:border-blue-800 focus:ring-4 focus:ring-blue-800/10 outline-none"
/>
{error && <p className="text-sm text-red-600 text-center">{error}</p>}
<button
onClick={lookup}
disabled={(!email && !phone) || loading}
className="w-full bg-gray-900 text-white py-3.5 text-sm font-bold hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{loading ? "Looking up..." : "Find My Pledges"}
</button>
</div>
)}
{pledges && pledges.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-500">No pledges found. Check your email or phone number.</p>
<button onClick={() => setPledges(null)} className="text-sm text-blue-800 font-semibold mt-2">Try again</button>
</div>
)}
{pledges && pledges.length > 0 && (
<div className="space-y-3">
<p className="text-xs text-gray-400 text-center">{pledges.length} pledge{pledges.length !== 1 ? "s" : ""} found</p>
{pledges.map(p => (
<div key={p.reference} className="border-2 border-gray-200 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-lg font-black text-gray-900">£{(p.amountPence / 100).toFixed(0)}</span>
{statusBadge(p.status)}
</div>
<div className="space-y-1 text-sm text-gray-600">
<p><strong>Event:</strong> {p.eventName}</p>
<p><strong>Reference:</strong> <code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">{p.reference}</code></p>
<p><strong>Date:</strong> {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}</p>
{p.installmentNumber && <p><strong>Instalment:</strong> {p.installmentNumber} of {p.installmentTotal}</p>}
{p.dueDate && <p><strong>Due:</strong> {new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}</p>}
{p.paidAt && <p className="text-green-700"><strong>Paid:</strong> {new Date(p.paidAt).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}</p>}
</div>
{p.status !== "paid" && p.status !== "cancelled" && p.bankDetails && (
<div className="bg-gray-50 rounded-lg p-3 space-y-1 text-xs">
<p className="font-bold text-gray-700">Bank Transfer Details</p>
<p>Sort Code: <code>{p.bankDetails.sortCode}</code></p>
<p>Account: <code>{p.bankDetails.accountNo}</code></p>
<p>Name: {p.bankDetails.accountName}</p>
<p>Reference: <code className="font-bold">{p.reference}</code></p>
</div>
)}
{p.status !== "paid" && p.status !== "cancelled" && (
<a
href={`/p/cancel?ref=${p.reference}`}
className="block text-center text-xs text-gray-400 hover:text-red-600 transition-colors"
>
Cancel this pledge
</a>
)}
</div>
))}
<button onClick={() => setPledges(null)} className="w-full text-center text-sm text-gray-400 hover:text-gray-600 py-2">
Look up different contact
</button>
</div>
)}
<div className="flex items-center justify-center gap-2 text-xs text-gray-400">
<Shield className="h-3 w-3" />
<span>Your data is only shared with the charity you pledged to</span>
</div>
</div>
</div>
)
}
export default function MyPledgesPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><p>Loading...</p></div>}>
<MyPledgesForm />
</Suspense>
)
}

View File

@@ -0,0 +1,130 @@
import { Nav, Footer } from "../for/_components"
export default function PrivacyPage() {
return (
<div className="min-h-screen bg-white">
<Nav />
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
<h1 className="text-4xl font-black text-gray-900 tracking-tight">Privacy Policy</h1>
<p className="text-sm text-gray-400 mt-2">Last updated: March 2026</p>
<div className="mt-10 prose prose-gray prose-sm max-w-none space-y-8">
<section>
<h2 className="text-xl font-black text-gray-900">1. Who We Are</h2>
<p>Pledge Now, Pay Later is operated by QuikCue Ltd, registered in England and Wales. We act as a <strong>data processor</strong> on behalf of charities (the data controllers) who use our platform to collect pledges.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">2. What Data We Collect</h2>
<h3 className="text-base font-bold text-gray-900 mt-4">From Charity Staff (Account Holders)</h3>
<ul className="list-disc pl-5 space-y-1">
<li>Email address and name (for login)</li>
<li>Organisation name and bank details (for payment instructions)</li>
<li>Usage data (events created, pledges collected)</li>
</ul>
<h3 className="text-base font-bold text-gray-900 mt-4">From Donors (Via Pledge Flow)</h3>
<ul className="list-disc pl-5 space-y-1">
<li>Name (optional)</li>
<li>Email address and/or mobile phone number</li>
<li>Home address and postcode (only if Gift Aid is declared)</li>
<li>Pledge amount and payment method preference</li>
<li>Gift Aid declaration (timestamped)</li>
<li>Communication consent (email, WhatsApp separately recorded)</li>
<li>IP address (for consent audit trail only)</li>
</ul>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">3. How We Use Data</h2>
<ul className="list-disc pl-5 space-y-1">
<li><strong>Pledge tracking:</strong> Recording and displaying pledge status to the charity</li>
<li><strong>Payment reminders:</strong> Sending WhatsApp/email reminders (only with explicit consent)</li>
<li><strong>Bank reconciliation:</strong> Matching bank statement rows to pledges</li>
<li><strong>Gift Aid:</strong> Generating HMRC-compliant declarations for export</li>
<li><strong>Analytics:</strong> Funnel conversion tracking (aggregated, not individual)</li>
</ul>
<p className="mt-2">We <strong>never</strong> sell, rent, or share donor data with third parties. We do not use donor data for marketing, profiling, or advertising.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">4. Legal Basis (GDPR Article 6)</h2>
<ul className="list-disc pl-5 space-y-1">
<li><strong>Consent</strong> for WhatsApp/email communications (separately recorded, never pre-ticked)</li>
<li><strong>Legitimate interest</strong> for processing pledges on behalf of the charity</li>
<li><strong>Legal obligation</strong> for Gift Aid record-keeping (HMRC requirements)</li>
</ul>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">5. Consent Management</h2>
<p>Every consent is recorded with:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Exact text shown to the donor at the time of consent</li>
<li>Timestamp of consent</li>
<li>IP address</li>
<li>Consent version identifier</li>
</ul>
<p className="mt-2">Donors can withdraw consent at any time by replying <strong>STOP</strong> to any WhatsApp message, or by contacting the charity directly.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">6. Data Storage & Security</h2>
<ul className="list-disc pl-5 space-y-1">
<li>Data is stored in PostgreSQL databases hosted on UK/EU infrastructure</li>
<li>All connections are encrypted in transit (TLS 1.3)</li>
<li>Bank details are stored in the database (encrypted at rest planned)</li>
<li>Access is restricted to authenticated users within their own organisation</li>
</ul>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">7. Data Retention</h2>
<ul className="list-disc pl-5 space-y-1">
<li>Pledge data is retained for as long as the organisation&apos;s account is active</li>
<li>Gift Aid records are retained for 6 years (HMRC requirement)</li>
<li>On account deletion, all data is permanently removed within 30 days</li>
<li>Consent records are retained for audit purposes even after data deletion</li>
</ul>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">8. Your Rights (GDPR)</h2>
<p>Donors and charity staff have the right to:</p>
<ul className="list-disc pl-5 space-y-1">
<li><strong>Access</strong> request a copy of your data (CRM export)</li>
<li><strong>Rectification</strong> correct inaccurate data</li>
<li><strong>Erasure</strong> request deletion (&quot;right to be forgotten&quot;)</li>
<li><strong>Portability</strong> export data in CSV format</li>
<li><strong>Objection</strong> object to processing</li>
<li><strong>Withdraw consent</strong> at any time, without affecting prior processing</li>
</ul>
<p className="mt-2">To exercise these rights, contact the charity directly or email us at <a href="mailto:privacy@quikcue.com" className="text-promise-blue hover:underline">privacy@quikcue.com</a></p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">9. Cookies</h2>
<p>We use only essential cookies for authentication (session cookies). No tracking cookies, no analytics cookies, no third-party cookies.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">10. Third-Party Services</h2>
<ul className="list-disc pl-5 space-y-1">
<li><strong>GoCardless</strong> for Direct Debit mandate processing (if enabled by charity)</li>
<li><strong>Stripe</strong> for card payment processing (if enabled by charity)</li>
<li><strong>OpenAI</strong> for AI-powered features (amount suggestions, reminder copy). No donor PII is sent to OpenAI only anonymised context.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">11. Contact</h2>
<p>Data protection enquiries: <a href="mailto:privacy@quikcue.com" className="text-promise-blue hover:underline">privacy@quikcue.com</a></p>
<p className="mt-2">You have the right to lodge a complaint with the ICO: <a href="https://ico.org.uk" className="text-promise-blue hover:underline" target="_blank" rel="noopener noreferrer">ico.org.uk</a></p>
</section>
</div>
</div>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,78 @@
import Link from "next/link"
import { Nav, Footer } from "../for/_components"
export default function TermsPage() {
return (
<div className="min-h-screen bg-white">
<Nav />
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
<h1 className="text-4xl font-black text-gray-900 tracking-tight">Terms of Service</h1>
<p className="text-sm text-gray-400 mt-2">Last updated: March 2026</p>
<div className="mt-10 prose prose-gray prose-sm max-w-none space-y-8">
<section>
<h2 className="text-xl font-black text-gray-900">1. Service Description</h2>
<p>Pledge Now, Pay Later (&quot;PNPL&quot;, &quot;the Service&quot;) is a free pledge collection tool operated by QuikCue Ltd (&quot;we&quot;, &quot;us&quot;), registered in England and Wales. The Service helps UK charities capture donation pledges at events and follow up to collect payments.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">2. Free Forever</h2>
<p>The core Service is free with no usage limits, feature gates, or mandatory upgrades. We generate revenue through an optional fractional Head of Technology consultancy service, which is entirely separate and never required.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">3. Your Responsibilities</h2>
<ul className="list-disc pl-5 space-y-1">
<li>You must be authorised to act on behalf of your organisation.</li>
<li>You must ensure your use of the Service complies with UK charity law, GDPR, and PECR.</li>
<li>You must not use the Service for fraudulent purposes.</li>
<li>You are responsible for the accuracy of bank details and donor data you enter.</li>
<li>You must obtain valid consent before sending communications to donors via the Service.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">4. Donor Data</h2>
<p>You own all donor data collected through the Service. We process it on your behalf as a data processor under GDPR. See our <Link href="/privacy" className="text-promise-blue hover:underline">Privacy Policy</Link> for full details.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">5. Payment Processing</h2>
<p>PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account, GoCardless, or Stripe. We are not liable for payment disputes, chargebacks, or failed transactions.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">6. WhatsApp Integration</h2>
<p>WhatsApp messaging is powered by your own WhatsApp account connected via the WAHA service. You are responsible for complying with WhatsApp&apos;s Terms of Service. We recommend using a dedicated phone number for your organisation.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">7. Gift Aid</h2>
<p>The Service collects Gift Aid declarations using HMRC model wording. You are responsible for verifying eligibility and submitting claims to HMRC. We provide the data the statutory responsibility remains with your charity.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">8. Limitation of Liability</h2>
<p>The Service is provided &quot;as is&quot;. To the fullest extent permitted by law, we shall not be liable for any indirect, incidental, or consequential damages arising from your use of the Service, including lost pledges, failed reminders, or data loss.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">9. Account Termination</h2>
<p>You may delete your account at any time. Upon deletion, all your data (including donor records, pledges, and analytics) will be permanently removed within 30 days. We may suspend accounts that violate these terms.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">10. Changes to Terms</h2>
<p>We may update these terms from time to time. We will notify registered users of material changes via email. Continued use of the Service after changes constitutes acceptance.</p>
</section>
<section>
<h2 className="text-xl font-black text-gray-900">11. Contact</h2>
<p>Questions about these terms? Email <a href="mailto:hello@quikcue.com" className="text-promise-blue hover:underline">hello@quikcue.com</a></p>
</section>
</div>
</div>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,96 @@
/**
* Activity log utility — records staff actions for audit trail (H10)
*
* Uses Prisma's AnalyticsEvent table as a lightweight activity store.
* Each entry records WHO did WHAT to WHICH entity and WHEN.
*/
import prisma from "@/lib/prisma"
import type { ActivityAction } from "@/lib/ai"
interface LogActivityInput {
action: ActivityAction
entityType: string
entityId?: string
orgId: string
userId?: string
userName?: string
metadata?: Record<string, unknown>
}
/**
* Log an activity entry. Non-blocking — errors are silently swallowed.
*/
export async function logActivity(input: LogActivityInput): Promise<void> {
if (!prisma) return
try {
await prisma.analyticsEvent.create({
data: {
eventType: `activity.${input.action}`,
eventId: input.entityType === "event" ? input.entityId : undefined,
pledgeId: input.entityType === "pledge" ? input.entityId : undefined,
metadata: {
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
orgId: input.orgId,
userId: input.userId,
userName: input.userName,
...input.metadata,
},
},
})
} catch (err) {
// Activity logging should never break the main flow
console.error("[activity-log] Failed to write:", err)
}
}
/**
* Query activity log for an org. Returns most recent entries.
*/
export async function getActivityLog(
orgId: string,
options: { limit?: number; entityType?: string; entityId?: string } = {}
) {
if (!prisma) return []
const { limit = 50, entityType, entityId } = options
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
eventType: { startsWith: "activity." },
}
if (entityId) {
where.OR = [
{ eventId: entityId },
{ pledgeId: entityId },
]
}
if (entityType) {
where.eventType = { startsWith: `activity.${entityType}.` }
}
const entries = await prisma.analyticsEvent.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
})
return entries.map(e => {
const meta = e.metadata as Record<string, unknown> || {}
return {
id: e.id,
action: meta.action as string,
entityType: meta.entityType as string,
entityId: meta.entityId as string,
userId: meta.userId as string,
userName: meta.userName as string,
metadata: meta,
timestamp: e.createdAt,
}
})
}

View File

@@ -184,3 +184,789 @@ export async function generateEventDescription(prompt: string): Promise<string>
{ role: "user", content: prompt },
], 60)
}
/**
* AI-10: Classify natural language WhatsApp messages from donors
* Maps free-text messages to known intents (PAID, HELP, CANCEL, STATUS)
*/
export async function classifyDonorMessage(
message: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_fromPhone: string
): Promise<{ action: string; confidence: number; extractedInfo?: string } | null> {
if (!OPENAI_KEY) return null
const result = await chat([
{
role: "system",
content: `You classify incoming WhatsApp messages from charity donors. Return ONLY valid JSON.
Possible actions: PAID, HELP, CANCEL, STATUS, UNKNOWN.
- PAID: donor says they've already paid/transferred/sent the money
- HELP: donor asks for bank details, reference, or needs assistance
- CANCEL: donor wants to cancel, stop messages, opt out, or withdraw
- STATUS: donor asks about their pledge status, how much they owe, etc.
- UNKNOWN: anything else (greetings, spam, unrelated)
Return: {"action":"ACTION","confidence":0.0-1.0,"extractedInfo":"any relevant detail"}`,
},
{ role: "user", content: `Message: "${message}"` },
], 60)
try {
const parsed = JSON.parse(result)
if (parsed.action === "UNKNOWN") return null
return { action: parsed.action, confidence: parsed.confidence || 0, extractedInfo: parsed.extractedInfo }
} catch {
return null
}
}
/**
* AI-3: Auto-detect CSV column mapping for bank statements
* Reads headers + sample rows and identifies date/description/amount columns
*/
export async function autoMapBankColumns(
headers: string[],
sampleRows: string[][]
): Promise<{
dateCol: string
descriptionCol: string
amountCol?: string
creditCol?: string
referenceCol?: string
confidence: number
} | null> {
if (!OPENAI_KEY) return null
const result = await chat([
{
role: "system",
content: `You map UK bank CSV columns. The CSV is a bank statement export. Return ONLY valid JSON with these fields:
{"dateCol":"column name for date","descriptionCol":"column name for description/details","creditCol":"column name for credit/paid in amount (optional)","amountCol":"column name for amount if no separate credit column","referenceCol":"column name for reference if it exists","confidence":0.0-1.0}
Common UK bank formats: Barclays (Date, Type, Description, Money In, Money Out, Balance), HSBC (Date, Description, Amount), Lloyds (Date, Type, Description, Paid In, Paid Out, Balance), NatWest (Date, Type, Description, Value, Balance), Monzo (Date, Description, Amount, Category), Starling (Date, Counter Party, Reference, Type, Amount, Balance).
Only return columns that exist in the headers. If amount can be negative (credits are positive), use amountCol. If there's a separate credit column, use creditCol.`,
},
{
role: "user",
content: `Headers: ${JSON.stringify(headers)}\nFirst 3 rows: ${JSON.stringify(sampleRows.slice(0, 3))}`,
},
], 120)
try {
const parsed = JSON.parse(result)
if (!parsed.dateCol || !parsed.descriptionCol) return null
return parsed
} catch {
return null
}
}
/**
* AI-9: Parse a natural language event description into structured event data
*/
export async function parseEventFromPrompt(prompt: string): Promise<{
name: string
description: string
location?: string
goalAmount?: number
zakatEligible?: boolean
tableCount?: number
} | null> {
if (!OPENAI_KEY) return null
const result = await chat([
{
role: "system",
content: `You extract structured event data from a natural language description. Return ONLY valid JSON:
{"name":"Event Name","description":"2-sentence description","location":"venue if mentioned","goalAmount":amount_in_pence_or_null,"zakatEligible":true_if_islamic_context,"tableCount":number_of_tables_if_mentioned}
UK charity context. goalAmount should be in pence (e.g. £50k = 5000000). Only include fields you're confident about.`,
},
{ role: "user", content: prompt },
], 150)
try {
return JSON.parse(result)
} catch {
return null
}
}
/**
* AI-7: Generate impact-specific reminder copy
*/
export async function generateImpactMessage(context: {
amount: string
eventName: string
reference: string
donorName?: string
impactUnit?: string // e.g. "£10 = 1 meal"
goalProgress?: number // 0-100 percentage
}): Promise<string> {
if (!OPENAI_KEY) {
return `Your £${context.amount} pledge to ${context.eventName} makes a real difference. Ref: ${context.reference}`
}
return chat([
{
role: "system",
content: `You write one short, warm, specific impact statement for a UK charity payment reminder. Max 2 sentences. Include the reference number. Be specific about what the money does — don't be vague. UK English. No emojis.`,
},
{
role: "user",
content: `Donor: ${context.donorName || "there"}. Amount: £${context.amount}. Event: ${context.eventName}. Ref: ${context.reference}.${context.impactUnit ? ` Impact: ${context.impactUnit}.` : ""}${context.goalProgress ? ` Campaign is ${context.goalProgress}% funded.` : ""} Generate the message.`,
},
], 80)
}
/**
* AI-4: Generate daily digest summary for org admin (WhatsApp format)
*/
export async function generateDailyDigest(stats: {
orgName: string
eventName?: string
newPledges: number
newPledgeAmount: number
paymentsConfirmed: number
paymentsAmount: number
overduePledges: Array<{ name: string; amount: number; days: number }>
totalCollected: number
totalPledged: number
topSource?: { label: string; rate: number }
}): Promise<string> {
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
if (!OPENAI_KEY) {
// Smart fallback without AI
let msg = `🤲 *Morning Update — ${stats.eventName || stats.orgName}*\n\n`
if (stats.newPledges > 0) msg += `*Yesterday:* ${stats.newPledges} new pledges (£${(stats.newPledgeAmount / 100).toFixed(0)})\n`
if (stats.paymentsConfirmed > 0) msg += `*Payments:* ${stats.paymentsConfirmed} confirmed (£${(stats.paymentsAmount / 100).toFixed(0)})\n`
if (stats.overduePledges.length > 0) {
msg += `*Needs attention:* ${stats.overduePledges.map(o => `${o.name} — £${(o.amount / 100).toFixed(0)} (${o.days}d)`).join(", ")}\n`
}
msg += `\n*Collection:* £${(stats.totalCollected / 100).toFixed(0)} of £${(stats.totalPledged / 100).toFixed(0)} (${collectionRate}%)`
if (stats.topSource) msg += `\n*Top source:* ${stats.topSource.label} (${stats.topSource.rate}% conversion)`
msg += `\n\nReply *REPORT* for full details.`
return msg
}
return chat([
{
role: "system",
content: `You write a concise morning WhatsApp summary for a UK charity fundraising manager. Use WhatsApp formatting (*bold*, _italic_). Include 🤲 at the start. Keep it under 200 words. Be specific with numbers. End with "Reply REPORT for full details."`,
},
{
role: "user",
content: `Org: ${stats.orgName}. Event: ${stats.eventName || "all campaigns"}.
Yesterday: ${stats.newPledges} new pledges totaling £${(stats.newPledgeAmount / 100).toFixed(0)}, ${stats.paymentsConfirmed} payments confirmed totaling £${(stats.paymentsAmount / 100).toFixed(0)}.
Overdue: ${stats.overduePledges.length > 0 ? stats.overduePledges.map(o => `${o.name} £${(o.amount / 100).toFixed(0)} ${o.days} days`).join(", ") : "none"}.
Overall: £${(stats.totalCollected / 100).toFixed(0)} of £${(stats.totalPledged / 100).toFixed(0)} (${collectionRate}%).
${stats.topSource ? `Top source: ${stats.topSource.label} at ${stats.topSource.rate}% conversion.` : ""}
Generate the WhatsApp message.`,
},
], 200)
}
/**
* AI-8: Generate a manual nudge message for staff to send
*/
export async function generateNudgeMessage(context: {
donorName?: string
amount: string
eventName: string
reference: string
daysSincePledge: number
previousReminders: number
clickedIPaid: boolean
}): Promise<string> {
const name = context.donorName?.split(" ")[0] || "there"
if (!OPENAI_KEY) {
if (context.clickedIPaid) {
return `Hi ${name}, you mentioned you'd paid your £${context.amount} pledge to ${context.eventName} — we haven't been able to match it yet. Could you double-check the reference was ${context.reference}? Thank you!`
}
return `Hi ${name}, just checking in about your £${context.amount} pledge to ${context.eventName}. Your ref is ${context.reference}. No rush — just wanted to make sure you have everything you need.`
}
return chat([
{
role: "system",
content: `You write a short, warm, personal WhatsApp message from a charity staff member to a donor about their pledge. Max 3 sentences. UK English. Be human, not corporate. ${context.previousReminders > 2 ? "Be firm but kind — this is a late follow-up." : "Be gentle — this is early in the process."}${context.clickedIPaid ? " The donor clicked 'I\\'ve paid' but the payment hasn't been matched yet — so gently ask them to check the reference." : ""}`,
},
{
role: "user",
content: `Donor: ${name}. Amount: £${context.amount}. Event: ${context.eventName}. Ref: ${context.reference}. Days since pledge: ${context.daysSincePledge}. Previous reminders: ${context.previousReminders}. Clicked I've paid: ${context.clickedIPaid}. Generate the message.`,
},
], 100)
}
/**
* AI-11: Detect anomalies in pledge data
*/
export async function detectAnomalies(data: {
recentPledges: Array<{ email: string; phone?: string; amount: number; eventId: string; createdAt: string }>
iPaidButNoMatch: Array<{ name: string; amount: number; days: number }>
highValueThreshold: number
}): Promise<Array<{ type: string; severity: "low" | "medium" | "high"; description: string }>> {
const anomalies: Array<{ type: string; severity: "low" | "medium" | "high"; description: string }> = []
// Rule-based checks (no AI needed)
// Duplicate email check
const emailCounts = new Map<string, number>()
for (const p of data.recentPledges) {
if (p.email) emailCounts.set(p.email, (emailCounts.get(p.email) || 0) + 1)
}
emailCounts.forEach((count, email) => {
if (count >= 5) {
anomalies.push({ type: "duplicate_email", severity: "medium", description: `${email} has ${count} pledges — possible duplicate or testing` })
}
})
// Unusually high amounts
for (const p of data.recentPledges) {
if (p.amount > data.highValueThreshold) {
anomalies.push({ type: "high_value", severity: "low", description: `£${(p.amount / 100).toFixed(0)} pledge from ${p.email} — verify this is intentional` })
}
}
// I've paid but no match for 30+ days
for (const p of data.iPaidButNoMatch) {
if (p.days >= 30) {
anomalies.push({ type: "stuck_payment", severity: "high", description: `${p.name} clicked "I've paid" ${p.days} days ago (£${(p.amount / 100).toFixed(0)}) — no bank match found` })
}
}
// Burst detection (5+ pledges in 1 minute = suspicious)
const sorted = [...data.recentPledges].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
for (let i = 0; i < sorted.length - 4; i++) {
const window = new Date(sorted[i + 4].createdAt).getTime() - new Date(sorted[i].createdAt).getTime()
if (window < 60000) {
anomalies.push({ type: "burst", severity: "high", description: `5 pledges in under 1 minute — possible bot/abuse` })
break
}
}
return anomalies
}
// ── AI-6: Smart Reminder Timing ──────────────────────────────────────────────
interface ReminderTimingInput {
donorName?: string
dueDate?: string // ISO string or undefined
rail: string // "bank_transfer" | "card" | "direct_debit"
eventName: string
pledgeDate: string // ISO string
amount: number // pence
reminderStep: number // 1-4
}
interface ReminderTimingOutput {
suggestedSendAt: string // ISO datetime
reasoning: string
delayHours: number // hours from now
}
export async function optimiseReminderTiming(
input: ReminderTimingInput
): Promise<ReminderTimingOutput> {
// Default schedule: step 1 = T+2d, step 2 = T+7d, step 3 = T+14d, step 4 = T+21d
const defaultDelayDays: Record<number, number> = { 1: 2, 2: 7, 3: 14, 4: 21 }
const baseDays = defaultDelayDays[input.reminderStep] || 7
const now = new Date()
const pledgeDate = new Date(input.pledgeDate)
// Smart heuristics (work without AI)
let adjustedDate = new Date(pledgeDate.getTime() + baseDays * 86400000)
// Rule 1: If due date is set, anchor reminders relative to it
if (input.dueDate) {
const due = new Date(input.dueDate)
const daysUntilDue = Math.floor((due.getTime() - now.getTime()) / 86400000)
if (input.reminderStep === 1 && daysUntilDue > 3) {
// First reminder: 3 days before due date
adjustedDate = new Date(due.getTime() - 3 * 86400000)
} else if (input.reminderStep === 2 && daysUntilDue > 0) {
// Second: on due date morning
adjustedDate = new Date(due.getTime())
} else if (input.reminderStep >= 3) {
// After due: 3 days and 7 days past
const overdueDays = input.reminderStep === 3 ? 3 : 7
adjustedDate = new Date(due.getTime() + overdueDays * 86400000)
}
}
// Rule 2: Don't send on Friday evening (6pm-midnight) — common family/community time
const dayOfWeek = adjustedDate.getDay() // 0=Sun
const hour = adjustedDate.getHours()
if (dayOfWeek === 5 && hour >= 18) {
// Push to Saturday morning
adjustedDate.setDate(adjustedDate.getDate() + 1)
adjustedDate.setHours(9, 0, 0, 0)
}
// Rule 3: Don't send before 9am or after 8pm
if (adjustedDate.getHours() < 9) adjustedDate.setHours(9, 0, 0, 0)
if (adjustedDate.getHours() >= 20) {
adjustedDate.setDate(adjustedDate.getDate() + 1)
adjustedDate.setHours(9, 0, 0, 0)
}
// Rule 4: Bank transfer donors get slightly longer (they need to log in to banking app)
if (input.rail === "bank_transfer" && input.reminderStep === 1) {
adjustedDate.setDate(adjustedDate.getDate() + 1)
}
// Rule 5: Don't send if adjusted date is in the past
if (adjustedDate.getTime() < now.getTime()) {
adjustedDate = new Date(now.getTime() + 2 * 3600000) // 2 hours from now
}
// Rule 6: High-value pledges (>£1000) get gentler spacing
if (input.amount >= 100000 && input.reminderStep >= 2) {
adjustedDate.setDate(adjustedDate.getDate() + 2)
}
const delayHours = Math.max(1, Math.round((adjustedDate.getTime() - now.getTime()) / 3600000))
// Try AI for more nuanced timing
const aiResult = await chat([
{
role: "system",
content: `You optimise charity pledge reminder timing. Given context, suggest the ideal send time (ISO 8601) and explain why in 1 sentence. Consider: payment rail delays, cultural sensitivity (Muslim community events — don't send during Jummah/Friday prayer time 12:30-14:00), payday patterns (25th-28th of month), and donor psychology. Return JSON: {"sendAt":"ISO","reasoning":"..."}`
},
{
role: "user",
content: JSON.stringify({
donorName: input.donorName,
dueDate: input.dueDate,
rail: input.rail,
eventName: input.eventName,
pledgeDate: input.pledgeDate,
amountGBP: (input.amount / 100).toFixed(0),
reminderStep: input.reminderStep,
defaultSendAt: adjustedDate.toISOString(),
currentTime: now.toISOString(),
})
}
], 150)
if (aiResult) {
try {
const parsed = JSON.parse(aiResult)
if (parsed.sendAt) {
const aiDate = new Date(parsed.sendAt)
// Sanity: AI date must be within 30 days and in the future
if (aiDate.getTime() > now.getTime() && aiDate.getTime() < now.getTime() + 30 * 86400000) {
return {
suggestedSendAt: aiDate.toISOString(),
reasoning: parsed.reasoning || "AI-optimised timing",
delayHours: Math.round((aiDate.getTime() - now.getTime()) / 3600000),
}
}
}
} catch { /* fall through to heuristic */ }
}
return {
suggestedSendAt: adjustedDate.toISOString(),
reasoning: input.dueDate
? `Anchored to due date (${input.dueDate.slice(0, 10)}), step ${input.reminderStep}`
: `Default schedule: ${baseDays} days after pledge, adjusted for time-of-day rules`,
delayHours,
}
}
// ── H1: Duplicate Donor Detection ────────────────────────────────────────────
interface DuplicateCandidate {
id: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
}
interface DuplicateGroup {
primaryId: string
duplicateIds: string[]
matchType: "email" | "phone" | "name_fuzzy"
confidence: number
}
export function detectDuplicateDonors(donors: DuplicateCandidate[]): DuplicateGroup[] {
const groups: DuplicateGroup[] = []
const seen = new Set<string>()
// Pass 1: Exact email match
const byEmail = new Map<string, DuplicateCandidate[]>()
for (const d of donors) {
if (d.donorEmail) {
const key = d.donorEmail.toLowerCase().trim()
if (!byEmail.has(key)) byEmail.set(key, [])
byEmail.get(key)!.push(d)
}
}
byEmail.forEach((matches) => {
if (matches.length > 1) {
const primary = matches[0]
const dupes = matches.slice(1).map(m => m.id)
groups.push({ primaryId: primary.id, duplicateIds: dupes, matchType: "email", confidence: 1.0 })
dupes.forEach(id => seen.add(id))
seen.add(primary.id)
}
})
// Pass 2: Phone normalization match
const normalizePhone = (p: string) => {
let clean = p.replace(/[\s\-\(\)\+]/g, "")
if (clean.startsWith("44")) clean = "0" + clean.slice(2)
if (clean.startsWith("0044")) clean = "0" + clean.slice(4)
return clean
}
const byPhone = new Map<string, DuplicateCandidate[]>()
for (const d of donors) {
if (d.donorPhone && !seen.has(d.id)) {
const key = normalizePhone(d.donorPhone)
if (key.length >= 10) {
if (!byPhone.has(key)) byPhone.set(key, [])
byPhone.get(key)!.push(d)
}
}
}
byPhone.forEach((matches) => {
if (matches.length > 1) {
const primary = matches[0]
const dupes = matches.slice(1).map(m => m.id)
groups.push({ primaryId: primary.id, duplicateIds: dupes, matchType: "phone", confidence: 0.95 })
dupes.forEach(id => seen.add(id))
}
})
// Pass 3: Fuzzy name match (Levenshtein-based)
const unseen = donors.filter(d => !seen.has(d.id) && d.donorName)
for (let i = 0; i < unseen.length; i++) {
for (let j = i + 1; j < unseen.length; j++) {
const a = unseen[i].donorName!.toLowerCase().trim()
const b = unseen[j].donorName!.toLowerCase().trim()
if (a === b || (a.length > 3 && b.length > 3 && jaroWinkler(a, b) >= 0.92)) {
groups.push({
primaryId: unseen[i].id,
duplicateIds: [unseen[j].id],
matchType: "name_fuzzy",
confidence: a === b ? 0.9 : 0.75,
})
seen.add(unseen[j].id)
}
}
}
return groups
}
/** Jaro-Winkler similarity (0-1, higher = more similar) */
function jaroWinkler(s1: string, s2: string): number {
if (s1 === s2) return 1
const maxDist = Math.floor(Math.max(s1.length, s2.length) / 2) - 1
if (maxDist < 0) return 0
const s1Matches = new Array(s1.length).fill(false)
const s2Matches = new Array(s2.length).fill(false)
let matches = 0
let transpositions = 0
for (let i = 0; i < s1.length; i++) {
const start = Math.max(0, i - maxDist)
const end = Math.min(i + maxDist + 1, s2.length)
for (let j = start; j < end; j++) {
if (s2Matches[j] || s1[i] !== s2[j]) continue
s1Matches[i] = true
s2Matches[j] = true
matches++
break
}
}
if (matches === 0) return 0
let k = 0
for (let i = 0; i < s1.length; i++) {
if (!s1Matches[i]) continue
while (!s2Matches[k]) k++
if (s1[i] !== s2[k]) transpositions++
k++
}
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3
// Winkler bonus for common prefix (up to 4 chars)
let prefix = 0
for (let i = 0; i < Math.min(4, s1.length, s2.length); i++) {
if (s1[i] === s2[i]) prefix++
else break
}
return jaro + prefix * 0.1 * (1 - jaro)
}
// ── H5: Bank CSV Format Presets ──────────────────────────────────────────────
export interface BankPreset {
bankName: string
dateCol: string
descriptionCol: string
creditCol?: string
debitCol?: string
amountCol?: string
referenceCol?: string
dateFormat: string
notes?: string
}
export const BANK_PRESETS: Record<string, BankPreset> = {
barclays: {
bankName: "Barclays",
dateCol: "Date",
descriptionCol: "Memo",
creditCol: "Amount",
dateFormat: "DD/MM/YYYY",
notes: "Credits are positive amounts, debits are negative",
},
hsbc: {
bankName: "HSBC",
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Credit Amount",
debitCol: "Debit Amount",
dateFormat: "DD MMM YYYY",
},
lloyds: {
bankName: "Lloyds",
dateCol: "Transaction Date",
descriptionCol: "Transaction Description",
creditCol: "Credit Amount",
debitCol: "Debit Amount",
dateFormat: "DD/MM/YYYY",
},
natwest: {
bankName: "NatWest / RBS",
dateCol: "Date",
descriptionCol: "Description",
amountCol: "Value",
dateFormat: "DD/MM/YYYY",
notes: "Single 'Value' column — positive = credit, negative = debit",
},
monzo: {
bankName: "Monzo",
dateCol: "Date",
descriptionCol: "Name",
amountCol: "Amount",
referenceCol: "Notes and #tags",
dateFormat: "DD/MM/YYYY",
notes: "Monzo exports include a 'Notes and #tags' column that sometimes has the reference",
},
starling: {
bankName: "Starling",
dateCol: "Date",
descriptionCol: "Reference",
amountCol: "Amount (GBP)",
dateFormat: "DD/MM/YYYY",
notes: "Amount is signed — positive = credit",
},
santander: {
bankName: "Santander",
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Money in",
debitCol: "Money out",
dateFormat: "DD/MM/YYYY",
},
nationwide: {
bankName: "Nationwide",
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Paid in",
debitCol: "Paid out",
dateFormat: "DD MMM YYYY",
},
cooperative: {
bankName: "Co-operative Bank",
dateCol: "Date",
descriptionCol: "Details",
creditCol: "Money In",
debitCol: "Money Out",
dateFormat: "DD/MM/YYYY",
},
tide: {
bankName: "Tide",
dateCol: "Transaction Date",
descriptionCol: "Transaction Information",
amountCol: "Amount",
referenceCol: "Reference",
dateFormat: "YYYY-MM-DD",
},
}
export function matchBankPreset(headers: string[]): BankPreset | null {
const lower = headers.map(h => h.toLowerCase().trim())
for (const preset of Object.values(BANK_PRESETS)) {
const requiredCols = [preset.dateCol, preset.descriptionCol] // Must match at least date + description
const matched = requiredCols.filter(col => lower.includes(col.toLowerCase()))
if (matched.length === requiredCols.length) {
// Check at least one amount column too
const amountCols = [preset.creditCol, preset.amountCol].filter(Boolean) as string[]
if (amountCols.some(col => lower.includes(col.toLowerCase()))) {
return preset
}
}
}
return null
}
// ── H16: Partial Payment Matching ────────────────────────────────────────────
interface PartialMatchInput {
bankAmount: number // pence
bankDescription: string
bankDate: string
pledges: Array<{
id: string
reference: string
amountPence: number
donorName: string | null
status: string
paidAmountPence: number // already paid (for instalment tracking)
installmentNumber: number | null
installmentTotal: number | null
}>
}
interface PartialMatchResult {
pledgeId: string
pledgeReference: string
matchType: "exact" | "partial_payment" | "overpayment" | "instalment"
expectedAmount: number
actualAmount: number
difference: number // positive = overpaid, negative = underpaid
confidence: number
note: string
}
export function matchPartialPayments(input: PartialMatchInput): PartialMatchResult[] {
const results: PartialMatchResult[] = []
const descLower = input.bankDescription.toLowerCase()
for (const pledge of input.pledges) {
if (pledge.status === "cancelled" || pledge.status === "paid") continue
const refLower = pledge.reference.toLowerCase()
const descContainsRef = descLower.includes(refLower) || descLower.includes(refLower.replace(/[-]/g, ""))
// Skip if no reference match in description
if (!descContainsRef) continue
const diff = input.bankAmount - pledge.amountPence
// Exact match
if (Math.abs(diff) <= 100) { // within £1 tolerance
results.push({
pledgeId: pledge.id,
pledgeReference: pledge.reference,
matchType: "exact",
expectedAmount: pledge.amountPence,
actualAmount: input.bankAmount,
difference: diff,
confidence: 0.99,
note: "Exact match (within £1 tolerance)",
})
continue
}
// Instalment: amount is close to pledgeAmount / installmentTotal
if (pledge.installmentTotal && pledge.installmentTotal > 1) {
const instalmentAmount = Math.round(pledge.amountPence / pledge.installmentTotal)
if (Math.abs(input.bankAmount - instalmentAmount) <= 100) {
results.push({
pledgeId: pledge.id,
pledgeReference: pledge.reference,
matchType: "instalment",
expectedAmount: instalmentAmount,
actualAmount: input.bankAmount,
difference: input.bankAmount - instalmentAmount,
confidence: 0.90,
note: `Matches instalment payment (1/${pledge.installmentTotal} of £${(pledge.amountPence / 100).toFixed(0)})`,
})
continue
}
}
// Partial payment: amount is less than pledge but reference matches
if (input.bankAmount < pledge.amountPence && input.bankAmount > 0) {
const pctPaid = Math.round((input.bankAmount / pledge.amountPence) * 100)
results.push({
pledgeId: pledge.id,
pledgeReference: pledge.reference,
matchType: "partial_payment",
expectedAmount: pledge.amountPence,
actualAmount: input.bankAmount,
difference: diff,
confidence: 0.80,
note: `Partial payment: £${(input.bankAmount / 100).toFixed(2)} of £${(pledge.amountPence / 100).toFixed(0)} (${pctPaid}%). Remaining: £${(Math.abs(diff) / 100).toFixed(2)}`,
})
continue
}
// Overpayment
if (input.bankAmount > pledge.amountPence) {
results.push({
pledgeId: pledge.id,
pledgeReference: pledge.reference,
matchType: "overpayment",
expectedAmount: pledge.amountPence,
actualAmount: input.bankAmount,
difference: diff,
confidence: 0.85,
note: `Overpayment: £${(input.bankAmount / 100).toFixed(2)} vs expected £${(pledge.amountPence / 100).toFixed(0)}. Excess: £${(diff / 100).toFixed(2)}`,
})
}
}
return results.sort((a, b) => b.confidence - a.confidence)
}
// ── H10: Activity Log Types ──────────────────────────────────────────────────
export type ActivityAction =
| "pledge.created"
| "pledge.updated"
| "pledge.cancelled"
| "pledge.marked_paid"
| "pledge.marked_overdue"
| "reminder.sent"
| "reminder.skipped"
| "import.created"
| "import.matched"
| "event.created"
| "event.updated"
| "event.cloned"
| "qr.created"
| "qr.deleted"
| "whatsapp.connected"
| "whatsapp.disconnected"
| "settings.updated"
| "account.deleted"
| "user.login"
export interface ActivityEntry {
action: ActivityAction
entityType: "pledge" | "event" | "import" | "reminder" | "qr" | "settings" | "account" | "user"
entityId?: string
userId?: string
userName?: string
metadata?: Record<string, unknown>
timestamp: Date
}

View File

@@ -0,0 +1,36 @@
/**
* Simple in-memory rate limiter (no Redis needed for MVP)
* Tracks requests per IP per window
*/
const store = new Map<string, { count: number; resetAt: number }>()
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now()
store.forEach((val, key) => {
if (val.resetAt < now) store.delete(key)
})
}, 60000) // every minute
export function rateLimit(
key: string,
limit: number,
windowMs: number
): { allowed: boolean; remaining: number; resetAt: number } {
const now = Date.now()
const entry = store.get(key)
if (!entry || entry.resetAt < now) {
// New window
store.set(key, { count: 1, resetAt: now + windowMs })
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs }
}
if (entry.count >= limit) {
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
}
entry.count++
return { allowed: true, remaining: limit - entry.count, resetAt: entry.resetAt }
}

View File

@@ -88,6 +88,11 @@ export const importBankStatementSchema = z.object({
})
export const updatePledgeStatusSchema = z.object({
status: z.enum(['new', 'initiated', 'paid', 'overdue', 'cancelled']),
status: z.enum(['new', 'initiated', 'paid', 'overdue', 'cancelled']).optional(),
notes: z.string().max(1000).optional(),
amountPence: z.number().int().min(100).max(100000000).optional(),
donorName: z.string().max(200).optional(),
donorEmail: z.string().max(200).optional(),
donorPhone: z.string().max(20).optional(),
rail: z.enum(['bank', 'gocardless', 'card']).optional(),
})