mirror of https://github.com/zulip/zulip.git
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:
parent
5fb683e788
commit
027d5e90c5
|
@ -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"
|
||||||
|
}
|
|
@ -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"
|
|
||||||
}
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue