[object Object]

← back to Norma Platform

Add donation refund reconciliation slice (TK-11335 Section A, local-safe)

437e1dc18f3ddf4e6863ea3be9ba070effa771f0 · 2026-09-10 13:15:47 -0700 · Steve Abrams

Sandbox-only mock provider refund ingest, mirroring the shipped one-time
reconciliation rails. No money moves; real keys, live signature-verified
webhooks, card collection, recurring billing, receipts, and prod deploy all
remain gated milestone-2 work.

- migration 030: append-only org-scoped organizing_donation_refunds ledger
  (cents, UNIQUE(org,provider,refund_ref) idempotency, FK->donations CASCADE),
  because a charge can be partially refunded multiple times.
- ingestRefund(): throws outside the sandbox; org from the authenticated scope
  never a payload field; locks the target contribution FOR UPDATE so concurrent
  refunds are serialized and can't over-refund past the unrefunded balance
  (rejects over-balance 400); 404 on unknown/cross-tenant ref.
- idempotency is identity-guarded (Cody red-team fix): a refund_ref already
  recorded against a DIFFERENT contribution is a 409 conflict, never a false
  "idempotent success" that silently drops the caller's refund. Guard applied to
  both the pre-insert dup check and the 23505 savepoint recovery.
- refunds netted into report + workspace stats + Insights card
  (refunded_cents, net_received_cents). Pledge match left intact; auto-releasing
  a fulfilled pledge on full refund deferred to milestone 2.
- tests: partial-nets, idempotent, over-balance 400, unknown-ref 404,
  tenant-scope 404, sequential cross-donation reuse 409, concurrent race
  (one 200 / one 409); sandbox-gate 409; migration 030 wired into harness.
  17/17 campaign integration journeys pass; tsc clean; eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 437e1dc18f3ddf4e6863ea3be9ba070effa771f0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 13:15:47 2026 -0700

    Add donation refund reconciliation slice (TK-11335 Section A, local-safe)
    
    Sandbox-only mock provider refund ingest, mirroring the shipped one-time
    reconciliation rails. No money moves; real keys, live signature-verified
    webhooks, card collection, recurring billing, receipts, and prod deploy all
    remain gated milestone-2 work.
    
    - migration 030: append-only org-scoped organizing_donation_refunds ledger
      (cents, UNIQUE(org,provider,refund_ref) idempotency, FK->donations CASCADE),
      because a charge can be partially refunded multiple times.
    - ingestRefund(): throws outside the sandbox; org from the authenticated scope
      never a payload field; locks the target contribution FOR UPDATE so concurrent
      refunds are serialized and can't over-refund past the unrefunded balance
      (rejects over-balance 400); 404 on unknown/cross-tenant ref.
    - idempotency is identity-guarded (Cody red-team fix): a refund_ref already
      recorded against a DIFFERENT contribution is a 409 conflict, never a false
      "idempotent success" that silently drops the caller's refund. Guard applied to
      both the pre-insert dup check and the 23505 savepoint recovery.
    - refunds netted into report + workspace stats + Insights card
      (refunded_cents, net_received_cents). Pledge match left intact; auto-releasing
      a fulfilled pledge on full refund deferred to milestone 2.
    - tests: partial-nets, idempotent, over-balance 400, unknown-ref 404,
      tenant-scope 404, sequential cross-donation reuse 409, concurrent race
      (one 200 / one 409); sandbox-gate 409; migration 030 wired into harness.
      17/17 campaign integration journeys pass; tsc clean; eslint 0 errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 app/api/campaigns/[[...path]]/route.ts     |  3 +-
 components/campaigns/CampaignWorkspace.tsx |  2 +-
 db/030_donation_refunds.sql                | 26 ++++++++++++
 docs/platform/RUNBOOK.md                   |  3 +-
 lib/campaigns/reconcile.ts                 | 68 ++++++++++++++++++++++++++++++
 lib/campaigns/store.ts                     |  2 +
 lib/campaigns/types.ts                     |  2 +-
 scripts/campaign-preview.sh                |  1 +
 scripts/test-instance.sh                   |  5 ++-
 tests/api-write-smoke.mjs                  |  2 +-
 tests/campaign-disabled.mjs                |  2 +
 tests/campaign-platform.mjs                | 38 +++++++++++++++++
 verification/platform/api-results.json     |  6 ++-
 13 files changed, 153 insertions(+), 7 deletions(-)

diff --git a/app/api/campaigns/[[...path]]/route.ts b/app/api/campaigns/[[...path]]/route.ts
index 1ac96c0..eea9c9d 100644
--- a/app/api/campaigns/[[...path]]/route.ts
+++ b/app/api/campaigns/[[...path]]/route.ts
@@ -2,7 +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 { ingestDonation, ingestRefund, 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';
@@ -34,6 +34,7 @@ export async function POST(request: NextRequest, context: Context) {
     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));
+    if (p.length === 2 && p[0] === 'donations' && p[1] === 'refund') return json(await ingestRefund(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 4a5f031..eef866e 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>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==='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>{workspace.stats.refunded_cents>0&&<li><strong>{dollars(workspace.stats.refunded_cents)}</strong> refunded · <strong>{dollars(workspace.stats.net_received_cents)}</strong> net received</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. Refunds are recorded as reversing ledger entries and netted from received totals. 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/030_donation_refunds.sql b/db/030_donation_refunds.sql
new file mode 100644
index 0000000..44888a0
--- /dev/null
+++ b/db/030_donation_refunds.sql
@@ -0,0 +1,26 @@
+-- Additive refund ledger for the campaign donation-reconciliation slice (TK-11335, Section A cont.).
+-- Org-scoped and in CENTS, mirroring organizing_donations. A single contribution can be refunded in
+-- MULTIPLE partial pieces by a real provider (ActBlue/Stripe), so refunds are a separate append-only
+-- ledger keyed to the donation rather than a column on it. No money moves here: rows are written only
+-- by the SANDBOX-ONLY mock provider refund ingest until a verified, signature-checked provider webhook
+-- is wired (milestone 2). The refund never mutates the donation's pledge match — a refund is recorded
+-- as a reversing entry and netted in the reconciliation totals; auto-releasing a fulfilled pledge on a
+-- full refund is a one-to-one product decision deferred to milestone 2.
+BEGIN;
+CREATE TABLE IF NOT EXISTS organizing_donation_refunds (
+  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  org_id uuid NOT NULL REFERENCES nonprofit_accounts(id),
+  -- the contribution being (partially) refunded; a refund is meaningless without it.
+  donation_id uuid NOT NULL REFERENCES organizing_donations(id) ON DELETE CASCADE,
+  provider text NOT NULL DEFAULT 'actblue' CHECK (provider IN ('actblue','other')),
+  -- provider's own refund transaction id; the idempotency key for repeated webhook deliveries.
+  refund_ref text NOT NULL,
+  amount_cents integer NOT NULL CHECK (amount_cents > 0),
+  refunded_at timestamptz NOT NULL DEFAULT now(),
+  created_at timestamptz NOT NULL DEFAULT now(),
+  -- one row per provider refund transaction per org: makes refund-webhook redelivery idempotent.
+  UNIQUE (org_id, provider, refund_ref)
+);
+CREATE INDEX IF NOT EXISTS organizing_donation_refunds_org ON organizing_donation_refunds(org_id, refunded_at DESC);
+CREATE INDEX IF NOT EXISTS organizing_donation_refunds_donation ON organizing_donation_refunds(donation_id);
+COMMIT;
diff --git a/docs/platform/RUNBOOK.md b/docs/platform/RUNBOOK.md
index 0185184..48e2a1e 100644
--- a/docs/platform/RUNBOOK.md
+++ b/docs/platform/RUNBOOK.md
@@ -38,7 +38,8 @@ The gate runs its own isolated process with public campaign intake disabled. Whi
 - 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. 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.
+- 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/receipts, and any production donation write are gated milestone-2 work.
+- Refund reconciliation (migration 030): a contribution can be refunded — partially and more than once — so refunds are an append-only org-scoped `organizing_donation_refunds` ledger keyed to the donation, not a column, idempotent on (org, provider, refund_ref). The ingest is the SANDBOX-ONLY mock provider push that throws outside the sandbox and always writes to the AUTHENTICATED organizer's org; it locks the target contribution FOR UPDATE (so concurrent refunds are serialized and can never over-refund past the amount) and rejects a refund exceeding the unrefunded balance. A refund is recorded as a reversing entry and NETTED from received totals (`refunded_cents`, `net_received_cents`); it deliberately does NOT mutate the donation's pledge match — auto-releasing a fulfilled pledge on a full refund is a one-to-one decision deferred to milestone 2. The PUBLIC, signature-verified refund-webhook transport and any production refund 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
index 38c9fd0..6571417 100644
--- a/lib/campaigns/reconcile.ts
+++ b/lib/campaigns/reconcile.ts
@@ -66,6 +66,72 @@ export async function ingestDonation(s: Scope, payload: Record<string, unknown>)
   });
 }
 
