Account Suspension & Fraud Hold Workflow: Chat Prompts
Every SaaS eventually has to suspend an account. Maybe a customer's payment failed for the third time. Maybe abuse detection flagged them for credential stuffing. Maybe legal sent a TOS-violation takedown. Maybe finance noticed they're 90 days past due. Whatever the cause, you need a workflow that pauses the account without losing the data, communicates clearly with the user, gives them a path to resolution, and prevents wrongful suspensions from being a customer-experience disaster.
Most teams build this incrementally and end up with a mess: a hardcoded suspended boolean, no audit log, no admin UI, no way for users to self-recover, and a manual "reach out to support to unsuspend" flow that costs hours per case. Built well, suspension is a clean state machine with categorized reasons, automatic remediation paths, an admin dashboard for ops, and customer-facing UX that doesn't feel hostile.
Suspension Categories
Different causes need different policies. Don't conflate them.
| Category | Example | Reversible? | Auto-resolve? |
|---|---|---|---|
| Billing hold | Failed payment, expired card, overdue invoice | Yes | Yes (on payment) |
| Trial expired / not converted | Free trial ended | Yes | Yes (on upgrade) |
| Plan-tier restriction | Hit usage limit on free plan | Partial (rate-limited) | Yes (on upgrade) |
| Fraud / abuse hold | Credential stuffing detected, ToS violation, illegal content | Sometimes | No (manual review) |
| Compliance hold | DMCA / GDPR / sanctions / legal request | Sometimes | No (legal review) |
| Customer-requested pause | Customer asks to pause subscription | Yes | Yes (per customer) |
| Account closure (self-serve) | User requests deletion | No (after grace period) | No |
| Admin / debug suspension | Internal-only — testing / investigation | Yes | No (admin reverts) |
Each category has different:
- Customer-facing messaging
- Allowed actions while suspended
- Automatic resolution path
- Data retention policy
State Machine Design
I'm building an account suspension state machine for my B2B SaaS. Help me design it.
States:
- active (normal)
- billing_hold (payment failure)
- trial_expired (no payment method on file)
- usage_limit_reached (over-quota)
- fraud_hold (manual investigation)
- compliance_hold (legal review)
- paused (customer-requested)
- closing (within deletion grace period; can still recover)
- closed (deleted)
Transitions:
- active → billing_hold: payment fails 3 times (Stripe webhook)
- billing_hold → active: successful payment captured
- billing_hold → closing: 60+ days unresolved
- active → trial_expired: trial end date reached, no card on file
- trial_expired → active: card added + plan selected
- active → usage_limit_reached: usage > plan limit (only on metered features)
- usage_limit_reached → active: upgraded plan or new billing cycle resets
- active → fraud_hold: abuse signal triggers (manual or automated)
- fraud_hold → active: admin clears after review
- fraud_hold → closing: admin confirms ToS violation
- active ↔ paused: customer initiates / resumes
- closing → closed: 30 days elapsed; data deleted
- closed: terminal
Constraints:
- Each state has a `reason: enum` (billing_hold has reasons: card_expired, insufficient_funds, dispute, etc.)
- Each transition writes to an audit log: { from, to, reason, actor: 'system' | 'admin' | 'user', timestamp }
- An account can have ONE suspension state at a time, but the audit log preserves history
Implement:
- Drizzle schema for `accounts.status`, `accounts.status_reason`, `accounts.status_started_at`, `account_status_log`
- TypeScript enum + Zod schemas for state and reason
- Transition functions with validation: `suspendForBilling(accountId, reason)`, `clearFraudHold(accountId, adminId)`, etc.
- Each transition function checks: current state allows this transition, audit-log writes happen atomically with the state change
Stack: Next.js + Drizzle + Postgres.
Determining What Suspended Accounts Can Do
Suspended != deleted. Most suspended accounts retain some capability. Decide explicitly.
| Capability | Active | Billing hold | Trial expired | Usage limit | Fraud hold | Closing |
|---|---|---|---|---|---|---|
| Sign in | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ |
| View existing data | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ |
| Export data | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ |
| Create new content | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Edit existing content | ✓ | Limited | ✗ | Limited | ✗ | ✗ |
| Use API | ✓ | ✗ | ✗ | Rate-limited | ✗ | ✗ |
| Receive incoming webhooks | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ |
| Update billing info | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ |
| Cancel / delete | ✓ | ✓ | ✓ | ✓ | Limited | n/a |
Rules of thumb:
- Always allow data export. Locking customers out of their data is anti-customer and often illegal under GDPR.
- Never allow new transactions / charges. A suspended account writing data + accruing usage is bad data hygiene.
- Allow billing recovery from any reversible suspension. Don't make customers email support to update their card.
Implement the authorization check for "can this account do X right now?" My middleware should:
1. Load the account's current status
2. Return a permissions object: { canCreate, canEdit, canExport, canApi, canWebhook, ... }
3. Apply this in route handlers — return 403 with a structured error if denied
4. For UI: provide a hook `useAccountPermissions()` that returns the same object so the UI can disable + explain rather than allow + 403
Add a structured error type:
{ code: 'account_suspended', state: 'billing_hold', reason: 'card_expired', resolveUrl: '/settings/billing' }
So clients can show the right messaging.
Stack: Next.js App Router + middleware + Drizzle.
Customer-Facing Suspension UX
How you tell users they're suspended determines whether they fix it or churn. The default banner saying "Your account is suspended. Contact support" is hostile; the friction prevents users from self-recovering.
Build a customer-facing "your account is suspended" experience. Each suspension state has its own UX:
billing_hold:
- Persistent banner on every page: "Your subscription is on hold — your last payment didn't go through. [Update payment method]"
- Direct link to Stripe Customer Portal or your billing page
- Email notifications: 1, 3, 7, 14 days into the hold
- Show last error reason in plain language ("Your card was declined" vs "Bank reported insufficient funds")
trial_expired:
- Modal on sign in: "Your trial ended X days ago. Upgrade to continue."
- Show usage from the trial (you created Y items)
- Plan picker right there
- Backdoor: "Just need a few more days?" — give 7-day extension once
usage_limit_reached:
- Inline upgrade prompt at the point of friction (creating the 51st item on a 50-item plan)
- Soft block: read-only access; can still export; can still use existing
- Clear plan-tier comparison
fraud_hold:
- Sign-in blocked
- Email + dashboard message: "Your account is under review for security concerns. We'll be in touch within 48 hours."
- Don't tell them WHY in detail (gives fraud actors info to evade)
compliance_hold:
- Similar to fraud — limited info; legal-reviewed messaging only
- Path to contact compliance team
paused:
- Cheerful "your account is paused" — they did this themselves
- Clear "resume" button; no friction
closing:
- "Your account will be deleted on [date]"
- Big "Restore account" button to undo
- Export data option
Build:
- A `<SuspensionBanner />` component that reads account status and renders the appropriate banner / modal / page
- Server-rendered for instant display — don't make users wait for JS
- Email templates for billing reminders (3 in the dunning sequence)
- The "resume" / "restore" / "update payment" CTAs
Stack: Next.js App Router + Tailwind + Resend (or your email provider).
Admin Dashboard for Ops
Your support and finance teams need to see + change suspension state. Don't make them write SQL.
Build an admin dashboard for managing suspended accounts:
1. List view: all currently-suspended accounts
- Filterable by suspension type (billing, fraud, compliance, etc.)
- Sort by suspension start date
- Search by email / domain / account ID
- Quick-action buttons per row
2. Detail view: per-account
- Current status + reason
- Audit log of all status changes (who, when, why)
- Account info (plan, ARR, signup date, primary contact)
- Recent payment events (Stripe data)
- Recent abuse signals (if fraud_hold)
- Action buttons:
- "Clear hold" (with required reason note)
- "Extend trial" (custom days)
- "Force close" (with confirmation)
- "Send reminder email" (for billing holds)
3. Audit + accountability:
- Every admin action requires a reason note
- Audit log surfaces who did what, with full context
- Senior admins see all; junior admins limited to billing holds (configurable RBAC)
4. Bulk operations (use with caution):
- "Clear hold for these 50 billing accounts whose card was just updated"
- With confirmation + dry-run preview
Stack: Next.js App Router + Drizzle + your existing admin auth + role system.
Automated Triggers
Most suspensions should be automatic. Only fraud / compliance need humans.
Wire up automated suspension triggers:
billing_hold trigger:
- Listen to Stripe webhook `invoice.payment_failed`
- After 3rd failed attempt (Stripe dunning logic configured at 1 / 4 / 7 days), fire `suspendForBilling(account, reason)`
- On `invoice.payment_succeeded` while in billing_hold, fire `clearBillingHold(account)`
trial_expired trigger:
- Daily cron: find accounts where `trial_ends_at < now() AND status = 'active' AND has_payment_method = false`
- Fire `setStatus(account, 'trial_expired', reason='trial_ended')`
usage_limit_reached trigger:
- On every metered usage event, check if cumulative usage > plan limit
- If so, fire `setStatus(account, 'usage_limit_reached', reason='monthly_quota')`
- Daily cron at start of new billing period: clear all `usage_limit_reached` accounts back to `active`
closing trigger (after billing hold for 60+ days):
- Daily cron: find accounts where `status = 'billing_hold' AND status_started_at < now() - 60 days`
- Fire `setStatus(account, 'closing', reason='extended_billing_hold')`
- Send "your account will be closed in 30 days" email
closed trigger (after closing for 30+ days):
- Daily cron: find accounts where `status = 'closing' AND status_started_at < now() - 30 days`
- Fire `setStatus(account, 'closed', reason='grace_period_expired')`
- Trigger data-deletion job (separate from status change)
Stack: Next.js + Vercel Cron + Drizzle + Stripe webhooks.
Bonus: a notification system so support gets a Slack ping when an account enters fraud_hold or compliance_hold.
Data Retention vs Deletion
Suspension is cheap (status change). Closing + deletion is expensive (data destroyed) and irreversible. Decouple them.
Design the data retention + deletion lifecycle:
1. Soft-suspend (active → billing_hold / fraud_hold / etc): no data changes; just access restricted
2. Closing (status='closing'): grace period; user can still restore. Send notifications. Schedule deletion.
3. Closed (status='closed'): trigger async deletion job
4. Deletion job:
- Anonymize / delete user PII per GDPR
- Delete account-owned content per data-deletion policy
- Preserve aggregated, anonymized usage data for analytics
- Preserve invoices for tax/legal retention period
5. Deletion confirmation: send a "your account has been deleted" email; mark deletion_completed_at
Implementation:
- The deletion job runs in a background queue (use Inngest / Trigger.dev / Vercel Queues)
- Idempotent — running twice should be safe
- Logs every table touched and rows affected
- For multi-tenant data, ensure no other tenants are affected
- Send a final data export to user before deletion (legal compliance in some jurisdictions)
Stack: Next.js + Vercel Queues / Inngest + Drizzle.
Common Pitfalls
Hardcoded suspended boolean. Conflates all suspension types. Use a typed status enum + reason.
No audit log. "Why was this account suspended?" answered by SQL queries against unrelated tables. Maintain a structured account_status_log table.
No data export for suspended accounts. Locks customers out of their data. Likely violates GDPR / CCPA. Always allow export.
No customer-facing message. Users hit a 403 with no context. Show a banner + email + clear resolution path.
Telling fraud-suspended users why. Detail tips off bad actors how to evade detection. Generic messaging only.
No self-serve recovery for billing holds. "Email support to unsuspend" wastes hours / case. Make billing recovery automatic on payment.
Suspension that breaks data integrity. A user is suspended mid-edit; their incomplete record is left in the DB. Either commit or rollback explicitly.
Same path for trial-expired and fraud. Different categories need different UX. Don't show the same "your account is suspended" banner to a trial user vs. a flagged abuser.
Forgetting incoming webhooks. A suspended account that still receives webhooks (and processes them) accrues data + state. Suspend webhook delivery too.
Forgetting team members / sub-accounts. A suspended workspace blocks the admin but should it block all collaborators? Decide consciously; document.
Permanent deletion before grace period. Delete-immediately on close means support can't recover an account a customer changes their mind on. Always have a 30-day grace period.
Abuse detection with no human review. Auto-suspending on a heuristic with no human review path causes false-positive churn. Manual review = required for fraud_hold.
No reason categorization. "billing_hold" with no reason field means support has to dig. Categorize: card_expired / insufficient_funds / dispute / etc.
Email + dashboard messaging out of sync. Email says "your account is suspended"; dashboard says "active." Resolve by reading status from one canonical place.
Suspension reactivates immediately on resumption. When a billing hold clears, the account snaps back to active and fires every queued webhook / email / notification it missed. Throttle the catch-up.
No metric on suspension funnel. Don't know what % of trials → billing → suspended → churned vs. recovered. Measure it.
Compliance hold without legal sign-off. Triggering a compliance hold from a heuristic is dangerous. Legal review must precede the suspension; auto-flag, don't auto-block.
Sharing the same closing state for self-deletion and admin-closure. Different triggers, different user-recovery affordances. Treat separately.
See Also
- Authentication — auth flows for suspended accounts (block sign in for fraud_hold)
- Audit Logs — the audit-log table this references
- Account Deletion & Data Export
- Account Merge / Org Transfer
- Refunds & Chargebacks
- Dunning & Failed Payments — the upstream of billing_hold
- Trial to Paid
- Trial Extension Save Offer UX
- Trial Countdown Conversion UI
- Quotas, Limits & Plan Enforcement
- Rate Limiting & Abuse
- CAPTCHA & Bot Protection
- Roles & Permissions
- Internal Admin Tools
- Background Jobs & Queue Management — for deletion jobs
- Toast Notifications UI
- Empty States, Loading & Error States
- Notification Preferences & Unsubscribe
- In-App Notifications
- Reduce Churn
- Customer Support — the team that will live in this admin dashboard