Back to Blog

SaaS Payment Failure Handling: Dunning, Retry Logic, Grace Periods, and Payment Method Updates

Build robust payment failure handling for your SaaS. Covers Stripe dunning with smart retries, grace period enforcement, in-app payment method update flow, failed charge webhooks, and account suspension logic.

Viprasol Tech Team
May 1, 2027
13 min read

Payment failures are inevitable โ€” cards expire, banks decline charges, accounts run out of funds. How you handle those failures determines whether you retain the customer or lose them permanently. The best dunning systems recover 60โ€“80% of failed payments automatically through smart retries and proactive communication, before the customer even realizes there's a problem.

This guide covers Stripe's smart retry system, grace period enforcement, in-app notification banners, and account suspension with a clean reactivation path.

Database Schema

-- Track payment failure state on subscriptions
ALTER TABLE subscriptions ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'active';
  -- 'active' | 'past_due' | 'unpaid' | 'suspended'
ALTER TABLE subscriptions ADD COLUMN past_due_since     TIMESTAMPTZ;
ALTER TABLE subscriptions ADD COLUMN suspension_date    TIMESTAMPTZ;  -- When to suspend if not recovered
ALTER TABLE subscriptions ADD COLUMN last_payment_error TEXT;         -- Stripe decline code
ALTER TABLE subscriptions ADD COLUMN retry_count        INTEGER NOT NULL DEFAULT 0;

-- Payment failure event log
CREATE TABLE payment_failures (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id      UUID NOT NULL REFERENCES workspaces(id),
  subscription_id   TEXT NOT NULL,  -- Stripe subscription ID
  invoice_id        TEXT NOT NULL,  -- Stripe invoice ID
  amount            INTEGER NOT NULL,  -- Cents
  currency          TEXT NOT NULL DEFAULT 'usd',
  decline_code      TEXT,             -- 'insufficient_funds' | 'card_expired' | etc.
  failure_message   TEXT,
  attempt_count     INTEGER NOT NULL DEFAULT 1,
  recovered_at      TIMESTAMPTZ,      -- NULL = still failed
  created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_payment_failures_workspace ON payment_failures(workspace_id, created_at DESC);
CREATE INDEX idx_payment_failures_invoice   ON payment_failures(invoice_id);

Stripe Configuration: Smart Retries + Dunning

Configure in the Stripe Dashboard or via API:

// lib/stripe-setup.ts โ€” run once at startup or in setup script
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Configure subscription settings (via Stripe Dashboard is preferred,
// but you can also set per-subscription)
async function createSubscriptionWithDunning(customerId: string, priceId: string) {
  return stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_settings: {
      payment_method_types: ["card"],
      save_default_payment_method: "on_subscription",
    },
    // How long after payment failure before subscription is cancelled
    // Stripe's smart retries happen within this window
    collection_method: "charge_automatically",

    // Days after first failure before subscription moves to 'unpaid'
    // Set in Stripe Dashboard: Settings โ†’ Billing โ†’ Retry schedule
    // Typical: retry on day 3, day 5, day 7 โ€” then mark unpaid on day 15
  });
}

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Webhook: Handle Payment Failures

// app/api/webhooks/stripe/route.ts โ€” relevant cases
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { sendPaymentFailureEmail } from "@/lib/email/payment-failure";
import { sendPaymentRecoveredEmail } from "@/lib/email/payment-recovered";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const GRACE_PERIOD_DAYS = 7; // Days before suspension after 'past_due'

export async function handleStripeWebhook(event: Stripe.Event) {
  switch (event.type) {

    // First payment attempt fails
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      const subscriptionId = invoice.subscription as string;

      const workspace = await prisma.workspace.findFirst({
        where: { subscription: { stripeSubscriptionId: subscriptionId } },
        include: {
          subscription: true,
          owner: { select: { email: true, name: true } },
        },
      });

      if (!workspace) break;

      const attemptCount = invoice.attempt_count ?? 1;
      const declineCode = (invoice.last_finalization_error as any)?.decline_code ?? null;

      // Record failure
      await prisma.paymentFailure.create({
        data: {
          workspaceId:    workspace.id,
          subscriptionId,
          invoiceId:      invoice.id,
          amount:         invoice.amount_due,
          currency:       invoice.currency,
          declineCode,
          failureMessage: invoice.last_finalization_error?.message ?? null,
          attemptCount,
        },
      });

      // Move to past_due on first failure
      if (attemptCount === 1) {
        const suspensionDate = new Date(
          Date.now() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000
        );

        await prisma.subscription.update({
          where: { stripeSubscriptionId: subscriptionId },
          data: {
            paymentStatus:   "past_due",
            pastDueSince:    new Date(),
            suspensionDate,
            lastPaymentError: declineCode,
            retryCount:      { increment: 1 },
          },
        });

        // Send email: first failure notice + link to update payment method
        await sendPaymentFailureEmail({
          to:             workspace.owner.email,
          userName:       workspace.owner.name,
          amount:         invoice.amount_due,
          currency:       invoice.currency,
          declineCode,
          daysUntilSuspension: GRACE_PERIOD_DAYS,
          updatePaymentUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing?update_payment=true`,
        });
      }
      break;
    }

    // Payment recovered (Stripe retry succeeded or customer updated payment method)
    case "invoice.payment_succeeded": {
      const invoice = event.data.object as Stripe.Invoice;
      const subscriptionId = invoice.subscription as string;

      if (!invoice.billing_reason?.includes("subscription")) break;

      // Check if this was a recovery (was past_due)
      const sub = await prisma.subscription.findFirst({
        where: { stripeSubscriptionId: subscriptionId, paymentStatus: { not: "active" } },
        include: { workspace: { include: { owner: { select: { email: true, name: true } } } } },
      });

      if (!sub) break; // Was already active โ€” normal renewal

      // Mark failure as recovered
      await prisma.paymentFailure.updateMany({
        where: { subscriptionId, recoveredAt: null },
        data:  { recoveredAt: new Date() },
      });

      // Reset subscription status
      await prisma.subscription.update({
        where: { stripeSubscriptionId: subscriptionId },
        data: {
          paymentStatus:   "active",
          pastDueSince:    null,
          suspensionDate:  null,
          lastPaymentError: null,
          retryCount:      0,
        },
      });

      await sendPaymentRecoveredEmail({
        to:       sub.workspace.owner.email,
        userName: sub.workspace.owner.name,
        amount:   invoice.amount_paid,
        currency: invoice.currency,
      });
      break;
    }

    // Stripe gives up โ€” subscription moves to 'unpaid'
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      const previous = event.data.previous_attributes as any;

      // Detect transition to 'unpaid' (Stripe exhausted retries)
      if (previous?.status !== "unpaid" && subscription.status === "unpaid") {
        await prisma.subscription.updateMany({
          where: { stripeSubscriptionId: subscription.id },
          data:  { paymentStatus: "unpaid" },
        });
        // Trigger immediate suspension or final warning email
      }
      break;
    }
  }
}

Enforcement: Suspend Past-Due Accounts

// workers/dunning-enforcer.ts โ€” run via cron every hour
import { prisma } from "@/lib/prisma";
import { sendSuspensionWarningEmail } from "@/lib/email/suspension-warning";

export async function enforceDunning(): Promise<void> {
  const now = new Date();

  // 1. Find workspaces 24h before suspension โ€” send final warning
  const warningThreshold = new Date(now.getTime() + 24 * 60 * 60 * 1000);
  const nearingSuspension = await prisma.subscription.findMany({
    where: {
      paymentStatus: "past_due",
      suspensionDate: {
        gte: now,
        lte: warningThreshold,
      },
    },
    include: {
      workspace: {
        include: { owner: { select: { email: true, name: true } } },
      },
    },
  });

  for (const sub of nearingSuspension) {
    await sendSuspensionWarningEmail({
      to:       sub.workspace.owner.email,
      userName: sub.workspace.owner.name,
      hoursUntilSuspension: Math.ceil(
        ((sub.suspensionDate?.getTime() ?? now.getTime()) - now.getTime()) / 3600000
      ),
      updatePaymentUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing?update_payment=true`,
    });
  }

  // 2. Suspend workspaces past their suspension date
  const toSuspend = await prisma.subscription.findMany({
    where: {
      paymentStatus: "past_due",
      suspensionDate: { lte: now },
    },
    select: { id: true, workspaceId: true },
  });

  if (toSuspend.length > 0) {
    await prisma.subscription.updateMany({
      where: { id: { in: toSuspend.map((s) => s.id) } },
      data:  { paymentStatus: "suspended" },
    });

    console.log(`Suspended ${toSuspend.length} workspace(s) for non-payment`);
  }
}

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

In-App Banner: Past-Due Notice

// components/billing/payment-failure-banner.tsx
"use client";

import { AlertTriangle, CreditCard, X } from "lucide-react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDistanceToNow } from "date-fns";

interface PaymentFailureBannerProps {
  paymentStatus:   "past_due" | "unpaid" | "suspended";
  suspensionDate:  Date | null;
  declineCode:     string | null;
  isOwner:         boolean;
}

const DECLINE_MESSAGES: Record<string, string> = {
  insufficient_funds: "Your card has insufficient funds.",
  card_expired:       "Your card has expired.",
  do_not_honor:       "Your bank declined the charge.",
  lost_card:          "Your card was reported lost.",
  stolen_card:        "Your card was reported stolen.",
};

export function PaymentFailureBanner({
  paymentStatus,
  suspensionDate,
  declineCode,
  isOwner,
}: PaymentFailureBannerProps) {
  const router = useRouter();
  const [dismissed, setDismissed] = useState(false);

  if (dismissed || paymentStatus === "active") return null;

  const isSuspended = paymentStatus === "suspended";
  const declineMessage = declineCode ? DECLINE_MESSAGES[declineCode] : null;
  const timeUntilSuspension = suspensionDate
    ? formatDistanceToNow(suspensionDate, { addSuffix: true })
    : null;

  return (
    <div className={`
      flex items-start gap-3 px-4 py-3 border-b text-sm
      ${isSuspended
        ? "bg-red-50 border-red-200 text-red-900"
        : "bg-orange-50 border-orange-200 text-orange-900"
      }
    `}>
      <AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />

      <div className="flex-1 min-w-0">
        {isSuspended ? (
          <span className="font-medium">
            Your account has been suspended due to a failed payment.{" "}
            {isOwner ? "Update your payment method to reactivate." : "Contact your workspace owner."}
          </span>
        ) : (
          <span>
            <span className="font-medium">Payment failed.</span>{" "}
            {declineMessage && <span>{declineMessage} </span>}
            {timeUntilSuspension && (
              <span>Your account will be suspended {timeUntilSuspension} if not resolved.</span>
            )}
          </span>
        )}
      </div>

      {isOwner && (
        <button
          onClick={() => router.push("/settings/billing?update_payment=true")}
          className={`
            flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-semibold whitespace-nowrap flex-shrink-0
            ${isSuspended
              ? "bg-red-600 text-white hover:bg-red-700"
              : "bg-orange-600 text-white hover:bg-orange-700"
            }
          `}
        >
          <CreditCard className="w-3.5 h-3.5" />
          Update payment
        </button>
      )}

      {!isSuspended && (
        <button
          onClick={() => setDismissed(true)}
          className="text-orange-500 hover:text-orange-700 flex-shrink-0"
          aria-label="Dismiss"
        >
          <X className="w-4 h-4" />
        </button>
      )}
    </div>
  );
}

Update Payment Method Flow

// app/api/billing/update-payment-method/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";

// Step 1: Create a Stripe Setup Intent for collecting new payment method
export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session?.user || session.user.role !== "owner") {
    return NextResponse.json({ error: "Only owners can update payment methods" }, { status: 403 });
  }

  const workspace = await prisma.workspace.findUnique({
    where: { id: session.user.workspaceId },
    include: { subscription: true },
  });

  if (!workspace?.subscription?.stripeCustomerId) {
    return NextResponse.json({ error: "No billing account found" }, { status: 404 });
  }

  const setupIntent = await stripe.setupIntents.create({
    customer:       workspace.subscription.stripeCustomerId,
    payment_method_types: ["card"],
    usage:          "off_session", // Will be charged automatically
    metadata: {
      workspaceId:    workspace.id,
      subscriptionId: workspace.subscription.stripeSubscriptionId ?? "",
    },
  });

  return NextResponse.json({ clientSecret: setupIntent.client_secret });
}
// app/settings/billing/update-payment-form.tsx โ€” Stripe Elements
"use client";

import { useState } from "react";
import { loadStripe } from "@stripe/stripe-js";
import {
  Elements,
  CardElement,
  useStripe,
  useElements,
} from "@stripe/react-stripe-js";

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

function UpdatePaymentForm({ onSuccess }: { onSuccess: () => void }) {
  const stripe = useStripe();
  const elements = useElements();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!stripe || !elements) return;

    setLoading(true);
    setError(null);

    try {
      // 1. Get Setup Intent client secret from server
      const res = await fetch("/api/billing/update-payment-method", {
        method: "POST",
      });
      const { clientSecret } = await res.json();

      // 2. Confirm Setup Intent with new card
      const { error: stripeError } = await stripe.confirmCardSetup(clientSecret, {
        payment_method: { card: elements.getElement(CardElement)! },
      });

      if (stripeError) throw new Error(stripeError.message);

      // 3. Notify server to retry the outstanding invoice
      await fetch("/api/billing/retry-invoice", { method: "POST" });

      onSuccess();
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to update payment method");
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div className="border border-gray-300 rounded-lg p-3">
        <CardElement
          options={{
            style: {
              base: { fontSize: "14px", color: "#111827", "::placeholder": { color: "#9ca3af" } },
            },
          }}
        />
      </div>
      {error && <p className="text-sm text-red-600">{error}</p>}
      <button
        type="submit"
        disabled={loading || !stripe}
        className="w-full py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:opacity-50"
      >
        {loading ? "Updatingโ€ฆ" : "Save payment method"}
      </button>
    </form>
  );
}

export function UpdatePaymentMethodPage() {
  const [success, setSuccess] = useState(false);

  return (
    <Elements stripe={stripePromise}>
      {success ? (
        <div className="text-center py-8">
          <p className="text-green-600 font-medium">โœ“ Payment method updated successfully</p>
          <p className="text-sm text-gray-500 mt-1">Your outstanding invoice will be retried shortly.</p>
        </div>
      ) : (
        <UpdatePaymentForm onSuccess={() => setSuccess(true)} />
      )}
    </Elements>
  );
}

Middleware: Block Suspended Workspaces

// middleware.ts โ€” gate access for suspended accounts
export async function middleware(req: NextRequest) {
  const session = await getSession(req);
  if (!session?.user) return NextResponse.next();

  const isSuspended = session.user.paymentStatus === "suspended";
  const isAllowedPath = [
    "/settings/billing",
    "/api/billing",
    "/api/webhooks",
    "/goodbye",
    "/api/auth",
  ].some((p) => req.nextUrl.pathname.startsWith(p));

  if (isSuspended && !isAllowedPath) {
    return NextResponse.redirect(new URL("/settings/billing?suspended=true", req.url));
  }

  return NextResponse.next();
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic dunning (webhook handler + banner)1 dev2โ€“3 days$600โ€“1,200
Full dunning system (retry + grace + suspension + email)1โ€“2 devs1โ€“2 weeks$3,000โ€“6,000
+ Update payment method flow + middleware gate1 dev3โ€“5 days$1,000โ€“2,000

Recovery rates with good dunning: Stripe Smart Retries alone recover ~50% of failed payments. Adding in-app banners and email sequences pushes that to 70โ€“80%.

See Also


Working With Viprasol

Failed payments that aren't handled gracefully turn fixable churn into permanent churn. Our team builds dunning systems that recover the majority of failed charges automatically โ€” with proactive emails timed to Stripe's retry schedule, in-app banners with direct links to update payment methods, grace periods that give customers time to fix the issue, and suspension middleware that limits access without locking customers out of their billing page.

What we deliver:

  • invoice.payment_failed webhook handler with attempt tracking and decline code parsing
  • Grace period enforcement cron (warning at 24h, suspension at deadline)
  • PaymentFailureBanner with dynamic messaging for past_due, unpaid, and suspended states
  • Stripe Setup Intent + CardElement flow for updating payment method
  • Middleware that redirects suspended workspaces to billing (exempts /api/webhooks)

Talk to our team about your SaaS billing reliability โ†’

Or explore our SaaS development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.