VibeWeek
Home/Grow/Account Suspension & Fraud Hold Workflow: Chat Prompts

Account Suspension & Fraud Hold Workflow: Chat Prompts

⬅️ Back to 6. Grow

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