+// SANDBOX-ONLY mock provider REFUND ingest. Models an ActBlue-shaped refund push against a prior
+// contribution and records it as a reversing ledger entry (a real provider can issue several partial
+// refunds, so refunds are an append-only ledger, not a column). Throws outside the sandbox exactly like
+// ingestDonation — the PUBLIC, signature-verified webhook transport (and any production write) is a
+// gated milestone. The org is the AUTHENTICATED organizer's org (s.org), never a payload field, so a
+// refund can never touch another tenant's contribution. The target contribution is located by (org,
+// provider, provider_ref) and LOCKED FOR UPDATE, which serializes every refund against it so the
+// remaining-balance check is race-safe; idempotent on (org, provider, refund_ref) so a redelivered
+// refund returns the original row rather than double-reversing. The donation's pledge match is left
+// intact (a refund nets out in the totals); auto-releasing a fulfilled pledge on a full refund is a
+// one-to-one product decision deferred to milestone 2.
+const FIND_REFUND = `SELECT r.id, r.amount_cents, r.donation_id,
+  ((SELECT COALESCE(sum(amount_cents),0) FROM organizing_donation_refunds WHERE donation_id=r.donation_id) >= d.amount_cents) fully
+  FROM organizing_donation_refunds r JOIN organizing_donations d ON d.id=r.donation_id
+  WHERE r.org_id=$1 AND r.provider=$2 AND r.refund_ref=$3`;
+export async function ingestRefund(s: Scope, payload: Record<string, unknown>) {
+  if (!sandbox()) throw new CampaignError(409, 'A verified payment-provider webhook is not configured for this deployment. Refund ingest is available in the sandbox only.');
+  const provider = choice(payload.provider ?? 'actblue', ['actblue', 'other'] as const, 'provider');
+  const refundRef = text(payload.refund_ref, 'Refund reference', 200);
+  const donationRef = text(payload.provider_ref, 'Original contribution reference', 200);
+  const amount = number(payload.amount_cents, 'Refund amount in cents', 1, 100000000);
+  let refundedAt: string | null = null;
+  const rawDate = payload.refunded_at;
+  if (rawDate !== undefined && rawDate !== null && rawDate !== '') {
+    if (typeof rawDate !== 'string' || !Number.isFinite(Date.parse(rawDate))) throw new CampaignError(400, 'refunded_at must be a valid date.');
+    refundedAt = new Date(rawDate).toISOString();
+  }
+  return transaction(async client => {
+    // Lock the target contribution: not found (wrong ref, or another tenant's ref) is a clean 404, and
+    // the row lock serializes concurrent refunds so two different refund_refs can't both pass the
+    // remaining-balance check and over-refund. Same-provider only — a refund_ref is issued by the
+    // provider that took the charge.
+    const donation = (await client.query('SELECT id, amount_cents FROM organizing_donations WHERE org_id=$1 AND provider=$2 AND provider_ref=$3 FOR UPDATE', [s.org, provider, donationRef])).rows[0];
+    if (!donation) throw new CampaignError(404, 'The referenced contribution was not found for this organization.');
+    // A refund_ref already on file is an idempotent redelivery ONLY if it names THIS same contribution.
+    // A refund_ref reused against a DIFFERENT contribution is a real conflict, not a replay — returning
+    // it as "idempotent success" would silently drop the caller's actual refund and understate the
+    // org's refunded total. (org, provider, refund_ref) is globally unique, so this is the same-provider
+    // reuse-across-charges case; reject it loudly rather than swallow it.
+    const dup = (await client.query(FIND_REFUND, [s.org, provider, refundRef])).rows[0];
+    if (dup) {
+      if (dup.donation_id !== donation.id) throw new CampaignError(409, 'This refund reference is already recorded against a different contribution.');
+      return { refund: dup.id, donation: dup.donation_id, amount_cents: dup.amount_cents, fully_refunded: dup.fully, idempotent: true };
+    }
+    const already = (await client.query('SELECT COALESCE(sum(amount_cents),0)::int total FROM organizing_donation_refunds WHERE donation_id=$1', [donation.id])).rows[0].total;
+    if (amount > donation.amount_cents - already) throw new CampaignError(400, 'Refund exceeds the unrefunded balance of this contribution.');
+    // The UNIQUE(org,provider,refund_ref) still guards the pathological case of one refund_ref racing
+    // against two different contributions (each under its own lock, so they don't serialize): the
+    // loser's 23505 is recovered without poisoning the transaction. The winner may be a DIFFERENT
+    // contribution, so re-check identity exactly like the pre-insert path — recover as idempotent ONLY
+    // when the winning row is this same contribution, else surface the conflict (never a dropped refund).
+    await client.query('SAVEPOINT add_refund');
+    try {
+      const inserted = (await client.query('INSERT INTO organizing_donation_refunds(org_id,donation_id,provider,refund_ref,amount_cents,refunded_at) VALUES($1,$2,$3,$4,$5,COALESCE($6::timestamptz,now())) RETURNING id', [s.org, donation.id, provider, refundRef, amount, refundedAt])).rows[0];
+      await audit(client, s, 'donation.refunded', donation.id);
+      return { refund: inserted.id, donation: donation.id, amount_cents: amount, fully_refunded: already + amount >= donation.amount_cents, idempotent: false };
+    } catch (e) {
+      if ((e as { code?: string }).code !== '23505') throw e;
+      await client.query('ROLLBACK TO SAVEPOINT add_refund');
+      const raced = (await client.query(FIND_REFUND, [s.org, provider, refundRef])).rows[0];
+      if (!raced || raced.donation_id !== donation.id) throw new CampaignError(409, 'This refund reference is already recorded against a different contribution.');
+      return { refund: raced.id, donation: raced.donation_id, amount_cents: raced.amount_cents, fully_refunded: raced.fully, idempotent: true };
+    }
+  });
+}
+
 // 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) {
