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