2016-12-03 18:51:33 +01:00
|
|
|
# Webhooks for external integrations.
|
2017-11-16 00:43:10 +01:00
|
|
|
import time
|
|
|
|
from datetime import datetime
|
2018-11-21 01:39:37 +01:00
|
|
|
from typing import Any, Dict, Optional, Tuple
|
2017-11-16 00:43:10 +01:00
|
|
|
|
|
|
|
from django.http import HttpRequest, HttpResponse
|
2016-12-03 18:51:33 +01:00
|
|
|
from django.utils.translation import ugettext as _
|
2017-11-16 00:43:10 +01:00
|
|
|
|
2017-10-31 04:25:48 +01:00
|
|
|
from zerver.decorator import api_key_only_webhook_view
|
|
|
|
from zerver.lib.request import REQ, has_request_variables
|
2017-11-16 00:43:10 +01:00
|
|
|
from zerver.lib.response import json_error, json_success
|
2018-05-22 16:46:45 +02:00
|
|
|
from zerver.lib.webhooks.common import check_send_webhook_message, \
|
|
|
|
UnexpectedWebhookEventType
|
2017-05-02 01:00:50 +02:00
|
|
|
from zerver.models import UserProfile
|
2016-12-03 18:51:33 +01:00
|
|
|
|
|
|
|
@api_key_only_webhook_view('Stripe')
|
|
|
|
@has_request_variables
|
2017-12-04 11:57:04 +01:00
|
|
|
def api_stripe_webhook(request: HttpRequest, user_profile: UserProfile,
|
|
|
|
payload: Dict[str, Any]=REQ(argument_type='body'),
|
2018-05-10 19:34:01 +02:00
|
|
|
stream: str=REQ(default='test')) -> HttpResponse:
|
2018-11-21 01:39:37 +01:00
|
|
|
event_type = payload["type"] # invoice.created, customer.subscription.created, etc
|
|
|
|
if len(event_type.split('.')) == 3:
|
|
|
|
category, resource, event = event_type.split('.')
|
|
|
|
else:
|
|
|
|
resource, event = event_type.split('.')
|
|
|
|
category = resource
|
|
|
|
|
|
|
|
object_ = payload["data"]["object"] # The full, updated Stripe object
|
|
|
|
|
|
|
|
# Set the topic to the customer_id when we can
|
|
|
|
topic = ''
|
|
|
|
customer_id = object_.get("customer", None)
|
|
|
|
if customer_id is not None:
|
|
|
|
# Running into the 60 character topic limit.
|
|
|
|
# topic = '[{}](https://dashboard.stripe.com/customers/{})' % (customer_id, customer_id)
|
|
|
|
topic = customer_id
|
2016-12-23 17:21:51 +01:00
|
|
|
body = None
|
2016-12-23 17:00:32 +01:00
|
|
|
|
2018-11-21 01:39:37 +01:00
|
|
|
def default_body() -> str:
|
|
|
|
return '{resource} {verbed}'.format(
|
|
|
|
resource=linkified_id(object_['id']), verbed=event.replace('_', ' '))
|
|
|
|
|
|
|
|
if category == 'account': # nocoverage
|
|
|
|
if event == 'updated':
|
|
|
|
topic = "account updates"
|
|
|
|
body = ''
|
2017-08-24 17:31:04 +02:00
|
|
|
else:
|
2018-11-21 01:39:37 +01:00
|
|
|
# Part of Stripe Connect
|
|
|
|
return json_success()
|
|
|
|
if category == 'application_fee': # nocoverage
|
|
|
|
# Part of Stripe Connect
|
|
|
|
return json_success()
|
|
|
|
if category == 'balance': # nocoverage
|
|
|
|
# Not that interesting to most businesses, I think
|
|
|
|
return json_success()
|
|
|
|
if category == 'charge':
|
|
|
|
if resource == 'charge':
|
2018-12-02 09:21:09 +01:00
|
|
|
if not topic: # only in legacy fixtures
|
2018-11-21 01:39:37 +01:00
|
|
|
topic = 'charges'
|
|
|
|
body = "{resource} for {amount} {verbed}".format(
|
|
|
|
resource=linkified_id(object_['id']),
|
|
|
|
amount=amount_string(object_['amount'], object_['currency']), verbed=event)
|
|
|
|
if object_['failure_code']: # nocoverage
|
|
|
|
body += '. Failure code: {}'.format(object_['failure_code'])
|
|
|
|
if resource == 'dispute':
|
|
|
|
topic = 'disputes'
|
|
|
|
body = default_body() + '. Current status: {status}.'.format(
|
|
|
|
status=object_['status'].replace('_', ' '))
|
|
|
|
if resource == 'refund': # nocoverage
|
|
|
|
topic = 'refunds'
|
|
|
|
body = 'A {resource} for a {charge} of {amount} was updated.'.format(
|
|
|
|
resource=linkified_id(object_['id'], lower=True),
|
|
|
|
charge=linkified_id(object_['charge'], lower=True), amount=object_['amount'])
|
|
|
|
if category == 'checkout_beta': # nocoverage
|
|
|
|
# Not sure what this is
|
|
|
|
return json_success()
|
|
|
|
if category == 'coupon': # nocoverage
|
|
|
|
# Not something that likely happens programmatically
|
|
|
|
return json_success()
|
|
|
|
if category == 'customer':
|
|
|
|
if resource == 'customer':
|
|
|
|
# Running into the 60 character topic limit.
|
|
|
|
# topic = '[{}](https://dashboard.stripe.com/customers/{})' % (object_['id'], object_['id'])
|
|
|
|
topic = object_['id']
|
|
|
|
body = default_body()
|
|
|
|
if event == 'created':
|
|
|
|
if object_['email']:
|
|
|
|
body += '\nEmail: {}'.format(object_['email'])
|
|
|
|
if object_['metadata']: # nocoverage
|
|
|
|
for key, value in object_['metadata'].items():
|
|
|
|
body += '\n{}: {}'.format(key, value)
|
2018-11-28 22:11:08 +01:00
|
|
|
if resource == 'discount':
|
|
|
|
body = 'Discount {verbed} ([{coupon_name}]({coupon_url})).'.format(
|
|
|
|
verbed=event.replace('_', ' '),
|
|
|
|
coupon_name=object_['coupon']['name'],
|
|
|
|
coupon_url='https://dashboard.stripe.com/{}/{}'.format('coupons', object_['coupon']['id'])
|
|
|
|
)
|
2018-11-21 01:39:37 +01:00
|
|
|
if resource == 'source': # nocoverage
|
|
|
|
body = default_body()
|
|
|
|
if resource == 'subscription':
|
|
|
|
body = default_body()
|
|
|
|
if event == 'trial_will_end':
|
2017-08-24 17:31:04 +02:00
|
|
|
DAY = 60 * 60 * 24 # seconds in a day
|
2018-11-21 01:39:37 +01:00
|
|
|
# Basically always three: https://stripe.com/docs/api/python#event_types
|
|
|
|
body += ' in {days} days'.format(
|
|
|
|
days=int((object_["trial_end"] - time.time() + DAY//2) // DAY))
|
|
|
|
if event == 'created':
|
|
|
|
if object_['plan']:
|
|
|
|
body += '\nPlan: [{plan_name}](https://dashboard.stripe.com/plans/{plan_id})'.format(
|
|
|
|
plan_name=object_['plan']['name'], plan_id=object_['plan']['id'])
|
|
|
|
if object_['quantity']:
|
|
|
|
body += '\nQuantity: {}'.format(object_['quantity'])
|
|
|
|
if 'billing' in object_: # nocoverage
|
|
|
|
body += '\nBilling method: {}'.format(object_['billing'].replace('_', ' '))
|
|
|
|
if category == 'file': # nocoverage
|
|
|
|
topic = 'files'
|
|
|
|
body = default_body() + ' ({purpose}). \nTitle: {title}'.format(
|
|
|
|
purpose=object_['purpose'].replace('_', ' '), title=object_['title'])
|
|
|
|
if category == 'invoice':
|
|
|
|
if event == 'upcoming': # nocoverage
|
|
|
|
body = 'Upcoming invoice created'
|
2018-11-19 20:40:54 +01:00
|
|
|
else:
|
2018-11-21 01:39:37 +01:00
|
|
|
body = default_body()
|
|
|
|
if event == 'created': # nocoverage
|
|
|
|
# Could potentially add link to invoice PDF here
|
|
|
|
body += ' ({reason})\nBilling method: {method}\nTotal: {total}\nAmount due: {due}'.format(
|
|
|
|
reason=object_['billing_reason'].replace('_', ' '),
|
|
|
|
method=object_['billing'].replace('_', ' '),
|
|
|
|
total=amount_string(object_['total'], object_['currency']),
|
|
|
|
due=amount_string(object_['amount_due'], object_['currency']))
|
|
|
|
if category == 'invoiceitem': # nocoverage
|
|
|
|
body = default_body()
|
|
|
|
if event == 'created':
|
|
|
|
body += ' for {amount}'.format(amount=amount_string(object_['amount'], object_['currency']))
|
|
|
|
if category.startswith('issuing'): # nocoverage
|
|
|
|
# Not implemented
|
|
|
|
return json_success()
|
|
|
|
if category.startswith('order'): # nocoverage
|
|
|
|
# Not implemented
|
|
|
|
return json_success()
|
|
|
|
if category in ['payment_intent', 'payout', 'plan', 'product', 'recipient',
|
|
|
|
'reporting', 'review', 'sigma', 'sku', 'source', 'subscription_schedule',
|
|
|
|
'topup', 'transfer']: # nocoverage
|
|
|
|
# Not implemented. In theory doing something like
|
|
|
|
# body = default_body()
|
|
|
|
# may not be hard for some of these
|
|
|
|
return json_success()
|
2016-12-23 17:21:51 +01:00
|
|
|
|
|
|
|
if body is None:
|
2018-05-22 16:46:45 +02:00
|
|
|
raise UnexpectedWebhookEventType('Stripe', event_type)
|
2016-12-03 18:51:33 +01:00
|
|
|
|
2018-11-21 01:39:37 +01:00
|
|
|
if 'previous_attributes' in payload['data']: # nocoverage
|
|
|
|
previous_attributes = payload['data']['previous_attributes']
|
|
|
|
else:
|
|
|
|
previous_attributes = {}
|
2018-12-03 07:44:04 +01:00
|
|
|
body += update_string(object_, previous_attributes)
|
2018-11-21 01:39:37 +01:00
|
|
|
body = body.strip()
|
2016-12-03 18:51:33 +01:00
|
|
|
|
2018-11-21 01:39:37 +01:00
|
|
|
check_send_webhook_message(request, user_profile, topic, body)
|
2016-12-03 18:51:33 +01:00
|
|
|
return json_success()
|
|
|
|
|
2018-11-21 01:39:37 +01:00
|
|
|
def amount_string(amount: int, currency: str) -> str:
|
2017-10-27 02:31:10 +02:00
|
|
|
zero_decimal_currencies = ["bif", "djf", "jpy", "krw", "pyg", "vnd", "xaf",
|
|
|
|
"xpf", "clp", "gnf", "kmf", "mga", "rwf", "vuv", "xof"]
|
2016-12-03 18:51:33 +01:00
|
|
|
if currency in zero_decimal_currencies:
|
2018-11-21 01:39:37 +01:00
|
|
|
decimal_amount = str(amount) # nocoverage
|
2016-12-03 18:51:33 +01:00
|
|
|
else:
|
2018-11-21 01:39:37 +01:00
|
|
|
decimal_amount = '{0:.02f}'.format(float(amount) * 0.01)
|
|
|
|
|
|
|
|
if currency == 'usd': # nocoverage
|
|
|
|
return '$' + decimal_amount
|
|
|
|
return decimal_amount + ' {}'.format(currency.upper())
|
|
|
|
|
2018-12-03 07:44:04 +01:00
|
|
|
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)
|
2018-11-21 01:39:37 +01:00
|
|
|
|
|
|
|
def linkified_id(object_id: str, lower: bool=False) -> str:
|
|
|
|
names_and_urls = {
|
|
|
|
# Core resources
|
|
|
|
'ch': ('Charge', 'charges'),
|
|
|
|
'cus': ('Customer', 'customers'),
|
|
|
|
'dp': ('Dispute', 'disputes'),
|
|
|
|
'file': ('File', 'files'),
|
|
|
|
'link': ('File link', 'file_links'),
|
|
|
|
'pi': ('Payment intent', 'payment_intents'),
|
|
|
|
'po': ('Payout', 'payouts'),
|
|
|
|
'prod': ('Product', 'products'),
|
|
|
|
're': ('Refund', 'refunds'),
|
|
|
|
'tok': ('Token', 'tokens'),
|
|
|
|
|
|
|
|
# Payment methods
|
|
|
|
# payment methods have URL prefixes like /customers/cus_id/sources
|
|
|
|
'ba': ('Bank account', None),
|
|
|
|
'card': ('Card', None),
|
|
|
|
'src': ('Source', None),
|
|
|
|
|
|
|
|
# Billing
|
|
|
|
# coupons have a configurable id, but the URL prefix is /coupons
|
|
|
|
# discounts don't have a URL, I think
|
|
|
|
'in': ('Invoice', 'invoices'),
|
|
|
|
'ii': ('Invoice item', 'invoiceitems'),
|
|
|
|
# products are covered in core resources
|
|
|
|
# plans have a configurable id, though by default they are created with this pattern
|
|
|
|
# 'plan': ('Plan', 'plans'),
|
|
|
|
'sub': ('Subscription', 'subscriptions'),
|
|
|
|
'si': ('Subscription item', 'subscription_items'),
|
|
|
|
# I think usage records have URL prefixes like /subscription_items/si_id/usage_record_summaries
|
|
|
|
'mbur': ('Usage record', None),
|
|
|
|
|
2018-12-02 09:21:09 +01:00
|
|
|
# Undocumented :|
|
|
|
|
'py': ('Payment', 'payments'),
|
|
|
|
|
2018-11-21 01:39:37 +01:00
|
|
|
# Connect, Fraud, Orders, etc not implemented
|
|
|
|
} # type: Dict[str, Tuple[str, Optional[str]]]
|
|
|
|
name, url_prefix = names_and_urls[object_id.split('_')[0]]
|
|
|
|
if lower: # nocoverage
|
|
|
|
name = name.lower()
|
|
|
|
if url_prefix is None: # nocoverage
|
|
|
|
return name
|
|
|
|
return '[{}](https://dashboard.stripe.com/{}/{})'.format(name, url_prefix, object_id)
|