Desktop Billing QA Checklist¶
Manual testing checklist for subscription, upgrade, downgrade, cancellation, and credit flows on desktop.
Prerequisites¶
- Running local stack:
docker compose up --build - Stripe CLI forwarding webhooks:
stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe - Stripe test card:
4242 4242 4242 4242(any future expiry, any CVC) - A verified account (email verified or OAuth)
- Fresh DB recommended:
docker compose down -v && docker compose up --build
1. Trial Flow¶
- New user lands on plan picker after signup (no dashboard access)
- Creator card shows "START FREE TRIAL" button
- Click START FREE TRIAL → dashboard loads with "TRIAL · 7d LEFT" in sidebar
- Sidebar shows "30 AI MIN"
- Settings → Credits tab shows 30 balance with progress bar
- Settings → Credits tab shows trial grant in transaction history
- Second account with same email cannot get another trial (409)
2. New Subscription (No Existing Sub)¶
- From plan picker, click SUBSCRIBE on Hobby → redirects to Stripe Checkout
- Stripe Checkout shows correct price ($9/mo or $86/yr depending on toggle)
- Complete payment with test card → redirects to "Processing Payment" screen
- Processing screen polls and transitions to dashboard within ~10s
- Sidebar credits update (120 AI MIN for Hobby)
- Settings → Billing tab shows "Hobby" plan with "active" status
- Settings → Billing tab shows payment in history table
- Settings → Credits tab shows subscription grant in transaction history
- Stripe CLI shows
checkout.session.completedwebhook 200
3. Upgrade (Hobby → Creator)¶
- From plan picker or Settings → Billing → UPGRADE PLAN
- Creator card shows "UPGRADE" button
- Click UPGRADE → confirmation screen appears (NOT Stripe Checkout)
- Confirmation shows prorated charge amount (should be < Creator full price)
- Confirmation shows next billing date
- Click GO BACK → returns to plan picker
- Click CONFIRM UPGRADE → "Plan Updated" success screen
- Dashboard shows updated tier in sidebar
- Credits update: old tier credits + new tier credits (check Credits tab)
- Settings → Billing tab shows "Creator" with "active" status
- Payment history shows prorated charge
- Stripe CLI shows
invoice.payment_succeededwebhook
Proration Math Verification¶
- Monthly Hobby (\(9) → Monthly Creator (\)19) mid-cycle: charge should be < $19
- Annual Hobby (\(86) → Annual Creator (\)182) mid-cycle: charge should be ~$96
- Amount shown in confirmation matches actual Stripe charge
4. Downgrade (Creator → Hobby)¶
- From plan picker, Hobby card shows "DOWNGRADE" button
- Click DOWNGRADE → "What You'll Lose" modal appears (NOT immediate change)
- Modal lists features being lost (AI Director, storage, etc.)
- Modal shows effective date (end of current billing period)
- Click KEEP MY CURRENT PLAN → modal closes, no change
- Click CONFIRM DOWNGRADE → "Plan Updated" success screen
- Dashboard still shows Creator tier (change not immediate)
- Settings → Billing still shows Creator as active until period end
5. Cancellation Flow¶
- Settings → Billing tab → CANCEL SUBSCRIPTION button visible when active
- Click CANCEL → Step 1: Reason picker appears
- Select reason + fill detail (required for some) → CONTINUE
- Step 2: "What You'll Lose" warning (all reasons, not just too_expensive)
- Warning lists dashboard, videos, AI features, no-refund notice
- Click KEEP MY PLAN → modal closes
- Click I UNDERSTAND → proceeds to step 3 (or discount for too_expensive)
Quality/Features/Competitor Path¶
- Select "Quality not what I expected" → after warn step, "Let Us Help" screen appears
- Select "Missing features" → same support screen with different message
- Select "Switching to another tool" → same support screen with different message
- Textarea allows typing a message
- SEND TO OUR TEAM → sends support request via API, shows "Sent." confirmation
- User receives confirmation email with FAQ + Discord links (Sapari voice, not corporate)
- Support team receives alert email with user's tier, credits, paid status, and reply-to
- JOIN OUR DISCORD → opens Discord link in new tab
- "Continue cancelling" → proceeds to confirm step
Too Expensive Path¶
- Step 3: Discount offer (20% off 3mo for monthly, 30% off next year for annual)
- Click CLAIM discount → Step 5: "Discount Applied" confirmation with message
- Click GOT IT → modal closes
- Verify discount in Stripe dashboard
- Click "No thanks, continue cancelling" → proceeds to step 4
One-Time-Only Discount Enforcement¶
- Accept discount once → cancel again with "too expensive" → discount step is SKIPPED (goes straight to confirm)
- Billing status
discount_usedistrueafter accepting discount - API: calling
discount_acceptedoutcome again returns "already used" message (not a new coupon) - API: calling
discount_acceptedwith a non-too_expensivereason returns 422 (server enforces reason/outcome coupling)
Final Cancellation¶
- Step 4: REMIND ME BEFORE RENEWAL → modal closes, reminder scheduled
- Step 4: CANCEL NOW → subscription cancelled, redirected to plan picker
- Refresh page → still on plan picker (not dashboard)
- Credits preserved (shown on plan picker if resubscribing)
- Stripe CLI shows
customer.subscription.deletedwebhook
6. Post-Cancel Resubscribe¶
- After cancel, plan picker shows SUBSCRIBE (not START FREE TRIAL)
- Subtitle text says "Choose a plan to continue" (not trial text)
- Subscribe to any plan → Stripe Checkout → payment → dashboard access restored
- Previous credits still available + new credits added
7. Billing Tab (Settings)¶
- Current plan card shows tier name, status badge, billing interval
- Trial users see trial end date
- UPGRADE PLAN button opens plan picker overlay
- UPDATE PAYMENT METHOD → redirects to Stripe Customer Portal (or shows error message)
- CANCEL SUBSCRIPTION only visible when status is "active"
- Payment history table: paginated, shows date/type/amount/status
- PREV/NEXT pagination works
8. Credits Tab (Settings)¶
- Balance card shows total AI MIN as big number
- Per-pool bars: "Beta" (X/240), "Subscription" (X/600), or "Credits" (fallback)
- Beta bar shows renewal date below
- Progress bar colors: green >60%, yellow >20%, red >0%, gray empty
- Reserved credits shown when analysis in progress
- Transaction history shows: trial grants, subscription grants, beta grants, usage deductions, period resets
- Transaction amounts: green +N for grants, red -N for usage
- Running balance shown per transaction
9. Sidebar Credit Badge¶
- Shows "X AI MIN" in orange
- Shows "0 AI MIN" in red when empty
- Shows "TRIAL · Xd LEFT" during trial
- Shows bolt icon + "BETA" for beta users
- Updates after: subscribing, analyzing, cancelling (no manual refresh needed)
10. Beta Access¶
- Beta user sees "Welcome to the Beta" modal on first login (not trial modal)
- Modal mentions 240 AI minutes, 30-day renewal, watermark-free, stacking
- Dismissing modal persists (refresh doesn't show it again)
- Sidebar CreditBadge shows bolt icon + "BETA" below balance
- Settings → Billing tab shows orange "BETA" badge next to tier name
- Settings → Credits tab shows "Beta" bar with X/240 and renewal date
- Settings → Credits tab shows total balance as big number above the bar
- Settings -> Billing shows beta fields (credits total, used, renewal date)
Beta + Subscription Stacking¶
- Beta user subscribes to Hobby → Credits tab shows two bars: "Beta" (X/240) + "Subscription" (X/120)
- Total balance = beta remaining + subscription remaining
- Using credits drains beta bar first (RENEWABLE priority)
- Cancelling subscription → beta credits survive, subscription bar disappears
- Billing tab shows both "BETA" badge and "active" subscription status
Beta Credit Renewal¶
- After 30 days, credits reset to 240 (verify via API or wait)
- Transaction history shows "Renewable period reset" entry after renewal
- Progress bar resets to full after renewal
Beta Invite Flow (Admin)¶
- Admin Beta page: invite existing user by email -> user gets beta + welcome email
- Admin Beta page: invite new email -> invite email sent with signup link
- Invite email contains signup URL with
?beta_invite=TOKEN - Email/password signup via invite link -> account created with beta access automatically
- Google OAuth signup via invite link -> account created with beta access automatically (token forwarded through OAuthState)
- Canonical email match: invite
User@example.com, signupuser@example.com-> beta granted (case) - Canonical email match: invite
user.name@gmail.com, signupusername@gmail.com-> beta granted (Gmail dot-stripping) - Canonical email match: invite
user+beta@example.com, signupuser@example.com-> beta granted (+tag alias) - Signup with different email than invite -> no beta granted (non-canonical, truly different mailbox)
- Invite link after 24h -> token expired, no auto-grant
Admin User Management¶
- User Lookup: search by username prefix finds user, emails are masked
- User Lookup: search by full email finds exact match
- User Lookup: search by user ID number finds exact match
- User Lookup: selecting user loads card with tier, credits, storage, projects
- User Lookup: grant credits form requires min 10 char reason, credits appear after grant
- User Lookup: "Rebuild Credits" button rebuilds balance from ledger
- Overview page: health dots show DB/Redis/Storage status
Admin Panel UI¶
- Admin nav item visible only to superusers (Lock icon in sidebar)
- Admin panel loads via React.lazy (check DevTools Network -- separate chunk)
- Overview page shows real stats (beta users, recent events, DB/Redis health, queue depths when non-zero)
- Overview page: "Credit Audit" button triggers audit (shows sudo prompt), result displayed inline
- Overview page: "Cleanup Exports" button runs cleanup, shows count of deleted exports
- User Lookup: search works, user card shows real data, quick actions work (grant beta, grant credits, change tier, notify, rebuild, deactivate, reactivate, force logout, GDPR delete)
- User Lookup: expand "Payment History" shows payments with status badges and amounts
- User Lookup: expand "Projects" shows project names with clip counts
- User Lookup: expand "Analysis Runs" shows runs with project name, mode, credits, status
- User Lookup: expand "Entitlements" shows type, status, granted/used, grant reason, expiry
- User Lookup: expand "Active Sessions" shows device, IP, last activity
- User Lookup: change tier dropdown shows all tiers, current tier highlighted
- User Lookup: send notification inline form works
- User Lookup: force logout and GDPR delete require sudo (password prompt)
- User Lookup: as super admin, "Promote to Admin" visible on non-admin users (requires sudo)
- User Lookup: as super admin, "Demote from Admin" visible on other admins (requires sudo, can't self-demote)
- User Lookup: promote/demote not visible to regular admins (only super admins)
- Beta Management: invite form, users table with search/sort, masked emails with reveal
- Feature Flags: create, toggle, percentage slider, allowlist add/remove, delete
- Audit Log: 5 filters (event type, resource type, admin ID, target user, since), expandable rows
- Notifications: compose with target modes (user/tier/everyone), confirmation popup, recent list with filters
- Discounts: create, active/scope filters, deactivate
- Support Triage: tickets/cancellations toggle, assignment filters, claim/unclaim, inline reply
- Payments: stuck payments table loads (empty = "all payments healthy"), Sync from Stripe button
- Dark mode works on all admin pages
- Non-superuser navigating to admin view redirected to pipeline
User Reactivation (Admin)¶
- User Lookup: deactivated user shows "Reactivate" button -> click it -> user can log in again
- User Lookup: active user has no "Reactivate" button
Audit Logging (Admin)¶
- After any admin action that mutates state (beta grant, credit rebuild, etc.), Audit Log page shows the event
- After admin reads that return PII (user list, user-by-username, entitlement lookups, discount list, notification list, beta user list, audit queries), Audit Log page shows a
searchevent - Searching for a non-existent username/ID still emits a
searchevent (emit-on-miss — forensic record of what admins probed for) - Audit Log: filter by event type (e.g. "grant" or "search") shows only matching events
- Audit Log: filter by admin ID shows only that admin's actions
- Audit Log: expanding a row shows detail JSON (for PII reads,
details.target_user_ididentifies the affected user when applicable) - Audit Log: pagination works (Newer/Older buttons)
Admin Security¶
- Clicking "Force Logout" or "GDPR Delete" shows password prompt (sudo modal)
- Entering correct password -> action executes
- Entering wrong password -> error message, action does not execute
- After 3 wrong sudo attempts -> locked out message
- Idle for >2 hours on admin pages -> next action returns error (session timeout)
Support System¶
- Paid/beta user: Settings > Support shows create ticket form
- Free user: Settings > Support hides the form and shows "Support is available to paid subscribers and beta testers. Subscribe to any plan to contact support."
- Trial user: Settings > Support hides the form (trial is not support-eligible, even though they have creator-tier features)
- Submit failure surfaces the backend's specific detail (e.g. "You already have an open support conversation"), not a generic "Failed to send"
- User creates ticket -> appears in admin Support Triage with correct priority
- Admin default view shows "Mine + Unclaimed" conversations
- Admin replies from triage -> auto-assigns, user sees reply in real-time
- Admin can't reply to another admin's conversation without clicking "Claim"
- Cancellation flow with "poor_quality" reason -> creates critical support conversation
- Admin can filter triage by status, priority, category, assignment
- Admin Cancellations tab shows feedback with reason/outcome filters
- Email masked everywhere in admin, click eye icon to reveal (logged in audit)
Payment Recovery (Admin)¶
- Payments page: shows stuck payments table (or "all payments healthy" if none)
- Payments page: summary badges show counts per failure mode
- Payments page: "Materialize" button opens sudo prompt, then grants credits
- Payments page: "Wait" button is gray and non-actionable (informational)
- Payments page: mail icon opens compose modal with pre-filled subject/message based on failure mode
- Payments page: admin can edit subject + message before sending
- Payments page: "Sync from Stripe" button shows result (X records checked, Y discrepancies)
- Non-superuser cannot access Payments page (redirected or 403)
11. Edge Cases¶
- Double-click UPGRADE button → only one request sent
- Upgrade while analysis running → works, credits correct
- Cancel then immediately resubscribe → works, no duplicate entitlements
- Switch billing period (monthly ↔ annual) on plan picker → prices update
- Stripe webhook delay (>10s) → payment success page shows "Processing..." then resolves
- Stripe webhook timeout (>30s) → payment success page shows refresh button
- Invalid/expired card → Stripe Checkout handles error (we don't see it)
12. Storage Quota¶
- Upload clip → Settings → Billing → Usage shows storage bar incrementing
- Upload second clip → counter accumulates correctly
- Delete clip → storage bar decrements
- Upload that would exceed quota → file shows inline error message (not just "FAILED")
- ClipUploader header shows storage label ("1.2 GB / 2 GB")
- Storage bar below header: orange normal, yellow > 80%, red > 95%
- When storage full → upload zone replaced with "Storage full" message
- YouTube import → does NOT count toward storage
- Sidebar CreditBadge shows "STORAGE 85%" warning when > 80% (hidden below 80%)
- Asset Manager sidebar shows storage bar with label
- Asset cards show file size below name (e.g. "40 MB")
- Asset group headers show total size (e.g. "3 ITEMS · 120 MB")
- Sort assets by "Largest First" / "Smallest First" → correct order
- Asset upload exceeding quota → red error banner with backend message
- Storage bar/labels update without page refresh after upload/delete
13. Project Limits¶
- Sidebar shows project count "⅔" next to PROJECTS nav item
- At project limit → NEW PROJECT button disabled (opacity-50, cursor-not-allowed)
- At project limit → hover shows tooltip "Project limit reached (3/3)"
- At project limit → uploading files shows creation error inline in ClipUploader
- At project limit → does NOT retry endlessly (check server logs for repeated 422s)
- Settings → Billing → Usage shows "Active Projects: 2 / 3" (red at limit)
- Delete a project → count updates, NEW PROJECT re-enabled
14. AI Director Gating¶
- Hobby user → Director Notes textarea is disabled with "Creator+" label
- Hobby user → Director Notes card has reduced opacity
- Creator user → Director Notes works normally, shows "Optional"
- Hobby user → typing in Director Notes not possible (disabled)
15. Export Retention¶
- Export a video → export list shows "Xd left" next to READY status
- Hobby export → expires after 14 days (check API response
expires_at) - Creator export → expires after 30 days
- Expired export → shows "EXPIRED" in red instead of "READY"
- Expired export → download button hidden
- Expired export → re-exporting the same project works fine
- Expiry warning: ≤ 3 days remaining → "Xd left" in yellow
16. Watermark¶
- Trial user: exported video has Sapari watermark (bottom-right, semi-transparent)
- Hobby paid user: exported video has NO watermark
- Creator paid user: exported video has NO watermark
- Beta user (granted via admin invite, even with prior trial entitlement): exported video has NO watermark
- Trial user: video preview shows watermark overlay
- Hover over watermark in preview → "Remove watermark" label appears
- Click watermark in preview → plan picker opens
- Paid user: no watermark overlay in video preview
- Beta user: no watermark overlay in video preview (preview must match export — both gate on
billing.can_watermark_free)
Stripe CLI Reference¶
# Forward webhooks to local backend
stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe
# Trigger specific webhook for testing
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
# View recent events
stripe events list --limit 10
Test Cards¶
| Card | Behavior |
|---|---|
4242 4242 4242 4242 |
Succeeds |
4000 0000 0000 3220 |
Requires 3D Secure |
4000 0000 0000 0002 |
Declined |
4000 0000 0000 9995 |
Insufficient funds |