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,
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_message = "[Customer](https://dashboard.stripe.com/customers/cus_00000000000000) updated" + \
"\n* Delinquent is now False"
self.send_and_test_stream_message('customer_updated__delinquency', expected_topic, expected_message,
"\n* Account balance is now 100"
self.send_and_test_stream_message('customer_updated__account_balance', expected_topic, expected_message,
content_type="application/x-www-form-urlencoded")
def test_customer_discount_created(self) -> None:

View File

@ -1,7 +1,7 @@
# Webhooks for external integrations.
import time
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.utils.translation import ugettext as _
@ -16,6 +16,9 @@ from zerver.models import UserProfile
class NotImplementedEventType(Exception):
pass
class SuppressedEvent(Exception):
pass
@api_key_only_webhook_view('Stripe')
@has_request_variables
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)
except NotImplementedEventType: # nocoverage
pass
except SuppressedEvent: # nocoverage
pass
return json_success()
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
body = None
def default_body() -> str:
return '{resource} {verbed}'.format(
def update_string(blacklist: List[str]=[]) -> str:
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('_', ' '))
if event == 'updated':
return body + update_string(blacklist=update_blacklist)
return body
if category == 'account': # nocoverage
if resource == 'account':
if event == 'updated':
topic = "account updates"
body = ''
body = update_string()
else:
# Part of Stripe Connect
raise NotImplementedEventType()
@ -93,7 +113,7 @@ def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
# Running into the 60 character topic limit.
# topic = '[{}](https://dashboard.stripe.com/customers/{})' % (object_['id'], object_['id'])
topic = object_['id']
body = default_body()
body = default_body(update_blacklist=['delinquent', 'currency', 'default_source'])
if event == 'created':
if 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
body = 'Upcoming invoice created'
else:
body = default_body()
body = default_body(update_blacklist=['lines', 'description', 'number', 'finalized_at'])
if event == 'created': # nocoverage
# Could potentially add link to invoice PDF here
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']),
due=amount_string(object_['amount_due'], object_['currency']))
if category == 'invoiceitem': # nocoverage
body = default_body()
body = default_body(update_blacklist=['description'])
if event == 'created':
body += ' for {amount}'.format(amount=amount_string(object_['amount'], object_['currency']))
if category.startswith('issuing'): # nocoverage
@ -159,13 +179,6 @@ def topic_and_body(payload: Dict[str, Any]) -> Tuple[str, str]:
if body is None:
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)
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 + ' {}'.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:
names_and_urls = {
# Core resources