I.What changed, and why
Three sessions of research have pushed the roadmap off its original course in one specific place — donation processing. The rest of the plan still holds. This revision pulls Phase 3 apart, threads in a new background-data-integrity track, and trims Phase 6 to match.
The three findings driving the revision
- A real 501(c)(3) receipt pipeline is more work than it looks. Building per-gift receipts, year-end aggregation, and recurring-payment lifecycle on top of Stripe‑direct adds at least a quarter of bespoke compliance code. No clean open-source library does just this piece — the candidates that exist are bundled inside full CRMs like CiviCRM.
- James Brysmart's
nonprofit-crm-stackis a near-perfect reference architecture for the future state. The pattern — webhook → normalize → match → either land in the CRM or stage with diagnostics — is correct and worth adopting wholesale, regardless of whether CBF ever uses his code. - Donorbox replaces the entire "Stripe-direct + custom-form + custom-receipt" scope of the old Phase 3 with a single SaaS dependency that still uses Stripe underneath. CBF keeps the Stripe relationship intact (donations create real Stripe objects on CBF's account) and gets receipts, recurring lifecycle, and dunning for $17/month + 1.75% platform fee.
The original "no recurring SaaS costs" north star bends here. The trade is deliberate: 1.75% + $17/mo buys back a quarter of engineering work, the entire 501(c)(3) compliance surface, and a clean Stripe-ID-based exit path if CBF ever wants to take it back in-house.
II.Current state, May 2026
| Layer | Technology | Status |
|---|---|---|
| Public website | Next.js 15, App Router, TypeScript on Cloudflare Workers (OpenNext) | Live |
| Content management | Tina CMS, Git-backed via Tina Cloud | Live |
| DNS | Cloudflare — cloudbase.foundation | Live |
| CRM (dev) | Twenty CRM, Docker, DigitalOcean $12/mo | Running |
| CRM (prod) | crm.cloudbase.foundation | Not provisioned |
| Donation processing | GiveLively, with manual CSV sync into Twenty | In use, planned for replacement |
| Project data | src/data/projects.json (static) | Phase 1; moves to Twenty in Phase 2 |
| Internal kanban | kan (self-hosted, AGPL), Docker on the Unraid VM (LAN); own Postgres + MinIO, isolated from Twenty | Running (dev) |
III.The architecture shift
The diagram below contrasts the donation/CRM data flow today against the target shape after Phases 3 and 3.5. The change is not the donation platform per se — it is the introduction of a small intake service between any donation source and Twenty, owning matching, idempotency, and a staging table for records that can’t be auto-linked.
library)):::lib IS -- "high-confidence" --> T2[(Twenty CRM)]:::crm IS -- "low / unknown" --> SR[[Staging table]]:::stage SR -. "human review" .-> T2 T2 -- "Donor.created" --> LM[ListMonk]:::saas end classDef donor fill:#ecd9a4,stroke:#9a7826,color:#15171c classDef saas fill:#fbf7ee,stroke:#0c2540,stroke-width:1.5px,color:#0c2540 classDef human fill:#ede5d2,stroke:#6c7585,color:#3a4250,stroke-dasharray:3 2 classDef crm fill:#0c2540,stroke:#c8a14a,stroke-width:2px,color:#f6f1e7 classDef service fill:#1f3b5e,stroke:#c8a14a,stroke-width:2px,color:#f6f1e7 classDef lib fill:#c8a14a,stroke:#9a7826,color:#0c2540 classDef stage fill:#f6f1e7,stroke:#a5402b,stroke-width:1.5px,color:#a5402b
Three execution surfaces, three roles
The revised plan deliberately splits new work across three execution surfaces: a sidecar Node service, a Twenty App, and a small Cloudflare Worker footprint. Each has a distinct shape, and the choice for each piece is not interchangeable.
Sidecar service
cbf-intake-service
- Donorbox webhook receiver (signature verification, event-ID idempotency)
- Provider-payload → canonical-payload normalization
- Calls
@nonprofit-crm/matchingper record - Owns
staging_recordtable for low-confidence or incomplete records - Background workers: retry, periodic dedupe sweep
- Review queue UI over its own staging data
Anything with a public webhook URL, its own database, or background jobs.
Twenty App
cbf-data-model
- Custom-object definitions:
RecurringAgreement,Partnerfield,receiptStatus - In-Twenty logic functions: rollup recompute on Donation.created/updated
- Twenty Workflows: ListMonk sync, partner notifications
- Can optionally import
@nonprofit-crm/matchingfor records entered through the Twenty UI (not just webhook intake)
Anything that extends the Twenty UI, data model, or workflow engine — portable to any vanilla Twenty instance.
Cloudflare Workers
edge handlers
- CBF-site → Twenty REST proxy (Phase 2)
- ListMonk unsubscribe webhook → Twenty PATCH (Phase 2)
- Future stateless edge work
Stateless work that benefits from edge latency. Rule of thumb: if it needs a database, it's not a Worker.
The matching library cuts across all three
@nonprofit-crm/matching is a zero-dependency TypeScript package — not a Twenty App, not a service, just a library. It is imported wherever donor matching is needed: by the sidecar (on webhook intake), by the Twenty App (on UI-entered records), by a CLI (for one-shot historical reconciliation), and potentially by James Brysmart's stack as a shared dependency. Framework-agnostic by design.
Why the intake service isn't a Twenty App
A Twenty App extends Twenty's data model, UI surfaces, and workflow engine. It does not expose arbitrary public HTTPS endpoints for external services to POST to, and it does not own state outside Twenty's schema. The intake service needs both: a public webhook URL Donorbox can reach, and a staging_record table that has no business being in the canonical CRM. Coupling its lifecycle to Twenty's would also tie webhook availability to CRM uptime. The right boundary is a separate process — same pattern as James's nonprofit-crm-fundraising-service.
IV.Donor matching — the decision flow
The matching engine ships as a zero-dependency TypeScript package (@nonprofit-crm/matching). The intake service imports it; a CLI imports it for one-shot reconciliation; James’s stack could in principle import it too. Logic is identical across callers; only the Twenty client adapter is injected.
existing Person?} B -- yes --> R1[/Definitive match · 1.0/]:::def B -- no --> C{External ID resolves?
givelivelyDonorId
or stripeCustomerId} C -- "yes, resolves" --> R1 C -- no --> D{First + last + city
match exactly one?} D -- yes --> R2[/High confidence · 0.8/]:::hi D -- no --> E{First + last
match exactly one?} E -- yes --> R3[/Medium · 0.5
requires review/]:::med E -- "ambiguous or none" --> R4[/No match/]:::none R1 --> P[Promote to Twenty
create Donation, link Person]:::ok R2 --> P R3 --> S[Stage with diagnostics]:::stage R4 --> S classDef start fill:#0c2540,stroke:#c8a14a,color:#f6f1e7,stroke-width:2px classDef def fill:#406b3f,stroke:#2d4e2c,color:#f6f1e7 classDef hi fill:#c8a14a,stroke:#9a7826,color:#0c2540 classDef med fill:#ecd9a4,stroke:#9a7826,color:#15171c classDef none fill:#f6f1e7,stroke:#a5402b,color:#a5402b classDef ok fill:#1f3b5e,stroke:#c8a14a,color:#f6f1e7 classDef stage fill:#a5402b,stroke:#7a2d1d,color:#f6f1e7
Background sweep — Scenario C
Per-record matching at intake handles new donations. It cannot find duplicates that accumulate over time through manual entry, varied entry paths, or imperfect historical imports. A periodic sweep job inside the intake service re-scans Twenty’s Person records using the same library and surfaces candidate duplicates in the same review queue. This is the "without manually triggered batches" promise applied to data hygiene, not just intake.
V.Phases — updated
Public site, CRM dev instance, documentation in place
Everything needed for a credible public presence and a working CRM dev environment.
Held without revision. Tracked in detail in roadmap.md Rev. 1. The two outputs that matter here are the live cloudbase.foundation site and the working Twenty dev instance with the CBF data model.
Twenty in production, CBF-site ↔ Twenty REST integration, ListMonk standup
All previously-planned items hold. Two additions land in this phase because they are prerequisites for Phase 3.
apply_data_model.py path.
Held from Rev. 1
- Provision production droplet, deploy Twenty to
crm.cloudbase.foundation - Mirror dev data model on prod, configure backups, lock down signups
- Saved views: active donors, pilots, board, partners, active projects, new submissions kanban
- Import existing GiveLively donor/donation history via CSV (UI path)
- Generate API key for CBF-site integration
- CBF-site → Twenty proxy at
src/app/api/twenty/[...path]/route.ts - Projects page reads from Twenty; contact form posts
Project Submissionto Twenty - ListMonk container added to
docker/compose.yaml,lists-dev.cloudbase.foundationproxied - Twenty workflow: Donor.created → ListMonk subscriber sync
- Cloudflare Worker: ListMonk unsubscribe webhook → Twenty PATCH
- Add
PartnerSELECT field (CBF / KarmaFlights) to Donor + Donation
New in Rev. 2
- Add
RecurringAgreementcustom object (status, provider, externalId, linked Person) - Expand
DonationwithfeeAmount,paymentMethod,intakeSource,externalId - Add lightweight
receiptStatusfield on Donation; defer fullReceiptobject (Donorbox owns receipts)
Adopt Donorbox; stand up cbf-intake-service; deprecate GiveLively
Replaces the original "Stripe-direct + custom donation form + custom Stripe-webhook Worker" approach. Same end state for the donor; far less custom code and full delegation of 501(c)(3) compliance.
Donorbox setup
- Apply for Donorbox nonprofit account; verify 501(c)(3) status
- Connect existing Stripe account (apply for Stripe nonprofit rate first: 2.2% + 30¢)
- Configure receipt templates (per-gift + year-end aggregated)
- Build branded donation forms in Donorbox; embed on CBF-site (replaces custom Next.js form)
- Configure suggested amounts, recurring options, project designations ↔ Twenty
Projectmapping
Matching library — @nonprofit-crm/matching
- Zero-dependency TypeScript package, dependency-injected Twenty client adapter
- Implements the resolution flow in Fig. 2: email → external ID → name+city → name-only
- Unit-tested against anonymized fixtures via the existing
anonymize_givelively.pypath - Wrapped in a one-shot CLI (
check-import.ts) for the GiveLively historical reconciliation pass
Intake service — cbf-intake-service
- New Node/TS service in
docker/compose.yaml; own Postgres or schema in the Twenty DB staging_recordtable modelled on James’sGiftStagingRecord(raw payload, statuses, diagnostics, error detail)- Event-ID ledger for idempotent webhook handling
- Donorbox webhook endpoint: verify signature, normalize, match, promote-or-stage
- Worker: retry transient failures with backoff +
Retry-Afterhonoring - Promotion path uses the same Twenty REST endpoints as the UI, so existing workflows (ListMonk sync) fire correctly
GiveLively deprecation
- Final GiveLively export → reconciliation pass via CLI → import into Twenty
- Redirect existing GiveLively donation URLs to new Donorbox-backed forms on CBF-site
- Notify existing recurring donors of platform transition (manual; small list)
- Keep GiveLively account read-only for 90 days as historical reference
Review queue, dedupe sweep, recurring lifecycle handling
Closes the loop opened by Phase 3. The intake service is in place; this track gives the staging table a UI, adds the periodic sweep, and handles recurring-payment edge cases.
- Review queue UI over the staging table: approve, edit, merge, reject
- Approval action promotes to Twenty via REST (same path as live intake) so workflows fire
- Periodic dedupe sweep job (daily cron in the intake service): scans Twenty Person records, surfaces candidates in the same queue
- Recurring-payment lifecycle: subscription created / renewed / failed / cancelled events update
RecurringAgreement - Donor rollups (
firstDonation,lastDonation,totalDonated): compute via Twenty Workflow on Donation.created/updated, OR recompute nightly in the sweep job. Treat as derived, never authoritative.
Generalize chelan-comps into an open-source product; add Twenty as an optional workflow surface
Rev. 1 framed this as building a volunteer tool from zero. That framing is wrong: the production app already exists at chelancomps.org — React 19 + Vite + Supabase + Resend, deployed via Cloudflare Pages. The work that remains is generalization, documentation, and CRM integration, not feature-building.
OSS-ification track
- Lift Chelan-specific assumptions out of code into
config/event.ts-style configuration so a new organizer can fork-and-fill - Generalize branding (currently Chelan-specific) into tokens/theme that can be overridden per deployment
- Extract setup docs: Supabase project creation, migrations, Edge Function deployment, Resend setup, Cloudflare Pages deployment
- License, CONTRIBUTING, code of conduct, public README rewrite for an open-source audience
- Move the repo from private to public on the Cloudbase-Foundation org
- Documentation site — likely
volunteers.cloudbase.foundation
Twenty integration track (optional workflow surface)
- Decide what flows through Twenty: volunteer applicants as Person records? Shifts as a custom object? Or only the comp organizer's contact record (a "Partner") with the application data staying in Supabase?
- If volunteers sync to Twenty: extend the matching library to handle volunteer payloads, treating applications as a new
intakeSourcealongside donations - Build a thin sync layer between Supabase and the existing
cbf-intake-service(reuses the same matching + staging pattern as donations) - Twenty integration is opt-in per deployment — an OSS adopter shouldn't be forced to run Twenty
Resources page track (CBF-site)
- Real comp-organizer testimonials / case studies (starting with Chelan)
- Document specific workflows the tool supports, with screenshots
- "Get early access" waitlist form → Twenty Project Submission
- Link to
volunteers.cloudbase.foundationdocs site
Past projects, board headshots, news featured images, homepage selection
Unchanged from Rev. 1. One small dependency added: homepage featured-projects selector should read from Twenty rather than from projects.json, so this phase is technically gated on Phase 2's Twenty integration shipping first.
Non-transactional email — ListMonk-based, plus branded receipt customization
Original scope assumed CBF would own the entire email stack (Postmark/Resend/SES, React Email templates, transactional pipeline). With Donorbox handling transactional receipts and ListMonk handling broadcasts, the remaining work is much smaller.
- Customize Donorbox receipt template to CBF brand (navy, gold, Barlow Condensed headings)
- Customize Donorbox year-end summary template
- ListMonk: configure SPF/DKIM/DMARC for
cloudbase.foundation - ListMonk: branded HTML template for newsletters and announcements
- Lapsed donor re-engagement: ListMonk segment from Twenty data via the Workflow already standing up in Phase 2
- New project announcement template — ListMonk campaign
VI.Architecture decisions log
| Decision | Chosen | Rejected | Rationale |
|---|---|---|---|
| CRM | Twenty CRM | CiviCRM + Backdrop | CiviCRM too complex to self-host; Twenty is modern, Docker-first, has a clean REST API. Unchanged from Rev. 1. |
| Deployment | Cloudflare Workers via OpenNext | Vercel | DNS already on Cloudflare; consolidates infrastructure. Unchanged. |
| CMS | Tina CMS | CloudCannon, Decap | Git-backed, hosted auth, App Router support. Unchanged. |
| Donation platform | Donorbox | Stripe-direct (deferred), GiveLively (current), Givebutter, Zeffy, Every.org | Revised in Rev. 2. Donorbox uses Stripe under the hood (preserving an eventual self-hosted path), has the strongest API of the candidates, auto-issues 501(c)(3)-compliant receipts including year-end aggregates, and supports recurring lifecycle webhooks. Trade: 1.75% platform fee + $17/mo for API access. See donation-platform-comparison.md for the full evaluation. |
| Intake-service home | Sidecar Node/TS service on the CRM droplet | Cloudflare Worker (Rev. 1 plan), Twenty Workflow only | New in Rev. 2. Stateful work (idempotency ledger, staging table, periodic sweep) doesn't belong in a Worker. Pattern mirrors James Brysmart's nonprofit-crm-fundraising-service. |
| Matching engine | Zero-dep TS library, @nonprofit-crm/matching |
CRM-coupled implementation; James's PersonIdentityService as-is |
New in Rev. 2. Portable, testable in isolation, reusable by CLI and intake service. Contribution candidate to James's stack (and any other Twenty-based nonprofit). |
| Twenty extension model | Twenty Apps framework for in-Twenty work only | Forking Twenty's core (James's current path); building intake/matching as Twenty Apps | New in Rev. 2. Twenty's own docs recommend against forking. The Apps framework is used narrowly: to package CBF's custom-object definitions (RecurringAgreement, Partner, etc.) and in-Twenty workflow logic. It is not the home for the intake service (needs its own webhook endpoint, database, and background jobs) or the matching library (framework-agnostic by design). See § III for the full surface breakdown. |
| Volunteer tool | Custom build, open source | Off-the-shelf | No existing tool fits free-flight comp workflows. Unchanged. |
| Internal kanban | kan, self-hosted | Trello / Notion (SaaS), GitHub Projects, Twenty tasks | New, June 2026. An org-wide board for internal work, deliberately kept separate from donor data. kan is AGPL, Docker-first, Postgres-only, with Better Auth (Google OAuth + magic link) lockable to the thecloudbasefoundation.org Workspace. Runs as its own stack on the Unraid VM (own Postgres + MinIO), isolated from Twenty. Future CRM tie-ins go through kan's REST API + webhooks, not a shared database — see the CBF-kanban repo's docs/phase-2-crm-integration.md. |
VII.Open questions & risks
| Question / risk | Track | Resolution path |
|---|---|---|
| Donor-cover-fees uptake rate | Phase 3 | Run Donorbox sandbox, instrument fee-cover prompt copy, target ≥ 70%. If lower, re-evaluate Every.org as a $0 alternative. |
| Recurring vs one-off intake paths diverge | Phase 3.5 | Confirm Donorbox webhook shape for subscription renewal events vs first-charge; build separate handlers if needed. |
| PII flow into the sidecar staging DB | Phase 3 | Apply same backup, retention, and access controls as Twenty's DB. Document in docs/architecture.md when service stands up. |
| Donorbox project-designation field shape | Phase 3 | Verify in sandbox whether designations come through as structured metadata mapable to Twenty Project records, or as free-text requiring a lookup. |
| KarmaFlights partner tagging on intake | Phase 2 → 3 | Decide whether Partner is settable from Donorbox URL params/metadata, or whether it's assigned post-intake by workflow. |
| James's stack alignment | Cross-cutting | Tracked separately in contribution-strategy.md. Matching library is the natural first contribution; architecture-advisory conversation about Twenty Apps framework is the second. |
VIII.Reference & environments
Environments
| Environment | URL | Purpose | Status |
|---|---|---|---|
| Website (prod) | cloudbase.foundation | Live public site | Live |
| Website (preview) | *.pages.dev | PR preview deployments | Auto |
| CRM (dev) | http://<192.168.60.162> (local Unraid VM, LAN) | Development & testing | Running |
| CRM (prod) | crm.cloudbase.foundation | Live donor data | Phase 2 |
| ListMonk (dev) | http://<192.168.60.162>:9000 (local Unraid VM, LAN) | Email broadcasts | Phase 2 |
| Intake service (dev) | internal — cbf-intake-service:4000 | Webhook + matching + staging | Phase 3 |
| Kanban (dev) | kan.cbf-dev (local Unraid VM, LAN) | Internal kanban — org work tracking | Running |
| Kanban (prod) | kan.cloudbase.foundation | Internal kanban (org-wide) | Planned (with CRM prod) |
Related strategy documents
donor-matching-considerations.md— full analysis of the matching architecture and the comparison to James's stackdonation-platform-comparison.md— the Donorbox / Givebutter / Zeffy / Every.org evaluation behind the Phase 3 revisioncbf-donor-matching.md— original matching-strategy draft (now superseded in part by considerations doc)contribution-strategy.md— how CBF's work feeds back into James'snonprofit-crm-stack../plans/phase2-integration-plan.md— Phase 2 detail, including ListMonk + KarmaFlights tracks (execution plan, indocs/plans/)CBF-kanban(separate repo) — self-hosted kan deployment: compose, env, nginx, runbook, and the Phase 2 CRM-integration design
Technical debt & maintenance
| Item | Priority | Notes |
|---|---|---|
| npm audit vulnerabilities | Medium | 19 vulnerabilities in CBF-site as of May 2026 |
| Tina / React 19 peer conflict | Low | Tina targets React 18; monitor upstream |
wrangler.json vs wrangler.toml | Low | OpenNext migration created .json; Cloudflare prefers .toml |
| Build cache | Low | None configured; slower builds |