Created
March 27, 2026 19:22
-
-
Save gsingal/e57a00fc4245b547cfb5c3af604ccbf4 to your computer and use it in GitHub Desktop.
Fraud Reduction Initiative — Full Summary (for Ahmed & Mahmoud)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |
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
Reviewed PR #3454. One Critical blocker:
FraudEnforcementService::enforceBlock()duplicatesBlockUserService::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.