Repository: mozmeao/basket

Issue:
refactor subhub API
events have been refactored on the subhub side, so we need to make the corresponding changes to basket.

details here: https://docs.google.com/spreadsheets/d/1z6aXj_9GUpl5g_hR2k_SmjY5tyhxvnoHGOr8e-cbo18/edit#gid=1689611325


Discussion:

Resolving patch:
diff --git a/basket/news/tasks.py b/basket/news/tasks.py
--- a/basket/news/tasks.py
+++ b/basket/news/tasks.py
@@ -27,8 +27,8 @@
 from basket.news.models import (FailedTask, Newsletter, Interest,
                                 QueuedTask, TransactionalEmailMessage)
 from basket.news.newsletters import get_sms_vendor_id, get_transactional_message_ids, newsletter_map
-from basket.news.utils import (generate_token, get_accept_languages, get_best_language, get_user_data,
-                               iso_format_unix_timestamp, parse_newsletters, parse_newsletters_csv,
+from basket.news.utils import (cents_to_dollars, generate_token, get_accept_languages, get_best_language,
+                               get_user_data, iso_format_unix_timestamp, parse_newsletters, parse_newsletters_csv,
                                SUBSCRIBE, UNSUBSCRIBE, get_best_supported_lang, split_name)
 
 log = logging.getLogger(__name__)
@@ -761,66 +761,17 @@ def process_subhub_event_customer_created(data):
         sfdc.add(contact_data)
 
 
-@et_task
-def process_subhub_event_customer_updated(data):
-    """
-    Event name: customer.updated
-
-    Updates customer record in SFDC if payment processor/Stripe customer
-    changes their name or email address
-    """
-    statsd.incr('news.tasks.process_subhub_event.customer_updated')
-
-    first, last = split_name(data['name'])
-    contact_data = {}
-
-    # find user by their payment processor ID
-    user_data = get_user_data(payee_id=data['customer_id'])
-
-    # if user was found in sfdc, see if we should update their name(s)
-    if user_data:
-        # if current last name is '_', update it
-        if user_data['last_name'] == '_':
-            contact_data['last_name'] = last
-
-        # if current last name is blank/Null, update it
-        if not user_data['first_name']:
-            contact_data['first_name'] = first
-
-        if 'first_name' in contact_data or 'last_name' in contact_data:
-            sfdc.update(user_data, contact_data)
-    # if user wasn't found, add them
-    else:
-        contact_data['email'] = data['email']
-        contact_data['first_name'] = first
-        contact_data['last_name'] = last
-        contact_data['payee_id'] = data['customer_id']
-
-        # create the user in sfdc
-        sfdc.add(contact_data)
-        statsd.incr('news.tasks.process_subhub_event.customer_updated.created')
-
-
 @et_task
 def process_subhub_event_subscription_charge(data):
     """
-    Event names: invoice.finalized, payment_intent.succeeded
+    Event names: customer.subscription.created, customer.recurring_charge
 
     This method handles both new and recurring charges.
 
-    Each of the handled events contains different information on the charge
-    which will need to be combined.
-
-    The charge_id field (sent in both payloads) will tie the two events
-    together.
-
-    invoice.finalized *should* be sent first, which is missing the following
-    credit card related fields:
-
-    - brand
-    - last4
-    - exp_month
-    - exp_year
+    Each of the handled events contains the same payload data. The only variation below
+    is in regards to Initial_Purchase__c, which will be True for the
+    `customer.subscription.created` event, and False for the `customer.recurring_charge`
+    event.
     """
 
     statsd.incr('news.tasks.process_subhub_event.subscription_charge')
@@ -828,146 +779,61 @@ def process_subhub_event_subscription_charge(data):
     user_data = get_user_data(payee_id=data['customer_id'],
                               extra_fields=['id'])
 
-    # the only user identifiable information available is the payment
-    # processor/Stripe ID, so if the user wasn't found by that, there's really
-    # nothing to be done here
     if user_data:
-        # this is the primary key for the opportunity
-        charge_id = data['charge_id']
-
-        # just in case amount data is missing/malformed
-        try:
-            # amount values are cents - convert to dollars
-            amount_paid = int(data['amount_paid']) / float(100)
-        except ValueError:
-            amount_paid = 0
-
-        # common data to both events
         transaction_data = {
-            'Amount': amount_paid,
-            'Billing_Cycle_End__c': iso_format_unix_timestamp(data['period_end']),
-            'Billing_Cycle_Start__c': iso_format_unix_timestamp(data['period_start']),
+            'Amount': cents_to_dollars(data['plan_amount']),
+            'Billing_Cycle_End__c': iso_format_unix_timestamp(data['current_period_end']),
+            'Billing_Cycle_Start__c': iso_format_unix_timestamp(data['current_period_start']),
             'CloseDate': iso_format_unix_timestamp(data['created']),
+            'Credit_Card_Type__c': data['brand'],
+            'currency__c': data['currency'],
             'Donation_Contact__c': user_data['id'],
+            'Initial_Purchase__c': data['event_type'] == 'customer.subscription.created',
+            'Invoice_Number__c': data['invoice_number'],
+            'Last_4_Digits__c': data['last4'],
             'Name': 'Subscription Services',
-            'PMT_Subscription_ID__c': data['subscription_id'],
             'Payment_Source__c': 'Stripe',
+            'PMT_Invoice_ID__c': data['invoice_id'],
+            'PMT_Subscription_ID__c': data['subscription_id'],
+            'PMT_Transaction_ID__c': data['charge'],
             'RecordTypeId': settings.SUBHUB_OPP_RECORD_TYPE,
+            'Service_Plan__c': data['nickname'],
             'StageName': 'Closed Won',
-            'currency__c': data['currency'],
         }
 
-        # invoice_number is only available in the invoice.finalized event
-        if 'invoice_number' in data:
-            transaction_data['Invoice_Number__c'] = data['invoice_number']
-
-        if 'invoice_id' in data:
-            transaction_data['PMT_Invoice_ID__c'] = data['invoice_id']
-
-        # these keys will be available for the payment_intent.succeeded event
-        if all(k in data for k in ('brand', 'last4', 'exp_month', 'exp_year')):
-            transaction_data['Credit_Card_Exp_Month__c'] = data['exp_month']
-            transaction_data['Credit_Card_Exp_Year__c'] = data['exp_year']
-            transaction_data['Credit_Card_Type__c'] = data['brand']
-            transaction_data['Last_4_Digits__c'] = data['last4']
-
-        try:
-            # attempt to update the opportunity first (this will happen on the
-            # second event for the current charge)
-            # will raise a SalesforceMalformedRequest if not found
-
-            # if the following succeeds, we don't have to worry about setting
-            # the initial purchase flag - we leave it alone, assuming it was
-            # set properly in the except block below
-            sfdc.opportunity.update('PMT_Transaction_ID__c/{}'.format(charge_id), transaction_data)
-        except sfapi.SalesforceMalformedRequest:
-            # we are creating a new opportunity, so put the charge_id in
-            transaction_data['PMT_Transaction_ID__c'] = charge_id
-
-            sfdc.opportunity.create(transaction_data)
-        else:
-            statsd.incr('news.tasks.process_subhub_event.subscription_charge_update')
+        sfdc.opportunity.create(transaction_data)
     else:
         statsd.incr('news.tasks.process_subhub_event.subscription_charge.user_not_found')
 
 
 @et_task
-def process_subhub_event_subscription_updated(data):
+def process_subhub_event_subscription_cancel(data):
     """
-    Event names: customer.subscription.created, customer.subscription.updated
-
-    This method handles three scenarios:
-
-    1. subscription created - creates a new subscription opportunity record
-    2. subscription updated - updates a subscription - specifically the name
-        of the plan
-    3. subscription cancelled - create a new opportunity record of the
-        cancellation
-
-    These scenarios are determined by the incoming data.
+    Event name: customer.subscription_cancelled
     """
-    statsd.incr('news.tasks.process_subhub_event.subscription_updated')
+    statsd.incr('news.tasks.process_subhub_event.subscription_cancel')
 
     user_data = get_user_data(payee_id=data['customer_id'],
                               extra_fields=['id'])
 
-    # the only user identifiable information available is the payment
-    # processor/Stripe ID, so if the user wasn't found by that, there's
-    # really nothing to be done here
-    if not user_data:
-        return
-
-    # just in case amount data is missing/malformed
-    try:
-        # amount values are cents - convert to dollars
-        plan_amount = int(data['plan_amount']) / float(100)
-    except ValueError:
-        plan_amount = 0
-
-    # common transaction data for all three scenarios
-    transaction_data = {
-        'Amount': plan_amount,
-        'Name': 'Subscription Services',
-        'PMT_Subscription_ID__c': data['subscription_id'],
-        'RecordTypeId': settings.SUBHUB_OPP_RECORD_TYPE,
-        'Service_Plan__c': data['nickname'],
-        'Donation_Contact__c': user_data['id'],
-    }
-
-    # it's only a cancellation if cancel_at_period_end is truthy
-    if data['cancel_at_period_end']:
-        statsd.incr('news.tasks.process_subhub_event.subscription_updated.cancelled')
-
-        transaction_data.update({
-            'Billing_Cycle_End__c': iso_format_unix_timestamp(data['cancel_at']),
-            'CloseDate': iso_format_unix_timestamp(data['canceled_at']),
+    if user_data:
+        transaction_data = {
+            'Amount': cents_to_dollars(data['plan_amount']),
+            'Billing_Cycle_End__c': iso_format_unix_timestamp(data['current_period_end']),
+            'Billing_Cycle_Start__c': iso_format_unix_timestamp(data['current_period_start']),
+            'CloseDate': iso_format_unix_timestamp(data['cancel_at']),
+            'Donation_Contact__c': user_data['id'],
+            'Name': 'Subscription Services',
             'Payment_Source__c': 'Stripe',
+            'PMT_Subscription_ID__c': data['subscriptionId'],
+            'RecordTypeId': settings.SUBHUB_OPP_RECORD_TYPE,
+            'Service_Plan__c': data['nickname'],
             'StageName': 'Subscription Canceled',
-        })
+        }
 
         sfdc.opportunity.create(transaction_data)
     else:
-        transaction_data.update({
-            'CloseDate': iso_format_unix_timestamp(data['created']),
-            'StageName': 'Closed Won',
-        })
-
-        transaction_data['Initial_Purchase__c'] = data['event_type'] == 'customer.subscription.created'
-
-        try:
-            # attempt to update the opportunity identified by the invoice_id
-            sfdc.opportunity.update('PMT_Invoice_ID__c/{}'.format(data['invoice_id']), transaction_data)
-        except sfapi.SalesforceMalformedRequest:
-            # no opportunity found - create a new one
-            sentry_client.captureException()
-
-            # add invoice_id to the payload
-            transaction_data['PMT_Invoice_ID__c'] = data['invoice_id']
-            sfdc.opportunity.create(transaction_data)
-            statsd.incr('news.tasks.process_subhub_event.subscription_updated.created')
-        else:
-            # if update was successful, log it
-            statsd.incr('news.tasks.process_subhub_event.subscription_updated.updated')
+        statsd.incr('news.tasks.process_subhub_event_subscription_cancel.user_not_found')
 
 
 @et_task
@@ -994,19 +860,19 @@ def process_subhub_event_payment_failed(data):
     # nothing to be done here
     if user_data:
         transaction_data = {
+            'Amount': cents_to_dollars(data['amount_due']),
+            'CloseDate': iso_format_unix_timestamp(data['created']),
             'Donation_Contact__c': user_data['id'],
-            'PMT_Subscription_ID__c': data['subscription_id'],
             'Invoice_Number__c': data['invoice_number'],
             'Name': 'Subscription Services',
+            'PMT_Invoice_ID__c': data['invoice_id'],
+            'PMT_Subscription_ID__c': data['subscription_id'],
+            'PMT_Transaction_ID__c': data['charge_id'],
+            'Payment_Source__c': 'Stripe',
             'RecordTypeId': settings.SUBHUB_OPP_RECORD_TYPE,
+            'Service_Plan__c': data['nickname'],
             'StageName': 'Payment Failed',
-            'Payment_Source__c': 'Stripe',
-            'Amount': data['amount_due'],
-            'CloseDate': iso_format_unix_timestamp(data['created']),
-            'PMT_Transaction_ID__c': data['charge_id'],
             'currency__c': data['currency'],
-            'Service_Plan__c': data['nickname'],
-            'PMT_Invoice_ID__c': data['invoice_id'],
         }
 
         sfdc.opportunity.create(transaction_data)
diff --git a/basket/news/utils.py b/basket/news/utils.py
--- a/basket/news/utils.py
+++ b/basket/news/utils.py
@@ -615,3 +615,12 @@ def split_name(name):
         first, last = '', names[0]
 
     return first, last
+
+
+def cents_to_dollars(cents):
+    try:
+        dollars = int(cents) / float(100)
+    except ValueError:
+        dollars = 0
+
+    return dollars
diff --git a/basket/news/views.py b/basket/news/views.py
--- a/basket/news/views.py
+++ b/basket/news/views.py
@@ -38,9 +38,8 @@
     upsert_user,
     process_subhub_event_credit_card_expiring,
     process_subhub_event_customer_created,
-    process_subhub_event_customer_updated,
     process_subhub_event_payment_failed,
-    process_subhub_event_subscription_updated,
+    process_subhub_event_subscription_cancel,
     process_subhub_event_subscription_charge,
 )
 from basket.news.utils import (
@@ -79,13 +78,11 @@
 
 SUBHUB_EVENT_TYPES = {
     'customer.created': process_subhub_event_customer_created,
+    'customer.recurring_charge': process_subhub_event_subscription_charge,  # subscriptioin creations & recurring charges
     'customer.source.expiring': process_subhub_event_credit_card_expiring,
-    'customer.subscription.created': process_subhub_event_subscription_updated,  # creations, updates, & cancellations
-    'customer.subscription.updated': process_subhub_event_subscription_updated,  # creations, updates, & cancellations
-    'customer.updated': process_subhub_event_customer_updated,
-    'invoice.finalized': process_subhub_event_subscription_charge,  # used for both new and recurring charges
+    'customer.subscription.created': process_subhub_event_subscription_charge,  # subscriptioin creations & recurring charges
+    'customer.subscription_cancelled': process_subhub_event_subscription_cancel,
     'invoice.payment_failed': process_subhub_event_payment_failed,
-    'payment_intent.succeeded': process_subhub_event_subscription_charge,  # used for both new and recurring charges
 }
 
 


Test patch:
diff --git a/basket/news/tests/test__utils.py b/basket/news/tests/test__utils.py
--- a/basket/news/tests/test__utils.py
+++ b/basket/news/tests/test__utils.py
@@ -8,6 +8,7 @@
 
 from basket.news.models import BlockedEmail
 from basket.news.utils import (
+    cents_to_dollars,
     email_block_list_cache,
     email_is_blocked,
     get_accept_languages,
@@ -396,3 +397,14 @@ def test_removes_suffixes(self):
         self.assertEqual(split_name('Jeffrey Lebowski jr.'), ('Jeffrey', 'Lebowski'))
         self.assertEqual(split_name('Uli Kunkel Sr.'), ('Uli', 'Kunkel'))
         self.assertEqual(split_name('Uli Kunkel sr'), ('Uli', 'Kunkel'))
+
+
+class TestCentsToDollars(TestCase):
+    def test_valid_int_data(self):
+        self.assertEqual(cents_to_dollars(5005), 50.05)
+
+    def test_valid_string_data(self):
+        self.assertEqual(cents_to_dollars('5005'), 50.05)
+
+    def test_invalid_data(self):
+        self.assertEqual(cents_to_dollars('briefcase'), 0)
diff --git a/basket/news/tests/test_tasks.py b/basket/news/tests/test_tasks.py
--- a/basket/news/tests/test_tasks.py
+++ b/basket/news/tests/test_tasks.py
@@ -29,6 +29,7 @@
     process_donation,
     process_donation_event,
     process_petition_signature,
+    process_subhub_event_credit_card_expiring,
     record_common_voice_goals,
     RECOVERY_MESSAGE_ID,
     SUBSCRIBE,
@@ -275,32 +276,28 @@ def test_charge_disputed(self, sfdc_mock):
 @patch('basket.news.tasks.get_user_data')
 @patch('basket.news.tasks.sfdc')
 class SubHubEventTests(TestCase):
-    def test_customer_created(self, sfdc_mock, gud_mock):
-        pass
-
-    def test_customer_updated_customer_found(self, sfdc_mock, gud_mock):
-        pass
-
-    def test_customer_updated_customer_not_found(self, sfdc_mock, gud_mock):
-        pass
+    @patch('basket.news.tasks.sfmc')
+    def test_credit_card_expiring(self, sfmc_mock, sfdc_mock, gud_mock):
+        process_subhub_event_credit_card_expiring({
+            'email': 'dude@example.com',
+        })
 
-    def test_subscription_charge_finalized(self, sfdc_mock, gud_mock):
-        pass
+        sfmc_mock.send_mail.assert_called_with(settings.SUBHUB_CC_EXPIRE_TRIGGER,
+            'dude@example.com', 'dude@example.com')
 
-    def test_subscription_charge_succeeded(self, sfdc_mock, gud_mock):
+    def test_customer_created_customer_found(self, sfdc_mock, gud_mock):
         pass
 
-    def test_subscription_canceled(self, sfdc_mock, gud_mock):
+    def test_customer_created_customer_not_found(self, sfdc_mock, gud_mock):
         pass
 
-    def test_subscription_canceled_not_cancelled(self, sfdc_mock, gud_mock):
+    def test_payment_failed(self, sfdc_mock, gud_mock):
         pass
 
-    @patch('basket.news.tasks.sfmc')
-    def test_credit_card_expiring(self, sfdc_mock, gud_mock, sfmc_mock):
+    def test_subscription_cancel(self, sfdc_mock, gud_mock):
         pass
 
-    def test_payment_failed(self, sfdc_mock, gud_mock):
+    def test_subscription_charge(self, sfdc_mock, gud_mock):
         pass