webhooks/stripe: Update how we handle updated events.

Also more explicitly ignores the Stripe Connect related events in the
'account' category.
This commit is contained in:
Rishi Gupta 2018-12-06 00:06:23 -08:00
parent 5fb683e788
commit 027d5e90c5
4 changed files with 220 additions and 157 deletions

View File

@ -0,0 +1,188 @@
{
"id": "evt_1DbqCuDEQaroqDjsIkZIT6uP",
"object": "event",
"api_version": "2018-11-08",
"created": 1543500572,
"data": {
"object": {
"id": "cus_00000000000000",
"object": "customer",
"account_balance": 100,
"created": 1543971209,
"currency": "usd",
"default_source": "src_1DdoduGh0CmXqmnwKJmq8lpv",
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"invoice_prefix": "6E2831C",
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"shipping": null,
"sources": {
"object": "list",
"data": [
{
"id": "src_1DdoduGh0CmXqmnwKJmq8lpv",
"object": "source",
"ach_credit_transfer": {
"account_number": "test_a479e261d1bb",
"bank_name": "TEST BANK",
"fingerprint": "cAzkzdbKgZYS8NhC",
"routing_number": "110000000",
"swift_code": "TSTEZ122",
"refund_routing_number": null,
"refund_account_number": null,
"refund_account_holder_type": null,
"refund_account_holder_name": null
},
"amount": null,
"client_secret": "src_client_secret_E60fLRPcrzhAJNmOjvsUHTli",
"created": 1543971214,
"currency": "usd",
"customer": "cus_E60fF1CuG8K36D",
"flow": "receiver",
"livemode": false,
"metadata": {
},
"owner": {
"address": null,
"email": "amount_0@stripe.com",
"name": null,
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": null,
"verified_phone": null
},
"receiver": {
"address": "110000000-test_a479e261d1bb",
"amount_charged": 0,
"amount_received": 0,
"amount_returned": 0,
"refund_attributes_method": "email",
"refund_attributes_status": "missing"
},
"statement_descriptor": null,
"status": "pending",
"type": "ach_credit_transfer",
"usage": "reusable"
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/customers/cus_E60fF1CuG8K36D/sources"
},
"subscriptions": {
"object": "list",
"data": [
{
"id": "sub_E60fxv28JMUWOG",
"object": "subscription",
"application_fee_percent": null,
"billing": "send_invoice",
"billing_cycle_anchor": 1543971209,
"cancel_at_period_end": false,
"canceled_at": null,
"created": 1543971209,
"current_period_end": 1575507209,
"current_period_start": 1543971209,
"customer": "cus_E60fF1CuG8K36D",
"days_until_due": 30,
"default_source": null,
"discount": null,
"ended_at": null,
"items": {
"object": "list",
"data": [
{
"id": "si_E60fDuBL4e55ZF",
"object": "subscription_item",
"created": 1543971210,
"metadata": {
},
"plan": {
"id": "plan_Do3xCvbzO89OsR",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1539831971,
"currency": "usd",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"nickname": "annual",
"product": "prod_Do3x494SetTDpx",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 8,
"subscription": "sub_E60fxv28JMUWOG"
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_E60fxv28JMUWOG"
},
"livemode": false,
"metadata": {
},
"plan": {
"id": "plan_Do3xCvbzO89OsR",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1539831971,
"currency": "usd",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"nickname": "annual",
"product": "prod_Do3x494SetTDpx",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 8,
"start": 1543971210,
"status": "active",
"tax_percent": 0,
"trial_end": null,
"trial_start": null
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/customers/cus_E60fF1CuG8K36D/subscriptions"
},
"tax_info": null,
"tax_info_verification": null
},
"previous_attributes": {
"account_balance": 0
}
},
"livemode": true,
"pending_webhooks": 1,
"request": {
"id": null,
"idempotency_key": null
},
"type": "customer.updated"
}

View File

@ -1,134 +0,0 @@
{
"id": "evt_1DbqCuDEQaroqDjsIkZIT6uP",
"object": "event",
"api_version": "2018-11-08",
"created": 1543500572,
"data": {
"object": {
"id": "cus_00000000000000",
"object": "customer",
"account_balance": 0,
"created": 1535559697,
"currency": "usd",
"default_source": "src_1DBQGZDEQaroqDjsfHSp24Hn",
"delinquent": false,
"description": "Acme corp",
"discount": {
"object": "discount",
"coupon": {
"id": "8nj6KZjp",
"object": "coupon",
"amount_off": null,
"created": 1542077780,
"currency": null,
"duration": "forever",
"duration_in_months": null,
"livemode": true,
"max_redemptions": null,
"metadata": {
},
"name": "85% discount",
"percent_off": 85,
"redeem_by": null,
"times_redeemed": 7,
"valid": true
},
"customer": "cus_DVXVXVNyLFSY9I",
"end": null,
"start": 1542387411,
"subscription": null
},
"email": "user@acme.com",
"invoice_prefix": "C0CDBB3",
"livemode": true,
"metadata": {
},
"shipping": {
"address": {
"city": "Pacific",
"country": "AA",
"line1": "Attn.: Road Runner",
"line2": "PO Box 1234",
"postal_code": "1234 AA",
"state": ""
},
"name": "",
"phone": null
},
"sources": {
"object": "list",
"data": [
{
"id": "src_1DBQGZDEQaroqDjsfHSp24Hn",
"object": "source",
"ach_credit_transfer": {
"account_number": "1234",
"bank_name": "WELLS FARGO BANK, N.A.",
"fingerprint": "lfPIkuf8skm5HD7v",
"routing_number": "1234",
"swift_code": "WFBIUS6S",
"refund_routing_number": null,
"refund_account_number": null,
"refund_account_holder_type": null,
"refund_account_holder_name": null
},
"amount": null,
"client_secret": "src_client_secret_DcfbFOQwJOCAfKoNgiVeT6lp",
"created": 1537204327,
"currency": "usd",
"customer": "cus_DVXVXVNyLFSY9I",
"flow": "receiver",
"livemode": true,
"metadata": {
},
"owner": {
"address": null,
"email": "user@acme.com",
"name": null,
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": null,
"verified_phone": null
},
"receiver": {
"address": "1234-1234",
"amount_charged": 100,
"amount_received": 100,
"amount_returned": 0,
"refund_attributes_method": "email",
"refund_attributes_status": "missing"
},
"statement_descriptor": null,
"status": "pending",
"type": "ach_credit_transfer",
"usage": "reusable"
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/customers/cus_DVXVXVNyLFSY9I/sources"
},
"subscriptions": {
"object": "list",
"data": [
],
"has_more": false,
"total_count": 0,
"url": "/v1/customers/cus_DVXVXVNyLFSY9I/subscriptions"
},
"tax_info": null,
"tax_info_verification": null
},
"previous_attributes": {
"delinquent": true
}
},
"livemode": true,
"pending_webhooks": 1,
"request": {
"id": null,
"idempotency_key": null
},
"type": "customer.updated"
}

View File

@ -84,11 +84,11 @@ Quantity: 1"""
expected_topic, expected_message, expected_topic, expected_message,
content_type="application/x-www-form-urlencoded") content_type="application/x-www-form-urlencoded")
def test_customer_updated__delinquency(self) -> None: def test_customer_updated__account_balance(self) -> None:
expected_topic = "cus_00000000000000" expected_topic = "cus_00000000000000"
expected_message = "[Customer](https://dashboard.stripe.com/customers/cus_00000000000000) updated" + \ expected_message = "[Customer](https://dashboard.stripe.com/customers/cus_00000000000000) updated" + \
"\n* Delinquent is now False" "\n* Account balance is now 100"
self.send_and_test_stream_message('customer_updated__delinquency', expected_topic, expected_message, self.send_and_test_stream_message('customer_updated__account_balance', expected_topic, expected_message,
content_type="application/x-www-form-urlencoded") content_type="application/x-www-form-urlencoded")
def test_customer_discount_created(self) -> None: def test_customer_discount_created(self) -> None:

View File

@ -1,7 +1,7 @@
# Webhooks for external integrations. # Webhooks for external integrations.
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -16,6 +16,9 @@ from zerver.models import UserProfile
class NotImplementedEventType(Exception): class NotImplementedEventType(Exception):
pass pass
class SuppressedEvent(Exception):
pass
@api_key_only_webhook_view('Stripe') @api_key_only_webhook_view('Stripe')
@has_request_variables @has_request_variables
def api_stripe_webhook(request: HttpRequest, user_profile: UserProfile, def api_stripe_webhook(request: HttpRequest, user_profile: UserProfile,
@ -26,6 +29,8 @@ def api_stripe_webhook(request: HttpRequest, user_profile: UserProfile,
check_send_webhook_message(request, user_profile, topic, body) check_send_webhook_message(request, user_profile, topic, body)
except NotImplementedEventType: # nocoverage except NotImplementedEventType: # nocoverage
pass pass
except SuppressedEvent: # nocoverage
pass
return json_success() return json_success()
def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]: def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
@ -47,14 +52,29 @@ def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
topic = customer_id topic = customer_id
body = None body = None
def default_body() -> str: def update_string(blacklist: List[str]=[]) -> str:
return '{resource} {verbed}'.format( assert('previous_attributes' in payload['data'])
previous_attributes = payload['data']['previous_attributes']
for attribute in blacklist:
previous_attributes.pop(attribute, None)
if not previous_attributes: # nocoverage
raise SuppressedEvent()
return ''.join('\n* ' + attribute.replace('_', ' ').capitalize() +
' is now ' + str(object_[attribute])
for attribute in sorted(previous_attributes.keys()))
def default_body(update_blacklist: List[str]=[]) -> str:
body = '{resource} {verbed}'.format(
resource=linkified_id(object_['id']), verbed=event.replace('_', ' ')) resource=linkified_id(object_['id']), verbed=event.replace('_', ' '))
if event == 'updated':
return body + update_string(blacklist=update_blacklist)
return body
if category == 'account': # nocoverage if category == 'account': # nocoverage
if resource == 'account':
if event == 'updated': if event == 'updated':
topic = "account updates" topic = "account updates"
body = '' body = update_string()
else: else:
# Part of Stripe Connect # Part of Stripe Connect
raise NotImplementedEventType() raise NotImplementedEventType()
@ -93,7 +113,7 @@ def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
# Running into the 60 character topic limit. # Running into the 60 character topic limit.
# topic = '[{}](https://dashboard.stripe.com/customers/{})' % (object_['id'], object_['id']) # topic = '[{}](https://dashboard.stripe.com/customers/{})' % (object_['id'], object_['id'])
topic = object_['id'] topic = object_['id']
body = default_body() body = default_body(update_blacklist=['delinquent', 'currency', 'default_source'])
if event == 'created': if event == 'created':
if object_['email']: if object_['email']:
body += '\nEmail: {}'.format(object_['email']) body += '\nEmail: {}'.format(object_['email'])
@ -131,7 +151,7 @@ def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
if event == 'upcoming': # nocoverage if event == 'upcoming': # nocoverage
body = 'Upcoming invoice created' body = 'Upcoming invoice created'
else: else:
body = default_body() body = default_body(update_blacklist=['lines', 'description', 'number', 'finalized_at'])
if event == 'created': # nocoverage if event == 'created': # nocoverage
# Could potentially add link to invoice PDF here # Could potentially add link to invoice PDF here
body += ' ({reason})\nBilling method: {method}\nTotal: {total}\nAmount due: {due}'.format( body += ' ({reason})\nBilling method: {method}\nTotal: {total}\nAmount due: {due}'.format(
@ -140,7 +160,7 @@ def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
total=amount_string(object_['total'], object_['currency']), total=amount_string(object_['total'], object_['currency']),
due=amount_string(object_['amount_due'], object_['currency'])) due=amount_string(object_['amount_due'], object_['currency']))
if category == 'invoiceitem': # nocoverage if category == 'invoiceitem': # nocoverage
body = default_body() body = default_body(update_blacklist=['description'])
if event == 'created': if event == 'created':
body += ' for {amount}'.format(amount=amount_string(object_['amount'], object_['currency'])) body += ' for {amount}'.format(amount=amount_string(object_['amount'], object_['currency']))
if category.startswith('issuing'): # nocoverage if category.startswith('issuing'): # nocoverage
@ -159,13 +179,6 @@ def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
if body is None: if body is None:
raise UnexpectedWebhookEventType('Stripe', event_type) raise UnexpectedWebhookEventType('Stripe', event_type)
if 'previous_attributes' in payload['data']: # nocoverage
previous_attributes = payload['data']['previous_attributes']
else:
previous_attributes = {}
body += update_string(object_, previous_attributes)
body = body.strip()
return (topic, body) return (topic, body)
def amount_string(amount: int, currency: str) -> str: def amount_string(amount: int, currency: str) -> str:
@ -180,10 +193,6 @@ def amount_string(amount: int, currency: str) -> str:
return '$' + decimal_amount return '$' + decimal_amount
return decimal_amount + ' {}'.format(currency.upper()) return decimal_amount + ' {}'.format(currency.upper())
def update_string(object_: Dict[str, Any], previous_attributes: Dict[str, Any]) -> str:
return ''.join('\n* ' + attribute.replace('_', ' ').capitalize() + ' is now ' + str(object_[attribute])
for attribute in previous_attributes)
def linkified_id(object_id: str, lower: bool=False) -> str: def linkified_id(object_id: str, lower: bool=False) -> str:
names_and_urls = { names_and_urls = {
# Core resources # Core resources