Skip to main content

Refund Flow (Stripe ↔ Tremendous)

This page documents how unused campaign budget comes back to the customer's card in a non-custodial architecture — and, honestly, what's shipped vs. what's still on the design board.

What you need to know (customer view)

When you end a campaign with unspent budget:

  1. Click End campaign in the dashboard.
  2. We compute the refundable amount: total_budget − approved_feedback_spend − platform_fee_on_approved.
  3. The amount is refunded to your original card within 5–10 business days.
  4. The platform fee is not refundable — it paid for the platform work that already happened (widget served, AI scoring, campaign slot).

This matches the Payout Rules page and the Terms of Service.

What's shipped today

  • ✅ Campaign activation: one Stripe Checkout with two line items (fee + budget).
  • ✅ Webhook idempotency: every Stripe event.id is recorded in processed_stripe_events so duplicate deliveries are no-ops.
  • ✅ Reward payout: when a response is approved, Tremendous disburses from the pooled balance.
  • ⚠️ End-campaign refund: manual — today, a tenant requests a refund via support@pay4feedback.com and we process it through Stripe's dashboard. The self-service End campaign → refund button is scoped for a later release. See issue tracker for timeline.

Why "non-custodial" makes refunds tricky

The reason traditional SaaS refunds are trivial (charge the card, refund the card) is that the money sits on the merchant's balance the whole time. Pay4Feedback's architecture deliberately moves reward budgets off our balance onto Tremendous's:

Customer card ──(Stripe Checkout €550)──▶ Stripe

├── €50 platform fee → Pay4Feedback operating balance
└── €500 → Tremendous pooled balance (earmarked for campaign N)

To refund the €500, we need to move money back leftward through the same stack. That's the "reverse flow" problem.

The canonical design (what we'll ship)

There are two reverse-flow patterns in the industry; we're choosing the second.

Pattern A — True two-leg reversal

  1. Pay4Feedback withdraws €500 from Tremendous pooled balance back to its own Stripe account (requires the Tremendous withdrawal API or a manual ACH transfer).
  2. Pay4Feedback refunds the customer's original payment_intent_id for €500 via Stripe's refund API.

Problem: Tremendous's withdrawal API ties up 3–5 business days. That turns a "5–10 business day" refund into "8–15 business days". Customer experience suffers.

Pattern B — Float absorption (chosen)

  1. Pay4Feedback refunds the customer €500 directly from its own Stripe balance (which holds accumulated platform fees + top-up reserves).
  2. The €500 that was earmarked in Tremendous stays in the pooled balance as a rolling float, earmarked against the next campaign funded by any tenant.
  3. Internal accounting: a campaign_refunds ledger records the refund; Tremendous pooled balance is reconciled nightly.

Advantages of Pattern B:

  • Customer gets the refund in 5–10 business days, matching Stripe's normal refund SLA.
  • No round-trip through Tremendous's withdrawal API.
  • The Tremendous float is a known liability that Pay4Feedback manages as working capital.

Cost of Pattern B:

  • Pay4Feedback bears the 1–2% Stripe refund processing fee (Stripe returns the original payment fee on refunds, but there's a marginal cost for cross-currency cases).
  • Pay4Feedback must hold enough Stripe-side reserve to cover concurrent refunds. For a platform processing €100K/month in campaigns with ~15% refund rate, that's €15K of working capital.

Data model (to ship)

New table campaign_refunds:

CREATE TABLE campaign_refunds (
id UUID PRIMARY KEY,
campaign_id UUID NOT NULL REFERENCES campaigns(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
stripe_refund_id VARCHAR(255) NOT NULL UNIQUE,
stripe_payment_intent_id VARCHAR(255) NOT NULL,
amount_cents INTEGER NOT NULL,
tremendous_float_cents INTEGER NOT NULL, -- what stays in pooled balance
reason VARCHAR(100), -- customer_request | campaign_end | platform_error
requested_by_user_id UUID, -- audit trail
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);

Edge cases (already thought through)

ScenarioHandling
Tremendous payout in flight when refund requestedRefund only unsent rewards. campaign_refunds.amount_cents = budget − approved_spend − in_flight_orders.
Customer disputes the Stripe charge (chargeback)Pay4Feedback absorbs the chargeback from operating balance. We reserve the right to suspend the tenant per the Terms.
Partial-refund recursion (customer refunds, then opens a new campaign)Fine — the Tremendous float naturally rotates through the next campaign's reward disbursement.
Currency mismatch (card in USD, Stripe EUR)Stripe's refund converts at the same rate as the original charge; Pay4Feedback bears FX spread.