← back to Norma Platform
Add donation reconciliation slice (TK-11335 Section A, local-safe)
b18aac0ab0a5109842a78ada347b819bc266fb4c · 2026-09-09 15:59:52 -0700 · Steve Abrams
Links a pledge (giving intention) to the real donation that fulfills it, with
NO money movement. Reviewed by the contrarian (Cody) before commit; all three
of its FIX-FIRST findings folded in and re-verified.
- Migration 029: org-scoped organizing_donations ledger (cents), idempotent on
(org, provider, provider_ref). A partial UNIQUE index on matched_participation_id
makes one-time reconciliation race-proof (a pledge is fulfilled by at most one
donation). Deliberately separate from the legacy non-tenant `donations` table
to preserve tenant isolation.
- lib/campaigns/reconcile.ts: deterministic FIFO matcher for ONE-TIME giving
(org + email + exact amount, pledge-before-donation), with a savepoint/23505
catch so a concurrent race leaves a donation honestly unattributed rather than
double-counting. Recurring (monthly) reconciliation is intentionally deferred
to milestone 2 (one-to-many) — monthly donations show as unattributed, never
falsely matched. ingestDonation is SANDBOX-ONLY (throws outside sandbox), uses
the authenticated scope.org (never a payload org), and accepts a validated
provider donated_at.
- GET /api/campaigns/reconcile + POST /donations/ingest; workspace reconcile
totals; a "Giving reconciliation" Insights card; RUNBOOK boundaries updated.
Section B (real Stripe/ActBlue keys, live signature-verified webhook transport,
card collection/recurring billing/refunds/receipts, prod deploy) stays gated.
Verified on a fresh build: tsc clean, eslint 0 errors, 16/16 API journeys
(match, idempotency, one-to-one, monthly-deferred, donated_at, tenant-scope),
8/8 Chromium+WebKit browser, production-default gate (ingest 409 outside sandbox).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTfwQCiPbW99SJV5RYRe4L
Files touched
M app/api/campaigns/[[...path]]/route.tsM components/campaigns/CampaignWorkspace.tsxA db/029_donation_reconciliation.sqlM docs/platform/RUNBOOK.mdA lib/campaigns/reconcile.tsM lib/campaigns/store.tsM lib/campaigns/types.tsM scripts/campaign-preview.shM scripts/test-instance.shM tests/campaign-disabled.mjsM tests/campaign-platform.mjsM verification/platform/api-results.jsonM verification/platform/browser-results.jsonM verification/platform/publication-results.jsonM verification/platform/screenshots/chromium-advocacy.pngM verification/platform/screenshots/chromium-campaign.pngM verification/platform/screenshots/chromium-consent.pngM verification/platform/screenshots/chromium-event.pngM verification/platform/screenshots/chromium-fundraiser.pngM verification/platform/screenshots/chromium-mobile.pngM verification/platform/screenshots/chromium-receipt.pngM verification/platform/screenshots/chromium-volunteer.pngM verification/platform/screenshots/webkit-advocacy.pngM verification/platform/screenshots/webkit-campaign.pngM verification/platform/screenshots/webkit-consent.pngM verification/platform/screenshots/webkit-event.pngM verification/platform/screenshots/webkit-fundraiser.pngM verification/platform/screenshots/webkit-mobile.pngM verification/platform/screenshots/webkit-receipt.pngM verification/platform/screenshots/webkit-volunteer.png
Diff
commit b18aac0ab0a5109842a78ada347b819bc266fb4c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 9 15:59:52 2026 -0700
Add donation reconciliation slice (TK-11335 Section A, local-safe)
Links a pledge (giving intention) to the real donation that fulfills it, with
NO money movement. Reviewed by the contrarian (Cody) before commit; all three
of its FIX-FIRST findings folded in and re-verified.
- Migration 029: org-scoped organizing_donations ledger (cents), idempotent on
(org, provider, provider_ref). A partial UNIQUE index on matched_participation_id
makes one-time reconciliation race-proof (a pledge is fulfilled by at most one
donation). Deliberately separate from the legacy non-tenant `donations` table
to preserve tenant isolation.
- lib/campaigns/reconcile.ts: deterministic FIFO matcher for ONE-TIME giving
(org + email + exact amount, pledge-before-donation), with a savepoint/23505
catch so a concurrent race leaves a donation honestly unattributed rather than
double-counting. Recurring (monthly) reconciliation is intentionally deferred
to milestone 2 (one-to-many) — monthly donations show as unattributed, never
falsely matched. ingestDonation is SANDBOX-ONLY (throws outside sandbox), uses
the authenticated scope.org (never a payload org), and accepts a validated
provider donated_at.
- GET /api/campaigns/reconcile + POST /donations/ingest; workspace reconcile
totals; a "Giving reconciliation" Insights card; RUNBOOK boundaries updated.
Section B (real Stripe/ActBlue keys, live signature-verified webhook transport,
card collection/recurring billing/refunds/receipts, prod deploy) stays gated.
Verified on a fresh build: tsc clean, eslint 0 errors, 16/16 API journeys
(match, idempotency, one-to-one, monthly-deferred, donated_at, tenant-scope),
8/8 Chromium+WebKit browser, production-default gate (ingest 409 outside sandbox).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTfwQCiPbW99SJV5RYRe4L
---
app/api/campaigns/[[...path]]/route.ts | 3 +
components/campaigns/CampaignWorkspace.tsx | 2 +-
db/029_donation_reconciliation.sql | 33 ++++++++
docs/platform/RUNBOOK.md | 3 +-
lib/campaigns/reconcile.ts | 89 +++++++++++++++++++++
lib/campaigns/store.ts | 5 +-
lib/campaigns/types.ts | 2 +-
scripts/campaign-preview.sh | 1 +
scripts/test-instance.sh | 5 +-
tests/campaign-disabled.mjs | 2 +
tests/campaign-platform.mjs | 40 ++++++++-
verification/platform/api-results.json | 6 +-
verification/platform/browser-results.json | 36 ++++-----
verification/platform/publication-results.json | 6 +-
.../platform/screenshots/chromium-advocacy.png | Bin 102670 -> 103951 bytes
.../platform/screenshots/chromium-campaign.png | Bin 145602 -> 145920 bytes
.../platform/screenshots/chromium-consent.png | Bin 142866 -> 142916 bytes
.../platform/screenshots/chromium-event.png | Bin 108818 -> 109311 bytes
.../platform/screenshots/chromium-fundraiser.png | Bin 84637 -> 85534 bytes
.../platform/screenshots/chromium-mobile.png | Bin 940034 -> 1187989 bytes
.../platform/screenshots/chromium-receipt.png | Bin 91477 -> 92296 bytes
.../platform/screenshots/chromium-volunteer.png | Bin 96715 -> 97038 bytes
.../platform/screenshots/webkit-advocacy.png | Bin 334913 -> 344785 bytes
.../platform/screenshots/webkit-campaign.png | Bin 464877 -> 455830 bytes
.../platform/screenshots/webkit-consent.png | Bin 432134 -> 433570 bytes
verification/platform/screenshots/webkit-event.png | Bin 356821 -> 365606 bytes
.../platform/screenshots/webkit-fundraiser.png | Bin 298397 -> 306556 bytes
.../platform/screenshots/webkit-mobile.png | Bin 2918553 -> 3639882 bytes
.../platform/screenshots/webkit-receipt.png | Bin 305728 -> 298943 bytes
.../platform/screenshots/webkit-volunteer.png | Bin 314359 -> 322088 bytes
30 files changed, 207 insertions(+), 26 deletions(-)
diff --git a/app/api/campaigns/[[...path]]/route.ts b/app/api/campaigns/[[...path]]/route.ts
index e24a19e..1ac96c0 100644
--- a/app/api/campaigns/[[...path]]/route.ts
+++ b/app/api/campaigns/[[...path]]/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { body, CampaignError, failure, publishingEnabled, sandbox, scope } from '@/lib/campaigns/validation';
import { attendance, createAction, createCampaign, detail, saveTask, updateAction, updateCampaign, workspace } from '@/lib/campaigns/store';
import { consentDetail, dispatchConfirmation } from '@/lib/campaigns/consent';
+import { ingestDonation, reconcileReport } from '@/lib/campaigns/reconcile';
import { CONSENT_STATE_CSV, type ConsentState } from '@/lib/campaigns/types';
import { query } from '@/lib/db';
export const dynamic = 'force-dynamic';
@@ -20,6 +21,7 @@ export async function GET(request: NextRequest, context: Context) {
return new NextResponse(csv,{headers:{'Content-Type':'text/csv; charset=utf-8','Content-Disposition':'attachment; filename="norma-supporters.csv"','Cache-Control':'private, no-store'}});
}
if (p.length === 2 && p[0] === 'consent') return json(await consentDetail(s.org,p[1]));
+ if (p.length === 1 && p[0] === 'reconcile') return json(await reconcileReport(s.org));
if (p.length === 1) return json(await detail(s.org,p[0]));
throw new CampaignError(404,'Endpoint not found.');
} catch(error) { return failure(error); }
@@ -31,6 +33,7 @@ export async function POST(request: NextRequest, context: Context) {
if (p.length === 2 && p[1] === 'actions') return json({action:await createAction(s,p[0],data)},201);
if (p.length === 1 && p[0] === 'tasks') return json({task:await saveTask(s,null,data)},201);
if (p.length === 1 && p[0] === 'consent') return json(await dispatchConfirmation(request,s,data.supporter_id as string));
+ if (p.length === 2 && p[0] === 'donations' && p[1] === 'ingest') return json(await ingestDonation(s,data));
throw new CampaignError(404,'Endpoint not found.');
} catch(error) { return failure(error); }
}
diff --git a/components/campaigns/CampaignWorkspace.tsx b/components/campaigns/CampaignWorkspace.tsx
index ddee9b3..4a5f031 100644
--- a/components/campaigns/CampaignWorkspace.tsx
+++ b/components/campaigns/CampaignWorkspace.tsx
@@ -105,7 +105,7 @@ export default function CampaignWorkspace() {
</>}
{!selected && tab==='Supporters' && workspace && <><div className={styles.sectionhead}><div><h2>People, connected.</h2><p className={styles.muted}>A single record across your campaign actions. Latest 500 supporters.</p></div><button className={`${styles.button} ${styles.secondary}`} onClick={()=>void exportCsv()}><Download size={16}/> Export all (up to 10,000)</button></div><div className={styles.controls}><input type="search" aria-label="Search supporters" placeholder="Find by name or email…" value={search} onChange={e=>setSearch(e.target.value)}/></div><div className={styles.note}>An email update request is not a verified subscription. Export includes consent-request status; do not use it as a send-ready mailing list.</div>{supporters.length?<div className={styles.tablewrap}><table className={styles.table}><thead><tr><th>Supporter</th><th>Participation</th><th>Pledged</th><th>Email updates</th><th>Created</th></tr></thead><tbody>{supporters.map(s=><tr key={s.id}><td><strong>{s.full_name}</strong><small>{s.email}</small></td><td>{s.participation_count} actions<small>{s.kinds.map(k=>KIND_LABEL[k]).join(', ')}</small></td><td>{dollars(s.pledged_cents)}</td><td><span className={styles.badge}>{CONSENT_STATE_LABEL[s.consent_state]||'Not requested'}</span>{s.consent_state==='pending'&&<button className={styles.textbutton} disabled={busy} onClick={()=>void dispatchConsent(s.id)}>Send confirmation</button>}</td><td title={s.created_at}>{timestamp(s.created_at)}</td></tr>)}</tbody></table></div>:<Empty title="Your supporter history starts here"><p>Participation connects people across campaigns without creating duplicate records.</p></Empty>}</>}
{!selected && tab==='Follow-ups' && renderTasks()}
- {!selected && tab==='Insights' && workspace && <><div className={styles.sectionhead}><h2>See what participation leads to.</h2></div><div className={styles.two}><article className={styles.card}><h3>Organizing outcomes</h3><ul className={styles.list}><li>{workspace.stats.volunteer_signups} volunteer signups</li><li>{workspace.stats.checked_in} event check-ins</li><li>{workspace.stats.monthly_pledges} monthly giving intentions</li><li>{dollars(workspace.stats.pledged_cents)} in pledges · payment collection is separate</li></ul><Link href="/?tab=donations" className={styles.textbutton}>Open the existing donation ledger ↗</Link></article><article className={styles.card}><h3>Where participation starts</h3><p>Share a form with <code>?source=newsletter</code> to attribute responses.</p><ul className={styles.list}>{workspace.sources.map(s=><li key={s.source}><strong>{s.source}</strong> · {s.count} responses · {dollars(s.pledged_cents)} pledged</li>)}</ul>{!workspace.sources.length&&<p>No attribution data yet.</p>}</article></div><div className={styles.sectionhead}><h2>Workspace activity</h2></div><div className={styles.card}>{workspace.audit.length?workspace.audit.map(a=><div className={styles.timeline} key={a.id}><p><strong>{a.event.replaceAll('.',' · ')}</strong> — {a.actor}</p><time className={styles.date} title={a.created_at}>{timestamp(a.created_at)}</time></div>):<p>No activity recorded yet.</p>}</div></>}
+ {!selected && tab==='Insights' && workspace && <><div className={styles.sectionhead}><h2>See what participation leads to.</h2></div><div className={styles.two}><article className={styles.card}><h3>Organizing outcomes</h3><ul className={styles.list}><li>{workspace.stats.volunteer_signups} volunteer signups</li><li>{workspace.stats.checked_in} event check-ins</li><li>{workspace.stats.monthly_pledges} monthly giving intentions</li><li>{dollars(workspace.stats.pledged_cents)} in pledges · payment collection is separate</li></ul><Link href="/?tab=donations" className={styles.textbutton}>Open the existing donation ledger ↗</Link></article><article className={styles.card}><h3>Where participation starts</h3><p>Share a form with <code>?source=newsletter</code> to attribute responses.</p><ul className={styles.list}>{workspace.sources.map(s=><li key={s.source}><strong>{s.source}</strong> · {s.count} responses · {dollars(s.pledged_cents)} pledged</li>)}</ul>{!workspace.sources.length&&<p>No attribution data yet.</p>}</article></div><div className={styles.sectionhead}><h2>Giving reconciliation</h2></div><div className={styles.card}><ul className={styles.list}><li><strong>{dollars(workspace.stats.pledged_cents)}</strong> pledged · <strong>{dollars(workspace.stats.received_cents)}</strong> received · <strong>{dollars(workspace.stats.matched_cents)}</strong> reconciled to a pledge</li><li>{workspace.stats.unattributed_count} donation{workspace.stats.unattributed_count===1?'':'s'} not yet matched to a pledge</li></ul><p className={styles.muted}>One-time contributions are matched to one-time fundraiser pledges by donor email + amount; each pledge reconciles once. Recurring (monthly) reconciliation and the live signature-verified provider webhook are gated milestone-2 work — monthly donations show here as unattributed until then — and no card is ever charged here.</p></div><div className={styles.sectionhead}><h2>Workspace activity</h2></div><div className={styles.card}>{workspace.audit.length?workspace.audit.map(a=><div className={styles.timeline} key={a.id}><p><strong>{a.event.replaceAll('.',' · ')}</strong> — {a.actor}</p><time className={styles.date} title={a.created_at}>{timestamp(a.created_at)}</time></div>):<p>No activity recorded yet.</p>}</div></>}
{!selected && tab==='Connections' && <><div className={styles.sectionhead}><h2>Your existing tools, in the same workspace.</h2></div><div className={styles.grid}>{[
['Petition studio','Write, manage, and deliver petition campaigns.','/?tab=petitions'],['Donation ledger','Review recorded giving separately from campaign pledges.','/?tab=donations'],['Contacts','Work with your existing contact directory and imports.','/?tab=contacts'],['Email workspace','Prepare correspondence through your configured email tools.','/?tab=gmail-crm'],['Social workspace','Prepare and manage your social content.','/?tab=social-overview'],['API connections','Inspect Norma’s existing connected services.','/?tab=integrations'],
].filter(([, , url])=>role==='admin'||!['/?tab=social-overview','/?tab=integrations'].includes(url)).map(([title,copy,url])=><a className={styles.card} href={url} key={title}><ArrowUpRight size={18}/><h3>{title}</h3><p>{copy}</p></a>)}</div><div className={styles.note}><strong>Giving and delivery readiness.</strong> Fundraiser forms can link to a hosted ActBlue contribution page. Card collection, recurring billing, refunds, email/SMS sends, and dialing need verified provider integrations. This workspace records pledges and prepares follow-ups; it does not represent those integrations as live.</div></>}
diff --git a/db/029_donation_reconciliation.sql b/db/029_donation_reconciliation.sql
new file mode 100644
index 0000000..a8a617c
--- /dev/null
+++ b/db/029_donation_reconciliation.sql
@@ -0,0 +1,33 @@
+-- Additive donation-reconciliation ledger for the campaign platform (TK-11335, Section A).
+-- Org-scoped and in CENTS (matching organizing_participation pledges) so a recorded
+-- contribution can be reconciled against the pledge that intended it. Deliberately SEPARATE
+-- from the legacy DW `donations` table (which is not org-scoped and carries no donor email),
+-- to preserve the platform's strict tenant isolation. No money moves here: rows are written
+-- only by the sandbox-only mock provider ingest until a verified webhook is wired (milestone 2).
+BEGIN;
+CREATE TABLE IF NOT EXISTS organizing_donations (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ org_id uuid NOT NULL REFERENCES nonprofit_accounts(id),
+ provider text NOT NULL DEFAULT 'actblue' CHECK (provider IN ('actblue','other')),
+ -- provider's own transaction id; the idempotency key for repeated webhook deliveries.
+ provider_ref text NOT NULL,
+ amount_cents integer NOT NULL CHECK (amount_cents > 0),
+ frequency text NOT NULL DEFAULT 'once' CHECK (frequency IN ('once','monthly')),
+ donor_email text NOT NULL DEFAULT '',
+ donor_name text NOT NULL DEFAULT '',
+ -- the pledge (organizing_participation) this donation fulfills, once reconciled; NULL = unattributed.
+ matched_participation_id uuid REFERENCES organizing_participation(id) ON DELETE SET NULL,
+ match_method text NOT NULL DEFAULT '' CHECK (match_method IN ('','email_amount')),
+ donated_at timestamptz NOT NULL DEFAULT now(),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ -- one row per provider transaction per org: makes webhook redelivery idempotent.
+ UNIQUE (org_id, provider, provider_ref)
+);
+CREATE INDEX IF NOT EXISTS organizing_donations_org ON organizing_donations(org_id, donated_at DESC);
+CREATE INDEX IF NOT EXISTS organizing_donations_match ON organizing_donations(matched_participation_id);
+-- Race-proof one-time reconciliation: a pledge can be fulfilled by at most one donation, enforced at
+-- the DB so two concurrent ingests can never both link (and thus double-count) the same pledge.
+-- Recurring (monthly) reconciliation, which is intentionally one-to-many, is milestone-2 work and is
+-- NOT auto-matched here, so this partial unique index is correct for the one-time matching we do do.
+CREATE UNIQUE INDEX IF NOT EXISTS organizing_donations_one_per_pledge ON organizing_donations(matched_participation_id) WHERE matched_participation_id IS NOT NULL;
+COMMIT;
diff --git a/docs/platform/RUNBOOK.md b/docs/platform/RUNBOOK.md
index 30cf0ef..0185184 100644
--- a/docs/platform/RUNBOOK.md
+++ b/docs/platform/RUNBOOK.md
@@ -37,7 +37,8 @@ The gate runs its own isolated process with public campaign intake disabled. Whi
- `NORMA_CAMPAIGN_SANDBOX=true` works only when `DATABASE_URL` names `sdcc_test`. The preview uses this mode.
- Public activation/intake defaults off elsewhere. `NORMA_CAMPAIGN_PUBLIC_ENABLED=true` must be deliberately configured for a separately approved public deployment.
- Set `NORMA_PUBLIC_ORIGIN` to the exact external origin behind an HTTPS reverse proxy. The ingress must overwrite client-IP headers, strip caller-supplied forwarded headers, and impose global abuse limits. The per-action limiter is not a substitute for ingress protection. **`NORMA_PUBLIC_ORIGIN` MUST be pinned before a live email provider is wired** — confirm/unsubscribe links are built from it, and an unpinned (Host-derived) origin would become a phishing link mailed to real supporters. Today this is moot: dispatch is sandbox-only and the link never leaves an authenticated JSON response.
-- A pledge is not a payment or a recurring subscription. The optional HTTPS ActBlue link opens an external contribution form; the user selects amount/frequency on that provider. There is no payment webhook reconciliation or accounting inference.
+- A pledge is not a payment or a recurring subscription. The optional HTTPS ActBlue link opens an external contribution form; the user selects amount/frequency on that provider. No card is ever charged in Norma.
+- Donation reconciliation (migration 029): recorded contributions live in the org-scoped `organizing_donations` ledger (cents), deliberately separate from the legacy non-tenant `donations` table. A ONE-TIME donation is matched to the one-time fundraiser pledge that intended it by donor email + exact amount (FIFO), and a partial unique index guarantees each pledge reconciles at most once (race-proof). The ingest is a SANDBOX-ONLY mock provider push that throws outside the sandbox and always writes to the AUTHENTICATED organizer's org — never a payload org; idempotent on (org, provider, provider_ref) so a redelivered webhook never doubles. RECURRING (monthly) reconciliation is intentionally NOT auto-matched here (it is one-to-many); monthly donations show as unattributed until milestone 2. The PUBLIC, signature-verified webhook transport, real card collection/recurring billing/refunds/receipts, and any production donation write are gated milestone-2 work.
- A participation email-update checkbox now starts a verified-consent state machine on the supporter (migration 028): `none → pending → confirmed`, with sticky `unsubscribed` and bounce-driven `suppressed` terminal states, every transition appended to an immutable `organizing_consent_events` log (a DB trigger blocks any UPDATE). The organizer "Send confirmation" dispatcher is a sandbox-only MOCK provider — it computes a deterministic delivery result (delivered / bounced → suppressed / transient-failed → retryable) and logs it but NEVER opens a network connection, and it throws outside the sandbox. Real email/SMS transmission through a verified provider is still deferred (see milestone 2). Do not treat a `pending` request as a send-ready subscriber; only `confirmed` reflects a completed double-opt-in.
- One email has one response per action. Identical retries return the original receipt; a changed duplicate returns a clear conflict instead of silently changing a pledge or consent. Supporter correction/self-service is future work.
- Existing donation, contact, petition and messaging modules are linked, but their historic records are not automatically merged into campaign supporter history.
diff --git a/lib/campaigns/reconcile.ts b/lib/campaigns/reconcile.ts
new file mode 100644
index 0000000..a27778f
--- /dev/null
+++ b/lib/campaigns/reconcile.ts
@@ -0,0 +1,89 @@
+import type { PoolClient } from 'pg';
+import { query } from '@/lib/db';
+import { audit, transaction, type Scope } from './store';
+import { CampaignError, choice, id, number, text } from './validation';
+import { sandbox } from './validation';
+
+// Deterministic reconciler for ONE-TIME giving: link an unmatched one-time donation to the one-time
+// fundraiser pledge that intended it. Match requires SAME org + SAME donor email (case-insensitive) +
+// EXACT amount, the pledge recorded on or before the donation, and the pledge not already fulfilled.
+// Oldest eligible pledge wins (FIFO). Recurring (monthly) reconciliation is intentionally one-to-many
+// and is milestone-2 work, so a monthly donation is deliberately left unattributed here rather than
+// falsely matched to a single payment. Runs in the caller's transaction with the donation row locked.
+async function matchDonation(client: PoolClient, org: string, donationId: string) {
+ const d = (await client.query('SELECT * FROM organizing_donations WHERE id=$1 AND org_id=$2 FOR UPDATE', [donationId, org])).rows[0];
+ if (!d || d.matched_participation_id || !d.donor_email || d.frequency !== 'once') return d;
+ const pledge = (await client.query(`SELECT p.id FROM organizing_participation p
+ JOIN organizing_supporters s ON s.id=p.supporter_id
+ JOIN organizing_actions a ON a.id=p.action_id
+ WHERE p.org_id=$1 AND a.kind='fundraiser' AND p.status!='cancelled' AND p.frequency='once'
+ AND lower(s.email)=lower($2) AND p.amount_cents=$3 AND p.created_at<=$4
+ AND NOT EXISTS (SELECT 1 FROM organizing_donations o WHERE o.matched_participation_id=p.id)
+ ORDER BY p.created_at LIMIT 1`, [org, d.donor_email, d.amount_cents, d.donated_at])).rows[0];
+ if (!pledge) return d;
+ // organizing_donations_one_per_pledge (partial UNIQUE) makes double-linking impossible: if a
+ // concurrent ingest linked this pledge between our SELECT and UPDATE, the UPDATE raises 23505. The
+ // savepoint lets us recover WITHOUT poisoning the outer transaction — we leave this donation
+ // unattributed (honest) rather than double-count reconciled money.
+ await client.query('SAVEPOINT link_pledge');
+ try {
+ return (await client.query("UPDATE organizing_donations SET matched_participation_id=$1, match_method='email_amount' WHERE id=$2 AND org_id=$3 RETURNING *", [pledge.id, donationId, org])).rows[0];
+ } catch (e) {
+ if ((e as { code?: string }).code === '23505') { await client.query('ROLLBACK TO SAVEPOINT link_pledge'); return d; }
+ throw e;
+ }
+}
+
+// SANDBOX-ONLY mock provider ingest. Models an ActBlue-shaped contribution push and reconciles it.
+// Throws outside the sandbox exactly like dispatchConfirmation — the PUBLIC, signature-verified webhook
+// transport (and any production donation write) is a gated milestone. The org is the AUTHENTICATED
+// organizer's org (s.org), never a payload field, so it can never write into another tenant. Idempotent
+// on (org, provider, provider_ref): a redelivered webhook returns the original donation, never a double.
+export async function ingestDonation(s: Scope, payload: Record<string, unknown>) {
+ if (!sandbox()) throw new CampaignError(409, 'A verified payment-provider webhook is not configured for this deployment. Donation ingest is available in the sandbox only.');
+ const provider = choice(payload.provider ?? 'actblue', ['actblue', 'other'] as const, 'provider');
+ const providerRef = text(payload.provider_ref, 'Provider reference', 200);
+ const amount = number(payload.amount_cents, 'Donation amount in cents', 1, 100000000);
+ const frequency = choice(payload.frequency ?? 'once', ['once', 'monthly'] as const, 'frequency');
+ const email = text(payload.donor_email ?? '', 'Donor email', 254, false).toLowerCase();
+ const name = text(payload.donor_name ?? '', 'Donor name', 160, false);
+ // Take the provider's reported transaction time when present (a real webhook carries it); default to
+ // now() when absent. Reject a present-but-invalid timestamp so a malformed webhook can't misdate the
+ // ledger — donated_at also gates matching (a donation can't fulfill a pledge made after it).
+ let donatedAt: string | null = null;
+ const rawDate = payload.donated_at;
+ if (rawDate !== undefined && rawDate !== null && rawDate !== '') {
+ if (typeof rawDate !== 'string' || !Number.isFinite(Date.parse(rawDate))) throw new CampaignError(400, 'donated_at must be a valid date.');
+ donatedAt = new Date(rawDate).toISOString();
+ }
+ return transaction(async client => {
+ const existing = (await client.query('SELECT id FROM organizing_donations WHERE org_id=$1 AND provider=$2 AND provider_ref=$3', [s.org, provider, providerRef])).rows[0];
+ if (existing) { const row = await matchDonation(client, s.org, existing.id); return { donation: row.id, matched: !!row.matched_participation_id, idempotent: true }; }
+ const inserted = (await client.query(`INSERT INTO organizing_donations(org_id,provider,provider_ref,amount_cents,frequency,donor_email,donor_name,donated_at)
+ VALUES($1,$2,$3,$4,$5,$6,$7,COALESCE($8::timestamptz,now())) RETURNING id`, [s.org, provider, providerRef, amount, frequency, email, name, donatedAt])).rows[0];
+ const row = await matchDonation(client, s.org, inserted.id);
+ await audit(client, s, `donation.ingested.${row.matched_participation_id ? 'matched' : 'unmatched'}`, row.id);
+ return { donation: row.id, matched: !!row.matched_participation_id, idempotent: false };
+ });
+}
+
+// Organizer reconciliation report: pledged vs received totals, the fundraiser pledges still awaiting a
+// donation, and the donations not yet attributed to any pledge. Read-only, tenant-scoped.
+export async function reconcileReport(org: string) {
+ id(org);
+ const [totals, unmatchedPledges, unattributed] = await Promise.all([
+ query(`SELECT
+ (SELECT COALESCE(sum(p.amount_cents),0)::float8 FROM organizing_participation p JOIN organizing_actions a ON a.id=p.action_id WHERE p.org_id=$1 AND a.kind='fundraiser' AND p.status!='cancelled') pledged_cents,
+ (SELECT COALESCE(sum(amount_cents),0)::float8 FROM organizing_donations WHERE org_id=$1) received_cents,
+ (SELECT COALESCE(sum(amount_cents),0)::float8 FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NOT NULL) matched_cents,
+ (SELECT count(*)::int FROM organizing_donations WHERE org_id=$1) donation_count,
+ (SELECT count(*)::int FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NULL) unattributed_count`, [org]),
+ query(`SELECT p.id, s.full_name, s.email, p.amount_cents, p.frequency, p.created_at
+ FROM organizing_participation p JOIN organizing_supporters s ON s.id=p.supporter_id JOIN organizing_actions a ON a.id=p.action_id
+ WHERE p.org_id=$1 AND a.kind='fundraiser' AND p.status!='cancelled'
+ AND NOT EXISTS (SELECT 1 FROM organizing_donations o WHERE o.matched_participation_id=p.id)
+ ORDER BY p.created_at DESC LIMIT 200`, [org]),
+ query('SELECT id, donor_email, donor_name, amount_cents, frequency, provider, donated_at FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NULL ORDER BY donated_at DESC LIMIT 200', [org]),
+ ]);
+ return { totals: totals.rows[0], unmatched_pledges: unmatchedPledges.rows, unattributed_donations: unattributed.rows };
+}
diff --git a/lib/campaigns/store.ts b/lib/campaigns/store.ts
index d563a95..bdf77a6 100644
--- a/lib/campaigns/store.ts
+++ b/lib/campaigns/store.ts
@@ -30,7 +30,10 @@ export async function workspace(org: string) {
COALESCE(sum(p.amount_cents) FILTER(WHERE p.status!='cancelled'),0)::float8 pledged_cents,
count(*) FILTER(WHERE p.frequency='monthly' AND p.status!='cancelled')::int monthly_pledges,
count(*) FILTER(WHERE p.status='checked_in')::int checked_in,
- count(*) FILTER(WHERE a.kind='volunteer' AND p.status!='cancelled')::int volunteer_signups
+ count(*) FILTER(WHERE a.kind='volunteer' AND p.status!='cancelled')::int volunteer_signups,
+ (SELECT COALESCE(sum(amount_cents),0)::float8 FROM organizing_donations WHERE org_id=$1) received_cents,
+ (SELECT COALESCE(sum(amount_cents),0)::float8 FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NOT NULL) matched_cents,
+ (SELECT count(*)::int FROM organizing_donations WHERE org_id=$1 AND matched_participation_id IS NULL) unattributed_count
FROM organizing_participation p JOIN organizing_actions a ON a.id=p.action_id WHERE p.org_id=$1`, [org]),
query(`SELECT source,count(*)::int count,COALESCE(sum(amount_cents),0)::float8 pledged_cents FROM organizing_participation WHERE org_id=$1 AND status!='cancelled' GROUP BY source ORDER BY count(*) DESC LIMIT 100`, [org]),
query('SELECT * FROM organizing_audit WHERE org_id=$1 ORDER BY created_at DESC LIMIT 40', [org]),
diff --git a/lib/campaigns/types.ts b/lib/campaigns/types.ts
index dc35e1c..73197ae 100644
--- a/lib/campaigns/types.ts
+++ b/lib/campaigns/types.ts
@@ -13,6 +13,6 @@ export interface Supporter { id: string; email: string; full_name: string; creat
export interface Participation { id: string; action_id: string; action_title: string; kind: ActionKind; full_name: string; email: string; status: 'registered' | 'checked_in' | 'cancelled'; amount_cents: number; frequency: 'once' | 'monthly'; source: string; consent_requested: boolean; created_at: string }
export interface OrganizingTask { supporter_name?: string; supporter_email?: string; campaign_title?: string; id: string; campaign_id: string; title: string; body: string; channel: TaskChannel; status: 'draft' | 'ready' | 'completed'; assignee?: string; shift_label?: string; due_at?: string | null; created_at: string; updated_at: string }
export interface CampaignDetail { campaign: Campaign; actions: CampaignAction[]; participation: Participation[]; tasks: OrganizingTask[] }
-export interface Workspace { campaigns: Campaign[]; supporters: Supporter[]; tasks: OrganizingTask[]; stats: { campaigns: number; supporters: number; participation: number; pledged_cents: number; monthly_pledges: number; checked_in: number; volunteer_signups: number }; sources: { source: string; count: number; pledged_cents: number }[]; audit: { id: string; actor: string; event: string; created_at: string }[]; publishing_enabled: boolean; sandbox: boolean }
+export interface Workspace { campaigns: Campaign[]; supporters: Supporter[]; tasks: OrganizingTask[]; stats: { campaigns: number; supporters: number; participation: number; pledged_cents: number; monthly_pledges: number; checked_in: number; volunteer_signups: number; received_cents: number; matched_cents: number; unattributed_count: number }; sources: { source: string; count: number; pledged_cents: number }[]; audit: { id: string; actor: string; event: string; created_at: string }[]; publishing_enabled: boolean; sandbox: boolean }
export const KIND_LABEL: Record<ActionKind, string> = { petition: 'Petition', fundraiser: 'Fundraiser', event: 'Event', volunteer: 'Volunteer', advocacy: 'Advocacy action' };
export const CONSENT_TEXT = 'I would like to receive campaign updates by email. My request will need confirmation before I am added to a mailing list.';
diff --git a/scripts/campaign-preview.sh b/scripts/campaign-preview.sh
index b05a535..a4ce8ab 100644
--- a/scripts/campaign-preview.sh
+++ b/scripts/campaign-preview.sh
@@ -8,4 +8,5 @@ export NORMA_CAMPAIGN_SANDBOX=true
psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/026_campaign_platform.sql
psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/027_field_assignments.sql
psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/028_consent.sql
+psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/029_donation_reconciliation.sql
exec bash scripts/test-instance.sh
diff --git a/scripts/test-instance.sh b/scripts/test-instance.sh
index 3baec19..3304385 100755
--- a/scripts/test-instance.sh
+++ b/scripts/test-instance.sh
@@ -31,7 +31,7 @@ export CRON_SECRET="test-cron-secret"
# suite back to red with no explanation. (Do NOT blanket-apply db/*.sql: seeds
# would duplicate fixture rows and alpha-order would run migrations before
# schema.sql. Add specific idempotent migrations here as the suite grows.)
-for m in db/024_user_management.sql db/025_email_assign_read.sql db/026_campaign_platform.sql db/027_field_assignments.sql db/028_consent.sql; do
+for m in db/024_user_management.sql db/025_email_assign_read.sql db/026_campaign_platform.sql db/027_field_assignments.sql db/028_consent.sql db/029_donation_reconciliation.sql; do
if psql "$DATABASE_URL" -v ON_ERROR_STOP=0 -f "$m" >/dev/null 2>&1; then
echo "[test-instance] ensured $m"
else
@@ -55,6 +55,9 @@ missing=$(psql "$DATABASE_URL" -Atc "
UNION ALL
SELECT 'organizing_consent_events (missing migration 028)'
WHERE to_regclass('public.organizing_consent_events') IS NULL
+ UNION ALL
+ SELECT 'organizing_donations (missing migration 029)'
+ WHERE to_regclass('public.organizing_donations') IS NULL
) t" 2>/dev/null || echo "PSQL_UNREACHABLE")
if [ -n "$missing" ]; then
echo "[test-instance] FATAL: sdcc_test is missing required schema: $missing"
diff --git a/tests/campaign-disabled.mjs b/tests/campaign-disabled.mjs
index a4f49f5..e9effd7 100644
--- a/tests/campaign-disabled.mjs
+++ b/tests/campaign-disabled.mjs
@@ -16,5 +16,7 @@ const post=await fetch(base+'/api/action-center/'+randomUUID(),{method:'POST',he
checks.push({name:'Default deployment rejects public intake',verdict:'PASS'});
const disp=await fetch(base+'/api/campaigns/consent',{method:'POST',headers:{Cookie:cookie,'Content-Type':'application/json'},body:JSON.stringify({supporter_id:randomUUID()})});assert.equal(disp.status,409);
checks.push({name:'Mock consent dispatcher is refused outside the sandbox',verdict:'PASS'});
+const ingest=await fetch(base+'/api/campaigns/donations/ingest',{method:'POST',headers:{Cookie:cookie,'Content-Type':'application/json'},body:JSON.stringify({provider:'actblue',provider_ref:randomUUID(),amount_cents:5000,donor_email:'default@example.test'})});assert.equal(ingest.status,409);
+checks.push({name:'Mock donation ingest is refused outside the sandbox',verdict:'PASS'});
fs.mkdirSync('verification/platform',{recursive:true});fs.writeFileSync('verification/platform/publication-results.json',JSON.stringify({timestamp:new Date().toISOString(),base,checks,cleanup:'No records created or changed; activation and intake rejected.'},null,2));
console.log('PASS: production-default activation and public intake are disabled');
diff --git a/tests/campaign-platform.mjs b/tests/campaign-platform.mjs
index 59b7e55..43e8b31 100644
--- a/tests/campaign-platform.mjs
+++ b/tests/campaign-platform.mjs
@@ -121,6 +121,44 @@ try {
await assert.rejects(db.query("UPDATE organizing_consent_events SET detail='tamper' WHERE supporter_id=$1",[ok]));
assert.equal((await hit('/api/campaigns/consent/'+ok,'GET',undefined,cookie,foreign)).status,404);
});
+ await check('Donation reconciliation: sandbox ingest matches a pledge, is idempotent, and stays tenant-scoped',async()=>{
+ const donor=run+'-donor@example.test';
+ const pledge=await intake(fundraiser,donor,randomUUID(),{amount_cents:5000,frequency:'once'});assert.equal(pledge.status,202);const pledgeId=pledge.body.receipt;
+ // before any donation the pledge shows as unmatched
+ let rep=(await hit('/api/campaigns/reconcile')).body;assert.ok(rep.unmatched_pledges.some(x=>x.id===pledgeId));
+ // sandbox mock ActBlue-shaped ingest reconciles to the pledge
+ const ref='ab_'+run+'_1';
+ const ing=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:ref,amount_cents:5000,frequency:'once',donor_email:donor,donor_name:'Test Supporter'});
+ assert.equal(ing.status,200,JSON.stringify(ing.body));assert.equal(ing.body.matched,true);assert.equal(ing.body.idempotent,false);
+ const drow=(await db.query('SELECT matched_participation_id,match_method FROM organizing_donations WHERE org_id=$1 AND provider_ref=$2',[org,ref])).rows[0];
+ assert.equal(drow.matched_participation_id,pledgeId);assert.equal(drow.match_method,'email_amount');
+ // idempotent: a redelivered webhook (same provider_ref) returns the original, never a double row
+ const dup=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:ref,amount_cents:5000,frequency:'once',donor_email:donor});
+ assert.equal(dup.body.idempotent,true);assert.equal(dup.body.donation,ing.body.donation);
+ assert.equal((await db.query('SELECT count(*)::int n FROM organizing_donations WHERE org_id=$1 AND provider_ref=$2',[org,ref])).rows[0].n,1);
+ // pledge now reconciled; totals reflect it
+ rep=(await hit('/api/campaigns/reconcile')).body;assert.ok(!rep.unmatched_pledges.some(x=>x.id===pledgeId));assert.ok(rep.totals.received_cents>=5000);assert.ok(rep.totals.matched_cents>=5000);
+ // a donation with no matching pledge stays unattributed
+ const ref2='ab_'+run+'_2', orphanEmail=run+'-nopledge@example.test';
+ const orphan=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:ref2,amount_cents:333,frequency:'once',donor_email:orphanEmail});assert.equal(orphan.body.matched,false);
+ rep=(await hit('/api/campaigns/reconcile')).body;assert.ok(rep.unattributed_donations.some(x=>x.donor_email===orphanEmail));
+ // amount mismatch does not match: a $1 donation for the donor leaves a second orphan, pledge already fulfilled
+ const wrong=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_3',amount_cents:100,frequency:'once',donor_email:donor});assert.equal(wrong.body.matched,false);
+ // one-to-one: a SECOND correct one-time donation does not re-link the already-fulfilled pledge (no double-count)
+ const second=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_4',amount_cents:5000,frequency:'once',donor_email:donor});assert.equal(second.body.matched,false);
+ assert.equal((await db.query('SELECT count(*)::int n FROM organizing_donations WHERE org_id=$1 AND matched_participation_id=$2',[org,pledgeId])).rows[0].n,1);
+ // recurring is deferred (milestone 2): a monthly donation to a monthly pledge stays unattributed, not falsely matched
+ const mdonor=run+'-monthly@example.test';assert.equal((await intake(fundraiser,mdonor,randomUUID(),{amount_cents:2000,frequency:'monthly'})).status,202);
+ const monthly=await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_5',amount_cents:2000,frequency:'monthly',donor_email:mdonor});assert.equal(monthly.body.matched,false);
+ // donated_at: a valid provider timestamp is stored verbatim; a malformed one is rejected 400
+ const when='2026-09-01T12:00:00.000Z';
+ await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_6',amount_cents:777,frequency:'once',donor_email:run+'-dated@example.test',donated_at:when});
+ assert.equal(new Date((await db.query('SELECT donated_at FROM organizing_donations WHERE org_id=$1 AND provider_ref=$2',[org,'ab_'+run+'_6'])).rows[0].donated_at).toISOString(),when);
+ assert.equal((await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:'ab_'+run+'_7',amount_cents:500,frequency:'once',donor_email:'dated-bad@example.test',donated_at:'not-a-date'})).status,400);
+ // reconcile is org-scoped: even an admin reading the foreign org's report never sees this org's donations
+ const foreignRep=(await hit('/api/campaigns/reconcile',undefined,undefined,cookie,foreign)).body;
+ assert.ok(Array.isArray(foreignRep.unattributed_donations)&&!foreignRep.unattributed_donations.some(x=>x.donor_email===orphanEmail));
+ });
await check('Follow-up drafts save, transition, and remain unsent',async()=>{
const r=await hit('/api/campaigns/tasks','POST',{campaign_id:primary,title:'Organizer checklist',body:'Review the campaign actions.',channel:'email',assignee:'Alex Organizer',shift_label:'Saturday park crew',due_at:new Date(Date.now()+86400000).toISOString()});assert.equal(r.status,201);const task=r.body.task.id;assert.equal(r.body.task.assignee,'Alex Organizer');assert.equal(r.body.task.shift_label,'Saturday park crew');
assert.equal((await hit('/api/campaigns/tasks/'+task,'PATCH',{status:'ready',body:'Updated draft'})).body.task.status,'ready');
@@ -141,6 +179,6 @@ try {
console.log(`${checks.length} integration journeys passed`);
} catch(error) {checks.push({name:'Execution failure',verdict:'FAIL',reason:error.message});console.error(error);process.exitCode=1;}
finally {
- if(campaigns.length){await db.query('DELETE FROM organizing_tasks WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_participation WHERE action_id=ANY($1::uuid[])',[actions]);await db.query("DELETE FROM organizing_consent_events WHERE org_id=$2 AND supporter_id IN (SELECT id FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2)",[run+'%',org]);await db.query("DELETE FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2",[run+'%',org]);await db.query('DELETE FROM organizing_actions WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_audit WHERE entity_id=ANY($1::uuid[])',[campaigns.concat(actions)]);await db.query('DELETE FROM organizing_campaigns WHERE id=ANY($1::uuid[])',[campaigns]);}
+ if(campaigns.length){await db.query("DELETE FROM organizing_donations WHERE org_id=$1 AND provider_ref LIKE $2",[org,'ab_'+run+'%']);await db.query('DELETE FROM organizing_tasks WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_participation WHERE action_id=ANY($1::uuid[])',[actions]);await db.query("DELETE FROM organizing_consent_events WHERE org_id=$2 AND supporter_id IN (SELECT id FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2)",[run+'%',org]);await db.query("DELETE FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2",[run+'%',org]);await db.query('DELETE FROM organizing_actions WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_audit WHERE entity_id=ANY($1::uuid[])',[campaigns.concat(actions)]);await db.query('DELETE FROM organizing_campaigns WHERE id=ANY($1::uuid[])',[campaigns]);}
await db.end();fs.mkdirSync('verification/platform',{recursive:true});fs.writeFileSync('verification/platform/api-results.json',JSON.stringify({timestamp:new Date().toISOString(),base,checks,cleanup:'Owned fixture campaign, action, supporter and participation records removed; audit receipt rows retained as verification history.'},null,2));
}
diff --git a/verification/platform/api-results.json b/verification/platform/api-results.json
index b9b3691..6e068ac 100644
--- a/verification/platform/api-results.json
+++ b/verification/platform/api-results.json
@@ -1,5 +1,5 @@
{
- "timestamp": "2026-09-09T22:16:57.572Z",
+ "timestamp": "2026-09-09T22:58:19.575Z",
"base": "http://127.0.0.1:7416",
"checks": [
{
@@ -46,6 +46,10 @@
"name": "Verified-consent state machine, mock dispatch, sticky opt-out, and immutable log",
"verdict": "PASS"
},
+ {
+ "name": "Donation reconciliation: sandbox ingest matches a pledge, is idempotent, and stays tenant-scoped",
+ "verdict": "PASS"
+ },
{
"name": "Follow-up drafts save, transition, and remain unsent",
"verdict": "PASS"
diff --git a/verification/platform/browser-results.json b/verification/platform/browser-results.json
index 34374c8..751f4e6 100644
--- a/verification/platform/browser-results.json
+++ b/verification/platform/browser-results.json
@@ -104,12 +104,12 @@
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
- "duration": 4113,
+ "duration": 2387,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T22:17:18.212Z",
+ "startTime": "2026-09-09T22:58:31.894Z",
"annotations": [],
"attachments": [
{
@@ -144,12 +144,12 @@
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
- "duration": 1298,
+ "duration": 1246,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T22:17:22.558Z",
+ "startTime": "2026-09-09T22:58:34.525Z",
"annotations": [],
"attachments": [
{
@@ -184,12 +184,12 @@
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
- "duration": 4158,
+ "duration": 18972,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T22:17:23.860Z",
+ "startTime": "2026-09-09T22:58:35.775Z",
"annotations": [],
"attachments": [
{
@@ -224,12 +224,12 @@
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
- "duration": 1933,
+ "duration": 8935,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T22:17:28.022Z",
+ "startTime": "2026-09-09T22:58:54.751Z",
"annotations": [],
"attachments": [
{
@@ -264,12 +264,12 @@
"workerIndex": 1,
"parallelIndex": 0,
"status": "passed",
- "duration": 3237,
+ "duration": 5504,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T22:17:30.195Z",
+ "startTime": "2026-09-09T22:59:04.090Z",
"annotations": [],
"attachments": [
{
@@ -304,12 +304,12 @@
"workerIndex": 1,
"parallelIndex": 0,
"status": "passed",
- "duration": 3879,
+ "duration": 1706,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T22:17:33.922Z",
+ "startTime": "2026-09-09T22:59:10.043Z",
"annotations": [],
"attachments": [
{
@@ -344,12 +344,12 @@
"workerIndex": 1,
"parallelIndex": 0,
"status": "passed",
- "duration": 4529,
+ "duration": 13529,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T22:17:37.806Z",
+ "startTime": "2026-09-09T22:59:11.753Z",
"annotations": [],
"attachments": [
{
@@ -384,12 +384,12 @@
"workerIndex": 1,
"parallelIndex": 0,
"status": "passed",
- "duration": 2248,
+ "duration": 2328,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T22:17:42.339Z",
+ "startTime": "2026-09-09T22:59:25.288Z",
"annotations": [],
"attachments": [
{
@@ -413,8 +413,8 @@
],
"errors": [],
"stats": {
- "startTime": "2026-09-09T22:17:18.004Z",
- "duration": 26634.139000000003,
+ "startTime": "2026-09-09T22:58:31.618Z",
+ "duration": 56044.723999999995,
"expected": 8,
"skipped": 0,
"unexpected": 0,
diff --git a/verification/platform/publication-results.json b/verification/platform/publication-results.json
index 051e696..daf040c 100644
--- a/verification/platform/publication-results.json
+++ b/verification/platform/publication-results.json
@@ -1,5 +1,5 @@
{
- "timestamp": "2026-09-09T22:18:33.007Z",
+ "timestamp": "2026-09-09T22:59:29.754Z",
"base": "http://127.0.0.1:7417",
"checks": [
{
@@ -13,6 +13,10 @@
{
"name": "Mock consent dispatcher is refused outside the sandbox",
"verdict": "PASS"
+ },
+ {
+ "name": "Mock donation ingest is refused outside the sandbox",
+ "verdict": "PASS"
}
],
"cleanup": "No records created or changed; activation and intake rejected."
diff --git a/verification/platform/screenshots/chromium-advocacy.png b/verification/platform/screenshots/chromium-advocacy.png
index 8832c80..531991f 100644
Binary files a/verification/platform/screenshots/chromium-advocacy.png and b/verification/platform/screenshots/chromium-advocacy.png differ
diff --git a/verification/platform/screenshots/chromium-campaign.png b/verification/platform/screenshots/chromium-campaign.png
index 4224c4b..c9621c9 100644
Binary files a/verification/platform/screenshots/chromium-campaign.png and b/verification/platform/screenshots/chromium-campaign.png differ
diff --git a/verification/platform/screenshots/chromium-consent.png b/verification/platform/screenshots/chromium-consent.png
index ef7fb31..178e722 100644
Binary files a/verification/platform/screenshots/chromium-consent.png and b/verification/platform/screenshots/chromium-consent.png differ
diff --git a/verification/platform/screenshots/chromium-event.png b/verification/platform/screenshots/chromium-event.png
index a3e70eb..4a9d619 100644
Binary files a/verification/platform/screenshots/chromium-event.png and b/verification/platform/screenshots/chromium-event.png differ
diff --git a/verification/platform/screenshots/chromium-fundraiser.png b/verification/platform/screenshots/chromium-fundraiser.png
index 5b278ee..cbbd82f 100644
Binary files a/verification/platform/screenshots/chromium-fundraiser.png and b/verification/platform/screenshots/chromium-fundraiser.png differ
diff --git a/verification/platform/screenshots/chromium-mobile.png b/verification/platform/screenshots/chromium-mobile.png
index f5743f5..4ccad56 100644
Binary files a/verification/platform/screenshots/chromium-mobile.png and b/verification/platform/screenshots/chromium-mobile.png differ
diff --git a/verification/platform/screenshots/chromium-receipt.png b/verification/platform/screenshots/chromium-receipt.png
index fd7e67b..5840c71 100644
Binary files a/verification/platform/screenshots/chromium-receipt.png and b/verification/platform/screenshots/chromium-receipt.png differ
diff --git a/verification/platform/screenshots/chromium-volunteer.png b/verification/platform/screenshots/chromium-volunteer.png
index ad43204..b434e15 100644
Binary files a/verification/platform/screenshots/chromium-volunteer.png and b/verification/platform/screenshots/chromium-volunteer.png differ
diff --git a/verification/platform/screenshots/webkit-advocacy.png b/verification/platform/screenshots/webkit-advocacy.png
index bbc393b..c9ed419 100644
Binary files a/verification/platform/screenshots/webkit-advocacy.png and b/verification/platform/screenshots/webkit-advocacy.png differ
diff --git a/verification/platform/screenshots/webkit-campaign.png b/verification/platform/screenshots/webkit-campaign.png
index cedc6a8..289c167 100644
Binary files a/verification/platform/screenshots/webkit-campaign.png and b/verification/platform/screenshots/webkit-campaign.png differ
diff --git a/verification/platform/screenshots/webkit-consent.png b/verification/platform/screenshots/webkit-consent.png
index e12f0b8..381d243 100644
Binary files a/verification/platform/screenshots/webkit-consent.png and b/verification/platform/screenshots/webkit-consent.png differ
diff --git a/verification/platform/screenshots/webkit-event.png b/verification/platform/screenshots/webkit-event.png
index 9c676df..1b62756 100644
Binary files a/verification/platform/screenshots/webkit-event.png and b/verification/platform/screenshots/webkit-event.png differ
diff --git a/verification/platform/screenshots/webkit-fundraiser.png b/verification/platform/screenshots/webkit-fundraiser.png
index d3680d9..b51c022 100644
Binary files a/verification/platform/screenshots/webkit-fundraiser.png and b/verification/platform/screenshots/webkit-fundraiser.png differ
diff --git a/verification/platform/screenshots/webkit-mobile.png b/verification/platform/screenshots/webkit-mobile.png
index ac3838b..4fe9976 100644
Binary files a/verification/platform/screenshots/webkit-mobile.png and b/verification/platform/screenshots/webkit-mobile.png differ
diff --git a/verification/platform/screenshots/webkit-receipt.png b/verification/platform/screenshots/webkit-receipt.png
index 9f3c4e7..e7dd0c0 100644
Binary files a/verification/platform/screenshots/webkit-receipt.png and b/verification/platform/screenshots/webkit-receipt.png differ
diff --git a/verification/platform/screenshots/webkit-volunteer.png b/verification/platform/screenshots/webkit-volunteer.png
index c8b9e03..990cdf3 100644
Binary files a/verification/platform/screenshots/webkit-volunteer.png and b/verification/platform/screenshots/webkit-volunteer.png differ
← 6d61307 Apply consent red-team fixes: fail-closed token secret + per
·
back to Norma Platform
·
chore: lint, refactor, v0.2.0 (session close) 2c3739d →