@@ -12,13 +12,15 @@ const humanize = require('humanize-string');
1212const isSANB = require ( 'is-string-and-not-blank' ) ;
1313const ms = require ( 'ms' ) ;
1414const parseErr = require ( 'parse-err' ) ;
15+ const pMapSeries = require ( 'p-map-series' ) ;
1516const titleize = require ( 'titleize' ) ;
1617
1718const { Users, Domains } = require ( '#models' ) ;
1819const config = require ( '#config' ) ;
1920const env = require ( '#config/env' ) ;
20- const syncStripePaymentIntent = require ( '#helpers/sync-stripe-payment-intent' ) ;
2121const emailHelper = require ( '#helpers/email' ) ;
22+ const logger = require ( '#helpers/logger' ) ;
23+ const syncStripePaymentIntent = require ( '#helpers/sync-stripe-payment-intent' ) ;
2224
2325const stripe = new Stripe ( env . STRIPE_SECRET_KEY ) ;
2426const { STRIPE_PRODUCTS } = config . payments ;
@@ -30,6 +32,107 @@ async function processEvent(ctx, event) {
3032 // <https://stripe.com/docs/cli/trigger#trigger-event>
3133 //
3234 switch ( event . type ) {
35+ //
36+ // NOTE: due to unprecedented Stripe credit card fraud (which Stripe has refused to help mitigate)
37+ // we've implemented our own logic here to prevent fraud (user's doing client-side attacks with generated numbers)
38+ // <https://docs.stripe.com/disputes/prevention/card-testing>
39+ //
40+ // prevent fraud by checking for users with 5+ failed charges in < 30 days
41+ // with zero verified domains on their account and/or unverified email address
42+ // ban user and notify admins, and refund all other charges from them
43+ //
44+ case 'charge.failed' : {
45+ // exit early if it wasn't a charge failure
46+ if ( event ?. data ?. object ?. object !== 'charge' ) break ;
47+ if ( typeof event ?. data ?. object ?. customer !== 'string' )
48+ throw new Error ( 'Charge did not have customer' ) ;
49+ const user = await Users . findOne ( {
50+ [ config . userFields . stripeCustomerID ] : event . data . object . customer
51+ } ) ;
52+ if ( ! user ) throw new Error ( 'User did not exist for customer' ) ;
53+ // <https://docs.stripe.com/api/charges/list>
54+ const charges = await stripe . charges . list ( {
55+ customer : event . data . object . customer ,
56+ created : {
57+ gte : dayjs ( ) . subtract ( 1 , 'month' ) . unix ( ) // only search last 30 days to prevent false positives
58+ }
59+ } ) ;
60+
61+ const filtered = charges . data . filter (
62+ ( d ) => d . status === 'failed' && d . failure_code === 'card_declined'
63+ ) ;
64+
65+ // if not more than 5 then return early
66+ if ( filtered . length < 5 ) break ;
67+
68+ // TODO: we may want to use payment methods count here too instead of just failed charges
69+ // (see `jobs/stripe/fraud-check.js` which uses this approach on a recurring basis)
70+
71+ // if user had verified domains then alert admins
72+ // otherwise ban the user and refund all their payments
73+ const count = await Domains . countDocuments ( {
74+ members : {
75+ $elemMatch : {
76+ user : user . _id ,
77+ group : 'admin'
78+ }
79+ } ,
80+ plan : { $in : [ 'enhanced_protection' , 'team' ] } ,
81+ has_txt_record : true
82+ } ) ;
83+
84+ const subject = `${ user . email } - ${ event . data . object . customer } - ${ filtered . length } declined charges and ${ count } verified domains` ;
85+
86+ emailHelper ( {
87+ template : 'alert' ,
88+ message : {
89+ to : config . email . message . from ,
90+ subject : `${
91+ count > 0
92+ ? 'Potential Fraud to Investigate'
93+ : 'Banned User for Fraud Alert'
94+ } : ${ subject } `
95+ } ,
96+ locals : {
97+ message : `<p><a href="https://dashboard.stripe.com/customers/${ event . data . object . customer } " class="btn btn-dark btn-lg" target="_blank" rel="noopener noreferrer">Review Stripe Customer</a></p>`
98+ }
99+ } )
100+ . then ( )
101+ . catch ( ( err ) => logger . fatal ( err ) ) ;
102+
103+ if ( count === 0 ) {
104+ user . is_banned = true ;
105+ await user . save ( ) ;
106+
107+ const [ charges , subscriptions ] = await Promise . all ( [
108+ stripe . charges . list ( {
109+ customer : event . data . object . customer
110+ } ) ,
111+ stripe . subscriptions . list ( {
112+ customer : event . data . object . customer
113+ } )
114+ ] ) ;
115+
116+ // refund all payments as fraudulent
117+ if ( charges ?. data ?. length > 0 )
118+ await pMapSeries ( charges . data , async ( charge ) => {
119+ if ( charge . status !== 'succeeded' || charge . paid !== true ) return ;
120+ await stripe . refunds . create ( {
121+ charge : charge . id
122+ } ) ;
123+ } ) ;
124+
125+ // cancel all subscriptions
126+ if ( subscriptions ?. data ?. length > 0 )
127+ await pMapSeries ( subscriptions . data , async ( subscription ) => {
128+ if ( subscription . status !== 'canceled' ) return ;
129+ await stripe . subscriptions . cancel ( subscription . id ) ;
130+ } ) ;
131+ }
132+
133+ break ;
134+ }
135+
33136 // create or update existing payment
34137 // (we may also want to upgrade plan; e.g. in case redirect does not occur)
35138 // (also need to ensure no conflicts with redirect)
@@ -387,18 +490,37 @@ async function processEvent(ctx, event) {
387490 // event.data.object is a subscription object
388491 if ( event . data . object . object !== 'subscription' )
389492 throw new Error ( 'Event object was not a subscription' ) ;
390- const subscription = event . data . object ;
391- if ( [ 'active' , 'trialing' ] . includes ( subscription . status ) )
493+ if ( [ 'active' , 'trialing' ] . includes ( event . data . object . status ) )
392494 await Users . findOneAndUpdate (
393495 {
394- [ config . userFields . stripeCustomerID ] : subscription . customer
496+ [ config . userFields . stripeCustomerID ] : event . data . object . customer
395497 } ,
396498 {
397499 $set : {
398- [ config . userFields . stripeSubscriptionID ] : subscription . id
500+ [ config . userFields . stripeSubscriptionID ] : event . data . object . id
399501 }
400502 }
401503 ) ;
504+ // if user had more than one subscription then notify admins by email
505+ const subscriptions = await stripe . subscriptions . list ( {
506+ customer : event . data . object . customer
507+ } ) ;
508+ const filtered = subscriptions . filter ( ( s ) => s . status !== 'canceled' ) ;
509+ if ( filtered . length > 1 ) {
510+ emailHelper ( {
511+ template : 'alert' ,
512+ message : {
513+ to : config . email . message . from ,
514+ subject : `Multiple Subscriptions Detected: ${ event . data . object . customer } `
515+ } ,
516+ locals : {
517+ message : `<p><a href="https://dashboard.stripe.com/customers/${ event . data . object . customer } " class="btn btn-dark btn-lg" target="_blank" rel="noopener noreferrer">Review Stripe Customer</a></p>`
518+ }
519+ } )
520+ . then ( )
521+ . catch ( ( err ) => logger . fatal ( err ) ) ;
522+ }
523+
402524 break ;
403525 }
404526
0 commit comments