-
-
Save gsingal/e57a00fc4245b547cfb5c3af604ccbf4 to your computer and use it in GitHub Desktop.
| # 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 |
I've read this, and reviewed the PR, but this has nothing to do with the front-end
confirming I've read.
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.
read it, it's a good project.
@ApxSnowflake @mahmoudessam7 — please review. This summarizes the full fraud reduction initiative. PR #3454 is ready for code review. The Stripe Dashboard changes (issuer blocking, Radar for SetupIntents) are already live.