@@ -74,6 +140,8 @@ export async function reconcileReport(org: string) {
     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_donation_refunds WHERE org_id=$1) refunded_cents,
+      ((SELECT COALESCE(sum(amount_cents),0) FROM organizing_donations WHERE org_id=$1) - (SELECT COALESCE(sum(amount_cents),0) FROM organizing_donation_refunds WHERE org_id=$1))::float8 net_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]),
diff --git a/lib/campaigns/store.ts b/lib/campaigns/store.ts
index bdf77a6..2abcdc0 100644
--- a/lib/campaigns/store.ts
+++ b/lib/campaigns/store.ts
@@ -32,6 +32,8 @@ export async function workspace(org: string) {
       count(*) FILTER(WHERE p.status='checked_in')::int checked_in,
       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_donation_refunds WHERE org_id=$1) refunded_cents,
+      ((SELECT COALESCE(sum(amount_cents),0) FROM organizing_donations WHERE org_id=$1) - (SELECT COALESCE(sum(amount_cents),0) FROM organizing_donation_refunds WHERE org_id=$1))::float8 net_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]),
diff --git a/lib/campaigns/types.ts b/lib/campaigns/types.ts
index 73197ae..911cafe 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; 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 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; refunded_cents: number; net_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 a4ce8ab..9ede0cd 100644
--- a/scripts/campaign-preview.sh
+++ b/scripts/campaign-preview.sh
@@ -9,4 +9,5 @@ psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/026_campaign
 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
+psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/030_donation_refunds.sql
 exec bash scripts/test-instance.sh
diff --git a/scripts/test-instance.sh b/scripts/test-instance.sh
index 3304385..9f229f2 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 db/029_donation_reconciliation.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 db/030_donation_refunds.sql; do
   if psql "$DATABASE_URL" -v ON_ERROR_STOP=0 -f "$m" >/dev/null 2>&1; then
     echo "[test-instance] ensured $m"
   else
@@ -58,6 +58,9 @@ missing=$(psql "$DATABASE_URL" -Atc "
     UNION ALL
     SELECT 'organizing_donations (missing migration 029)'
       WHERE to_regclass('public.organizing_donations') IS NULL
+    UNION ALL
+    SELECT 'organizing_donation_refunds (missing migration 030)'
+      WHERE to_regclass('public.organizing_donation_refunds') 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/api-write-smoke.mjs b/tests/api-write-smoke.mjs
index 71b295e..5d8c3d1 100644
--- a/tests/api-write-smoke.mjs
+++ b/tests/api-write-smoke.mjs
@@ -39,7 +39,7 @@ const MUT = ['POST', 'PUT', 'PATCH', 'DELETE'];
 // UNAUTH (safe, rejected before side-effects) but NOT fired as admin, so the suite
 // never sends an email, hits a paid model, or makes an outbound API call.
 const NO_AUTHED_FIRE =
-  /\/(send|send-test|gmail|slack|cron|ingest|community|generate|auto-generate|rewrite|score|discover|enrich|orchestrate|dispatch|digest|compute|student-finder|suggest|oauth|sync|draft-reply|data-explorer|chat|ai-chat)(\/|$)|\/ai\//;
+  /\/(send|send-test|gmail|slack|cron|ingest|refund|community|generate|auto-generate|rewrite|score|discover|enrich|orchestrate|dispatch|digest|compute|student-finder|suggest|oauth|sync|draft-reply|data-explorer|chat|ai-chat)(\/|$)|\/ai\//;
 
 // Resolve a REAL org id so org-scoped INSERTs satisfy their org_id FK and the
 // tenant path is exercised faithfully (a nonexistent org id triggers FK-violation
diff --git a/tests/campaign-disabled.mjs b/tests/campaign-disabled.mjs
index e9effd7..5ae2a7e 100644
--- a/tests/campaign-disabled.mjs
+++ b/tests/campaign-disabled.mjs
@@ -18,5 +18,7 @@ const disp=await fetch(base+'/api/campaigns/consent',{method:'POST',headers:{Coo
 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'});
+const refund=await fetch(base+'/api/campaigns/donations/refund',{method:'POST',headers:{Cookie:cookie,'Content-Type':'application/json'},body:JSON.stringify({provider:'actblue',provider_ref:randomUUID(),refund_ref:randomUUID(),amount_cents:5000})});assert.equal(refund.status,409);
+checks.push({name:'Mock donation refund 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 43e8b31..81ae3e2 100644
--- a/tests/campaign-platform.mjs
+++ b/tests/campaign-platform.mjs
@@ -159,6 +159,44 @@ try {
     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('Refund reconciliation: sandbox refund nets received, is idempotent, bounded, and tenant-scoped',async()=>{
+    // A fresh one-time contribution to refund against.
+    const rdonor=run+'-refund@example.test';const dref='ab_'+run+'_refund_charge';
+    await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:dref,amount_cents:6000,frequency:'once',donor_email:rdonor});
+    const before=(await hit('/api/campaigns/reconcile')).body.totals;
+    // A partial refund records a reversing entry and nets out of received; the donation row is untouched.
+    const r1=await hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:dref,refund_ref:'ab_'+run+'_refund_1',amount_cents:2000});
+    assert.equal(r1.status,200);assert.equal(r1.body.idempotent,false);assert.equal(r1.body.fully_refunded,false);
+    const after=(await hit('/api/campaigns/reconcile')).body.totals;
+    assert.equal(after.refunded_cents-before.refunded_cents,2000);assert.equal(before.net_received_cents-after.net_received_cents,2000);assert.equal(after.received_cents,before.received_cents);
+    assert.equal((await db.query('SELECT count(*)::int n FROM organizing_donation_refunds WHERE org_id=$1 AND refund_ref=$2',[org,'ab_'+run+'_refund_1'])).rows[0].n,1);
+    // Redelivery of the SAME refund_ref is idempotent — no double reversal.
+    const dup=await hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:dref,refund_ref:'ab_'+run+'_refund_1',amount_cents:2000});
+    assert.equal(dup.body.idempotent,true);assert.equal(dup.body.refund,r1.body.refund);
+    assert.equal((await db.query('SELECT count(*)::int n FROM organizing_donation_refunds WHERE org_id=$1 AND refund_ref=$2',[org,'ab_'+run+'_refund_1'])).rows[0].n,1);
+    // A refund exceeding the unrefunded balance (2000 already refunded of 6000; 5000 > 4000) is rejected.
+    assert.equal((await hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:dref,refund_ref:'ab_'+run+'_refund_over',amount_cents:5000})).status,400);
+    // Refunding the exact remaining balance fully refunds it.
+    const r2=await hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:dref,refund_ref:'ab_'+run+'_refund_2',amount_cents:4000});
+    assert.equal(r2.body.fully_refunded,true);
+    // A refund against an unknown contribution ref is a clean 404, never a write.
+    assert.equal((await hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:'ab_'+run+'_nope',refund_ref:'ab_'+run+'_refund_3',amount_cents:100})).status,404);
+    // Tenant scope: the foreign org cannot refund THIS org's contribution (its ref is invisible → 404).
+    assert.equal((await hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:dref,refund_ref:'ab_'+run+'_refund_4',amount_cents:100},cookie,foreign)).status,404);
+    assert.equal((await db.query('SELECT count(*)::int n FROM organizing_donation_refunds WHERE refund_ref=$1',['ab_'+run+'_refund_4'])).rows[0].n,0);
+    // A refund_ref already recorded against a DIFFERENT contribution is a conflict (409), never a false
+    // idempotent "success" that silently drops this caller's refund. (Cody TK-11335 red-team hole #1.)
+    const dref2=run+'_refund_charge2';await hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:dref2,amount_cents:4000,frequency:'once',donor_email:run+'-refund2@example.test'});
+    assert.equal((await hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:dref2,refund_ref:'ab_'+run+'_refund_1',amount_cents:500})).status,409);
+    assert.equal((await db.query('SELECT donation_id FROM organizing_donation_refunds WHERE org_id=$1 AND refund_ref=$2',[org,'ab_'+run+'_refund_1'])).rows.length,1);
+    // Two CONCURRENT refunds sharing one refund_ref across two different contributions: exactly one wins
+    // (200) and the other conflicts (409) — never two successes, never a silently dropped write.
+    const c1=run+'_con_1',c2=run+'_con_2',cref='ab_'+run+'_con_shared';
+    await Promise.all([hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:c1,amount_cents:5000,frequency:'once',donor_email:run+'-c1@example.test'}),hit('/api/campaigns/donations/ingest','POST',{provider:'actblue',provider_ref:c2,amount_cents:5000,frequency:'once',donor_email:run+'-c2@example.test'})]);
+    const race=await Promise.all([hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:c1,refund_ref:cref,amount_cents:1000}),hit('/api/campaigns/donations/refund','POST',{provider:'actblue',provider_ref:c2,refund_ref:cref,amount_cents:2000})]);
+    assert.deepEqual(race.map(r=>r.status).sort(),[200,409]);
+    assert.equal((await db.query('SELECT count(*)::int n FROM organizing_donation_refunds WHERE org_id=$1 AND refund_ref=$2',[org,cref])).rows[0].n,1);
+  });
   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');
diff --git a/verification/platform/api-results.json b/verification/platform/api-results.json
index 6e068ac..ec71e0a 100644
--- a/verification/platform/api-results.json
+++ b/verification/platform/api-results.json
@@ -1,5 +1,5 @@
 {
-  "timestamp": "2026-09-09T22:58:19.575Z",
+  "timestamp": "2026-09-10T20:15:17.630Z",
   "base": "http://127.0.0.1:7416",
   "checks": [
     {
@@ -50,6 +50,10 @@
       "name": "Donation reconciliation: sandbox ingest matches a pledge, is idempotent, and stays tenant-scoped",
       "verdict": "PASS"
     },
+    {
+      "name": "Refund reconciliation: sandbox refund nets received, is idempotent, bounded, and tenant-scoped",
+      "verdict": "PASS"
+    },
     {
       "name": "Follow-up drafts save, transition, and remain unsent",
       "verdict": "PASS"

← 2c3739d chore: lint, refactor, v0.2.0 (session close)  ·  back to Norma Platform  ·  Stop video re-bloat on feature branch: gitignore cta/rec + d 2626361 →