Skip to content

Instantly share code, notes, and snippets.

@gsingal
Created March 27, 2026 19:22
Show Gist options
  • Select an option

  • Save gsingal/e57a00fc4245b547cfb5c3af604ccbf4 to your computer and use it in GitHub Desktop.

Select an option

Save gsingal/e57a00fc4245b547cfb5c3af604ccbf4 to your computer and use it in GitHub Desktop.
Fraud Reduction Initiative — Full Summary (for Ahmed & Mahmoud)
# Fraud Reduction Initiative — Complete Summary
**Date:** 2026-03-25 to 2026-03-27
**Trigger:** Megan's Slack report of recurring Bancorp scammer
**PR:** #3454 (ready for review)
**Reviewer:** @ApxSnowflake
---
## What Happened
Megan reported another scammer using a Bancorp prepaid card. Investigation revealed four systemic gaps in our fraud defenses. This initiative addresses all of them.
## The Problems We Found
### 1. Blocked users' listings stay visible
When Megan blocks a scammer in the admin panel, their listings remain live in search results. She has to manually find and deactivate each one. Same problem with bounced emails — a scammer signs up with a fake .edu address, the email bounces, but the listing keeps getting views.
### 2. Card testing invisible to Stripe Radar
Two users generated 133 failed subscription attempts in March. Stripe Radar never saw them because Cashier's `newSubscription()->create()` failures don't produce charges — Radar only scores successful payment objects.
### 3. No fraud feedback to Stripe
When we identify fraud, we never told Stripe. So Radar's ML model never learned from our fraud signals — the same card/device/IP patterns could be reused on new accounts.
### 4. No rate limiting on auth or payments
Login, register, and forgot-password endpoints had zero rate limiting. A bot could brute-force credentials or mass-create accounts. Listing descriptions could contain phone numbers and emails to route contact off-platform.
---
## What We Built (PR #3454)
### Phase 1: Block Damage Propagation
- **FraudEnforcementService** — when an admin blocks a user, all their active listings are automatically deactivated. Stripe subscription cancelled. One click, done.
- **Hard bounce enforcement** — when Postmark reports a permanent email bounce (HardBounce or BadEmailAddress), listings are hidden. Subscription is preserved (user might fix their email). Soft bounces don't trigger this.
- **Search filtering** — rooms flagged by Stripe Radar (`under_review = true`) are now excluded from search results. Previously they slipped through because `GeospatialService` bypassed the `Room::active()` scope.
- **Webhook fixes** — `handleReviewOpened` and `handleReviewClosed` now have null-safety checks, try-catch around Stripe API calls, and re-throw on failure for Stripe retries. Slack alerts include room ID, listing URL, review link, and reason.
- **Admin filters** — Blocked and Bounced columns + toggle filters in the admin user list (per Megan's request).
- **Backfill migration** — existing blocked users' active listings deactivated retroactively.
### Phase 2: Subscription Fraud Prevention
- **SubscriptionRateLimiter** — per-user rate limit of 5 subscription attempts per hour using Laravel's `RateLimiter` facade. Applied to 4 paid call sites in PostRoomController and HaveRoomController. Free plan and system jobs exempt.
- **BlockCardTesters command** — runs hourly, finds users with >5 `incomplete` subscriptions in 24 hours. Auto-blocks them: sets `blocked = true` and `is_fraudulent = true` in DB, adds to Stripe Radar block lists (customer ID + email), deactivates listings via FraudEnforcementService, posts to Slack. Idempotent via `is_fraudulent` database flag (survives cache flushes). Defers the fraud flag until after enforcement completes so failed enforcement gets retried on the next run.
### Phase 3: Stripe Fraud Feedback
- **StripeFraudReportingService** — reports all of a fraudulent user's charges to Stripe via `fraud_details.user_report = 'fraudulent'`. This trains Radar's ML model. Per-charge try-catch so one bad charge doesn't abort all. Skips already-refunded charges (reporting auto-refunds unreturned charges).
- **ReportFraudToStripeJob** — queued job, 3 retries. Dispatched from `FraudEnforcementService::enforceBlock()` when user has `stripe_id` and `is_fraudulent = true`.
- **BackfillStripeFraudReports command** — one-time command to report all 113 historically flagged users' charges. Run after deploy.
### Phase 4: Proactive Detection
- **Auth rate limiting** — login/forgot-password: 10/min per IP. Register: 5/min per IP. Covers 5 endpoints including `/post-room/{id}/login` and `/post-room/{id}/register`.
- **Off-platform contact detection** — `ContentModerationService` detects phone numbers, email addresses, and URLs in listing descriptions. Sets `off_platform_contact_detected` flag via `RoomObserver::saving()` (only when description actually changes). Admin reviews flagged listings — does NOT block submission.
- **Stripe.js on every page** — loaded globally via `<script async>` in the app layout. Stripe's advanced fraud detection collects device and behavioral signals on every page, improving Radar ML accuracy by ~36%.
---
## Stripe Dashboard Changes (Already Live)
These don't require code deployment:
1. **Neobank issuer blocking** — `blocked_card_issuers` value list with: Bancorp, Sutton Bank, Stride Bank, Green Dot, Pathward, MetaBank. Rule: `Block if :card_issuer: IN @blocked_card_issuers`
2. **Radar for SetupIntents** — enabled "Use Radar on payment methods saved for future use." Radar now screens all subscription creation attempts. Estimated cost: ~$238/year (284 SetupIntents/month × $0.07).
---
## Key Design Decisions
| Decision | What we chose | Why |
|----------|--------------|-----|
| Block vs bounce severity | Blocking cancels subscription. Bounce hides listing but preserves subscription. | Blocked = confirmed fraud. Bounced = might be a typo. |
| Per-user rate limiting (not per-IP) | Cache-based per authenticated user | Shared IPs at hospitals/universities would cause false positives |
| Flag phone numbers, don't block | Soft flag for admin review | Regex false positives on prices, dates, zip codes |
| Defer `is_fraudulent` flag | Set AFTER enforcement completes | If enforcement fails, next hourly run retries instead of skipping |
| Skip already-refunded charges | Don't report to Stripe | Reporting auto-refunds — would cause double refund |
| No FingerprintJS (deferred) | Maximize Stripe Radar signals first | Open-source FingerprintJS is 40-60% accurate and trivially spoofable. Stripe Radar already includes device fingerprinting. |
| No Radar Sessions | Load Stripe.js globally instead | Stripe docs: "If you use Elements, don't use Radar Sessions — you already send sufficient information" |
---
## Error Handling
Every enforcement path has error handling for partial failures:
- `FraudEnforcementService` catches per-room failures, continues with remaining rooms, sends Slack alert for partial enforcement
- Admin controller wraps enforcement in try-catch — admin save completes even if Stripe fails
- Webhook handlers re-throw on failure so Stripe/Postmark retries
- `BlockCardTesters` catches per-user failures and continues processing other suspects
---
## Tests
37 tests, 82 assertions across 8 test files:
- `UserBlockingTest` (5) — enforcement, cancellation reason, activity logs, transition guard
- `PostmarkBounceListingTest` (6) — hard/soft/bad-address bounces, pastdue, already-bounced, subscription preservation
- `GeospatialUnderReviewTest` (3) — search exclusion for both search methods + positive case
- `SubscriptionRateLimitTest` (4) — allows 5, blocks 6th, resets after window, per-user isolation
- `BlockCardTestersTest` (4) — blocks suspects, skips below threshold, skips already-flagged, deactivates rooms
- `StripeFraudReportingTest` (4) — dispatches for fraudulent, skips non-fraudulent, skips no-stripe-id, backfill
- `AuthRateLimitingTest` (3) — login limited after 10, register limited after 5, forgot-password limited
- `PhoneNumberDetectionTest` (8) — phone/email/URL detection, false positive avoidance, flag on save, clear on edit, isDirty guard
All tests have FUNCTIONAL RISKS comments per our PHP test quality rules.
---
## Post-Deploy Monitoring
Launch tracker entry: `fraud-reduction-4-phases` in `docs/launch-tracker.json`
**8 KPIs tracked at day 1/7/30/60:**
1. Active listings belonging to blocked users (target: 0)
2. Card testing attempts breaching threshold (target: 0 within 7 days)
3. Stripe fraud reports backfilled (target: 113 users)
4. Auth rate limiting active (target: 429 on 11th attempt)
5. Radar evaluating SetupIntents with device signals
6. Monthly chargeback count (target: 30% reduction in 60 days)
7. Auto-block false positive rate (target: 0 in 30 days)
8. Off-platform contact flags (target: ≥1 in 30 days)
**4 Guardrails:** no false deactivations, no new Rollbar errors, subscription flow works, auth flow not disrupted.
---
## Issues Created/Closed
| Issue | Title | Status |
|-------|-------|--------|
| #3446 | Stripe Radar neobank BIN blocking | Closed (deployed) |
| #3451 | Backfill FUNCTIONAL RISKS on PHP tests | Open (tier:next) |
| #3453 | PHP test quality enforcement loop | Closed (merged) |
| #3457 | Stripe Radar signal optimization | Closed (deployed) |
| #3458 | Cross-account device fingerprinting | Open (tier:later) |
---
## What's Next After Merge
1. Run `php artisan fraud:backfill-stripe-reports` to report 113 flagged users to Stripe
2. Day-1 checks: blocked user SQL, auth 429 test, Radar SetupIntent verification
3. Monitor Slack for auto-block alerts and partial enforcement warnings
4. Day-30 review: evaluate if Radar signal improvements reduce fraud enough or if FingerprintJS Pro is needed
@AhmedEssamElNaggar

Copy link
Copy Markdown

Reviewed PR #3454. One Critical blocker: FraudEnforcementService::enforceBlock() duplicates BlockUserService::enforce() which shipped to master today (PR #3528). Both fire on block — observer triggers BlockUserService, then controller/command explicitly calls FraudEnforcementService — double execution (duplicate Slack alerts, logs, Stripe calls). Fix: remove the duplicate, keep only the new pieces (bounce deactivation, Stripe fraud reporting, rate limiting, content moderation).

Once fixed, PR is ready to merge. Architecture is solid, 37 tests/82 assertions, excellent error handling.

Re: #3380 vs #3454 overlap: Close #3380 after merging #3454 (superset). Cherry-pick the .edu verification gate into #3454 once the verification UI is built.

@AhmedEssamElNaggar

Copy link
Copy Markdown

read it, it's a good project.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment