Skip to content

Commit

Permalink
Merge branch 'feature/coupons' into feature/fix-webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
jacoor committed Sep 5, 2017
2 parents 5b0d970 + f3abd32 commit 672eb09
Show file tree
Hide file tree
Showing 10 changed files with 160 additions and 31 deletions.
56 changes: 56 additions & 0 deletions README.rst
Expand Up @@ -119,6 +119,35 @@ The command above returns whole plan data send by stripe.
https://stripe.com/docs/api#plans


Coupons Support
---------------
Stripe coupons can be created both in the Stripe Dashboard and using the ``aa_stripe.models.StripeCoupon`` model, and also if webhooks are properly configured in your app, you will be able to see all changes related to coupons made in the Stripe Dashboard.
This works both ways, if a coupon was created, edited or deleted on the application side, the list of coupons in Stripe will be updated respectively.
::

from aa_stripe.models import StripeCoupon

coupon = StripeCoupon.objects.create(
coupon_id="SALE10",
duration=StripeCoupon.DURATION_FOREVER,
currency="usd",
amount_off=10, # in dollars
)
# coupon was created at Stripe
coupon.delete()
# coupon was deleted from Stripe, but the StripeCoupon object is kept
print(coupon.is_deleted) # True

**Important:** When updating coupon data, do not use the ``StripeCoupon.objects.update()`` method, because it does not call the ``StripeCoupon.save()`` method, and therefore the coupon will not be updated at Stripe.

The refresh_coupons management command
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To make sure your app is always up to date with Stripe, the ``refresh_coupons`` management command should be run chronically.
It allows to periodically verify if all coupons are correctly stored in your app and no new coupons were created or deleted at Stripe.

For more information about coupons, see: https://stripe.com/docs/api#coupons


Webhooks support
----------------
All webhooks should be sent to ``/aa-stripe/webhooks`` url. Add ``STRIPE_WEBHOOK_ENDPOINT_SECRET`` to your settings to enable webhook verifications. Each received webhook is saved as StripeWebhook object in database. User need to add parsing webhooks depending on the project.
Expand All @@ -130,6 +159,33 @@ so if you have some kind of error tracking service configured on your servers (f

By default the site used in the ``check_pending_webhooks`` command is the first ``django.contrib.sites.models.Site`` object from the database, but in case you need to use some other site, please use the ``--site`` parameter to pass your site's id.

Parsing webhooks
^^^^^^^^^^^^^^^^
To parse webhooks, you can connect to the ``aa_stripe.models.webhook_pre_parse`` signal, which is sent each time a
``StripeWebhook`` object is parsed.

Sample usage:

::

from aa_stripe.models import StripeWebhook, webhook_pre_parse

def stripewebhook_pre_parse(sender, instance, event_type, event_model, event_action, **kwargs):
if not instance.is_parsed:
# parse

webhook_pre_parse.connect(stripewebhook_pre_parse, sender=StripeWebhook)

Arguments:

* sender - the ``StripeWebhook`` class
* instance - the ``StripeWebhook`` event object
* event_type - Stripe event type (for example: ``coupon.created``, ``invoice.payment_failed``, ``ping``, etc., see: https://stripe.com/docs/api#event_types)
* event_model - the model which created the event (for example: ``coupon``, ``invoice``, ``charge.dispute``, etc.)
* event_action - the action done on the ``event_model`` (for example: ``created``, ``updated``, ``payment_failed``, etc.)

Both ``event_model`` and ``event_action`` equal to ``None`` if ``event_type`` is a ``ping`` event.

Support
=======
* Django 1.11
Expand Down
4 changes: 0 additions & 4 deletions aa_stripe/admin.py
Expand Up @@ -63,10 +63,6 @@ def get_readonly_fields(self, request, obj=None):

return self.readonly_fields

def has_delete_permission(self, request, obj=None):
# allow deleting single object, but disable bulk delete (bulk delete does not call models' .delete() method)
return bool(obj)


class StripeSubscriptionAdmin(ReadOnly):
list_display = (
Expand Down
2 changes: 1 addition & 1 deletion aa_stripe/api_urls.py
Expand Up @@ -5,7 +5,7 @@
from aa_stripe.api import CouponDetailsAPI, CustomersAPI, WebhookAPI

urlpatterns = [
url(r"^aa-stripe/coupons/(?P<coupon_id>[\w]+)$", CouponDetailsAPI.as_view(), name="stripe-coupon-details"),
url(r"^aa-stripe/coupons/(?P<coupon_id>.*)$", CouponDetailsAPI.as_view(), name="stripe-coupon-details"),
url(r"^aa-stripe/customers$", CustomersAPI.as_view(), name="stripe-customers"),
url(r"^aa-stripe/webhooks$", WebhookAPI.as_view(), name="stripe-webhooks")
]
Expand Down
8 changes: 8 additions & 0 deletions aa_stripe/forms.py
Expand Up @@ -5,6 +5,14 @@


class StripeCouponForm(forms.ModelForm):
def clean_currency(self):
amount_off = self.cleaned_data.get("amount_off")
currency = self.cleaned_data.get("currency")
if amount_off and not currency:
raise forms.ValidationError(_("Currency is required when amount_off is set"))

return currency

def clean_coupon_id(self):
coupon_id = self.cleaned_data.get("coupon_id")
if coupon_id:
Expand Down
6 changes: 4 additions & 2 deletions aa_stripe/management/commands/refresh_coupons.py
Expand Up @@ -29,8 +29,10 @@ def handle(self, *args, **options):
is_deleted=False)
counts["updated"] += coupon.update_from_stripe_data(stripe_coupon)
except StripeCoupon.DoesNotExist:
# already have the data - we do not need to call Stripe API again
coupon = StripeCoupon(coupon_id=stripe_coupon.id)
coupon.save(force_retrieve=True)
coupon.update_from_stripe_data(stripe_coupon, commit=False)
super(StripeCoupon, coupon).save()
counts["created"] += 1

# indicate which coupons should have is_deleted=False
Expand All @@ -45,4 +47,4 @@ def handle(self, *args, **options):
counts["deleted"] = StripeCoupon.objects.filter(is_deleted=False).exclude(
pk__in=active_coupons_ids).update(is_deleted=True)
if options.get("verbosity") > 1:
print("Coupons created: {created}, updated: {updated}, deleted {deleted}".format(**counts))
print("Coupons created: {created}, updated: {updated}, deleted: {deleted}".format(**counts))
5 changes: 4 additions & 1 deletion aa_stripe/migrations/0010_auto_20170822_1004.py
Expand Up @@ -2,6 +2,8 @@
# Generated by Django 1.11.3 on 2017-08-22 10:04
from __future__ import unicode_literals

from decimal import Decimal

import django.db.models.deletion
import jsonfield.fields
import stripe
Expand Down Expand Up @@ -33,6 +35,7 @@ def migrate_subcription(apps, schema_editor):
}
update_data = {key: stripe_coupon[key] for key in fields}
update_data["created"] = timestamp_to_timezone_aware_date(stripe_coupon.get("created"))
update_data["amount_off"] = Decimal(update_data["amount_off"]) / 100
coupon = StripeCoupon.objects.create(coupon_id=stripe_coupon.get("id"), is_created_at_stripe=True,
**update_data)
subscription.coupon = coupon
Expand All @@ -53,7 +56,7 @@ class Migration(migrations.Migration):
('updated', models.DateTimeField(auto_now=True)),
('stripe_response', jsonfield.fields.JSONField(default=dict)),
('coupon_id', models.CharField(help_text='Identifier for the coupon', max_length=255)),
('amount_off', models.PositiveIntegerField(blank=True, help_text='Amount (in the currency specified) that will be taken off the subtotal ofany invoices for this customer.', null=True)),
('amount_off', models.DecimalField(decimal_places=2, max_digits=10, blank=True, help_text='Amount (in the currency specified) that will be taken off the subtotal ofany invoices for this customer.', null=True)),
('currency', models.CharField(default='usd', max_length=3, null=True, blank=True, help_text='If amount_off has been set, the three-letter ISO code for the currency of the amount to take off.', choices=[('usd', 'USD'), ('aed', 'AED'), ('afn', 'AFN'), ('all', 'ALL'), ('amd', 'AMD'), ('ang', 'ANG'), ('aoa', 'AOA'), ('ars', 'ARS'), ('aud', 'AUD'), ('awg', 'AWG'), ('azn', 'AZN'), ('bam', 'BAM'), ('bbd', 'BBD'), ('bdt', 'BDT'), ('bgn', 'BGN'), ('bif', 'BIF'), ('bmd', 'BMD'), ('bnd', 'BND'), ('bob', 'BOB'), ('brl', 'BRL'), ('bsd', 'BSD'), ('bwp', 'BWP'), ('bzd', 'BZD'), ('cad', 'CAD'), ('cdf', 'CDF'), ('chf', 'CHF'), ('clp', 'CLP'), ('cny', 'CNY'), ('cop', 'COP'), ('crc', 'CRC'), ('cve', 'CVE'), ('czk', 'CZK'), ('djf', 'DJF'), ('dkk', 'DKK'), ('dop', 'DOP'), ('dzd', 'DZD'), ('egp', 'EGP'), ('etb', 'ETB'), ('eur', 'EUR'), ('fjd', 'FJD'), ('fkp', 'FKP'), ('gbp', 'GBP'), ('gel', 'GEL'), ('gip', 'GIP'), ('gmd', 'GMD'), ('gnf', 'GNF'), ('gtq', 'GTQ'), ('gyd', 'GYD'), ('hkd', 'HKD'), ('hnl', 'HNL'), ('hrk', 'HRK'), ('htg', 'HTG'), ('huf', 'HUF'), ('idr', 'IDR'), ('ils', 'ILS'), ('inr', 'INR'), ('isk', 'ISK'), ('jmd', 'JMD'), ('jpy', 'JPY'), ('kes', 'KES'), ('kgs', 'KGS'), ('khr', 'KHR'), ('kmf', 'KMF'), ('krw', 'KRW'), ('kyd', 'KYD'), ('kzt', 'KZT'), ('lak', 'LAK'), ('lbp', 'LBP'), ('lkr', 'LKR'), ('lrd', 'LRD'), ('lsl', 'LSL'), ('mad', 'MAD'), ('mdl', 'MDL'), ('mga', 'MGA'), ('mkd', 'MKD'), ('mmk', 'MMK'), ('mnt', 'MNT'), ('mop', 'MOP'), ('mro', 'MRO'), ('mur', 'MUR'), ('mvr', 'MVR'), ('mwk', 'MWK'), ('mxn', 'MXN'), ('myr', 'MYR'), ('mzn', 'MZN'), ('nad', 'NAD'), ('ngn', 'NGN'), ('nio', 'NIO'), ('nok', 'NOK'), ('npr', 'NPR'), ('nzd', 'NZD'), ('pab', 'PAB'), ('pen', 'PEN'), ('pgk', 'PGK'), ('php', 'PHP'), ('pkr', 'PKR'), ('pln', 'PLN'), ('pyg', 'PYG'), ('qar', 'QAR'), ('ron', 'RON'), ('rsd', 'RSD'), ('rub', 'RUB'), ('rwf', 'RWF'), ('sar', 'SAR'), ('sbd', 'SBD'), ('scr', 'SCR'), ('sek', 'SEK'), ('sgd', 'SGD'), ('shp', 'SHP'), ('sll', 'SLL'), ('sos', 'SOS'), ('srd', 'SRD'), ('std', 'STD'), ('svc', 'SVC'), ('szl', 'SZL'), ('thb', 'THB'), ('tjs', 'TJS'), ('top', 'TOP'), ('try', 'TRY'), ('ttd', 'TTD'), ('twd', 'TWD'), ('tzs', 'TZS'), ('uah', 'UAH'), ('ugx', 'UGX'), ('uyu', 'UYU'), ('uzs', 'UZS'), ('vnd', 'VND'), ('vuv', 'VUV'), ('wst', 'WST'), ('xaf', 'XAF'), ('xcd', 'XCD'), ('xof', 'XOF'), ('xpf', 'XPF'), ('yer', 'YER'), ('zar', 'ZAR'), ('zmw', 'ZMW')])),
('duration', models.CharField(choices=[('forever', 'forever'), ('once', 'once'), ('repeating', 'repeating')], help_text='Describes how long a customer who applies this coupon will get the discount.', max_length=255)),
('duration_in_months', models.PositiveIntegerField(blank=True, help_text='If duration is repeating, the number of months the coupon applies.Null if coupon duration is forever or once.', null=True)),
Expand Down
52 changes: 42 additions & 10 deletions aa_stripe/models.py
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from decimal import Decimal
from time import sleep

import simplejson as json
Expand Down Expand Up @@ -65,6 +66,22 @@ class Meta:
ordering = ["id"]


class StripeCouponQuerySet(models.query.QuerySet):
def delete(self):
# StripeCoupon.delete must be executed (along with post_save)
deleted_counter = 0
for obj in self:
obj.delete()
deleted_counter = deleted_counter + 1

return deleted_counter, {self.model._meta.label: deleted_counter}


class StripeCouponManager(models.Manager):
def get_queryset(self):
return StripeCouponQuerySet(self.model, using=self._db)


class StripeCoupon(StripeBasicModel):
# fields that are fetched from Stripe API
STRIPE_FIELDS = {
Expand Down Expand Up @@ -106,11 +123,12 @@ class StripeCoupon(StripeBasicModel):
)

coupon_id = models.CharField(max_length=255, help_text=_("Identifier for the coupon"))
amount_off = models.PositiveIntegerField(
blank=True, null=True, help_text=_("Amount (in the currency specified) that will be taken off the subtotal of "
"any invoices for this customer."))
amount_off = models.DecimalField(
blank=True, null=True, decimal_places=2, max_digits=10,
help_text=_("Amount (in the currency specified) that will be taken off the subtotal of any invoices for this"
"customer."))
currency = models.CharField(
max_length=3, default="USD", choices=CURRENCY_CHOICES, blank=True, null=True,
max_length=3, choices=CURRENCY_CHOICES, blank=True, null=True,
help_text=_("If amount_off has been set, the three-letter ISO code for the currency of the amount to take "
"off."))
duration = models.CharField(
Expand Down Expand Up @@ -141,29 +159,36 @@ class StripeCoupon(StripeBasicModel):
is_deleted = models.BooleanField(default=False)
is_created_at_stripe = models.BooleanField(default=False)

objects = StripeCouponManager()

def __init__(self, *args, **kwargs):
super(StripeCoupon, self).__init__(*args, **kwargs)
self._previous_is_deleted = self.is_deleted

def __str__(self):
return self.coupon_id

def update_from_stripe_data(self, stripe_coupon, exclude_fields=None):
def update_from_stripe_data(self, stripe_coupon, exclude_fields=None, commit=True):
"""
Update StripeCoupon object with data from stripe.Coupon without calling stripe.Coupon.retrieve.
Returns the number of rows altered.
To only update the object, set the commit param to False.
Returns the number of rows altered or None if commit is False.
"""
fields_to_update = self.STRIPE_FIELDS - set(exclude_fields or [])
update_data = {key: stripe_coupon[key] for key in fields_to_update}
if "created" in update_data:
if update_data.get("created"):
update_data["created"] = timestamp_to_timezone_aware_date(update_data["created"])

if update_data.get("amount_off"):
update_data["amount_off"] = Decimal(update_data["amount_off"]) / 100

# also make sure the object is up to date (without the need to call database)
for key, value in six.iteritems(update_data):
setattr(self, key, value)

return StripeCoupon.objects.filter(pk=self.pk).update(**update_data)
if commit:
return StripeCoupon.objects.filter(pk=self.pk).update(**update_data)

def save(self, force_retrieve=False, *args, **kwargs):
"""
Expand Down Expand Up @@ -193,12 +218,15 @@ def save(self, force_retrieve=False, *args, **kwargs):
self.update_from_stripe_data(coupon, exclude_fields=["metadata"] if not force_retrieve else [])
self.stripe_response = coupon
except stripe.error.InvalidRequestError:
if force_retrieve:
raise

self.is_deleted = True
else:
self.stripe_response = stripe.Coupon.create(
id=self.coupon_id,
duration=self.duration,
amount_off=self.amount_off,
amount_off=int(self.amount_off * 100) if self.amount_off else None,
currency=self.currency,
duration_in_months=self.duration_in_months,
max_redemptions=self.max_redemptions,
Expand Down Expand Up @@ -435,7 +463,11 @@ class StripeWebhook(models.Model):
def _parse_coupon_notification(self, action):
coupon_id = self.raw_data["data"]["object"]["id"]
if action == "created":
StripeCoupon(coupon_id=coupon_id).save(force_retrieve=True)
try:
StripeCoupon(coupon_id=coupon_id).save(force_retrieve=True)
except stripe.error.InvalidRequestError:
# do not fail in case the coupon has already been removed from Stripe before we received the webhook
pass
elif action == "updated":
StripeCoupon.objects.filter(coupon_id=coupon_id, is_deleted=False).update(
metadata=self.raw_data["data"]["object"]["metadata"])
Expand Down
34 changes: 25 additions & 9 deletions tests/test_coupons.py
@@ -1,5 +1,6 @@
import time
from datetime import datetime
from decimal import Decimal

import requests_mock
import simplejson as json
Expand Down Expand Up @@ -98,11 +99,11 @@ def test_update(self):

def test_delete(self):
coupon = self._create_coupon(coupon_id="CPON", amount_off=1, duration=StripeCoupon.DURATION_FOREVER)
self.assertFalse(coupon.is_deleted)
self.assertEqual(StripeCoupon.objects.filter(is_deleted=True).count(), 0)
stripe_response = {
"id": "CPON",
"object": "coupon",
"amount_off": 1,
"amount_off": 100,
"created": int(time.mktime(datetime.now().timetuple())),
"currency": "usd",
"duration": StripeCoupon.DURATION_FOREVER,
Expand All @@ -116,11 +117,21 @@ def test_delete(self):
"valid": True
}
with requests_mock.Mocker() as m:
for method in ["GET", "DELETE"]:
m.register_uri(method, "https://api.stripe.com/v1/coupons/CPON", text=json.dumps(stripe_response))
for coupon_name in ["CPON", "CPON2", "CPON3"]:
for method in ["GET", "DELETE"]:
m.register_uri(method, "https://api.stripe.com/v1/coupons/{}".format(coupon_name),
text=json.dumps(stripe_response))
coupon.delete()
coupon.refresh_from_db()
self.assertTrue(coupon.is_deleted)
self.assertEqual(StripeCoupon.objects.filter(is_deleted=True).count(), 1)

# also test the overriden queryset's delete
coupon2 = self._create_coupon(coupon_id="CPON2")
coupon3 = self._create_coupon(coupon_id="CPON3")
self.assertEqual(StripeCoupon.objects.filter(is_deleted=False).count(), 2)
delete_result = StripeCoupon.objects.filter(pk__in=[coupon2.pk, coupon3.pk]).delete()
self.assertEqual(delete_result, (2, {"aa_stripe.StripeCoupon": 2}))
self.assertEqual(StripeCoupon.objects.filter(is_deleted=True).count(), 3)
self.assertEqual(StripeCoupon.objects.filter(is_deleted=False).count(), 0)

def test_admin_form(self):
# test correct creation
Expand All @@ -145,6 +156,11 @@ def test_admin_form(self):
self.assertFalse(StripeCouponForm(data=data).is_valid())
del data["percent_off"]

# test passing amount_off without currency
del data["currency"]
self.assertFalse(StripeCouponForm(data=data).is_valid())
data["currency"] = "usd"

# test passing duration repeating with empty duration_in_months
data["duration"] = StripeCoupon.DURATION_REPEATING
self.assertFalse(StripeCouponForm(data=data).is_valid())
Expand Down Expand Up @@ -187,7 +203,7 @@ def test_admin_form(self):

def test_details_api(self):
# test accessing without authentication
url = reverse("stripe-coupon-details", kwargs={"coupon_id": "FAKE"})
url = reverse("stripe-coupon-details", kwargs={"coupon_id": "FAKE-COUPON"})
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, 403)

Expand Down Expand Up @@ -229,7 +245,7 @@ def test_refresh_coupons_command(self):
coupon_1a_new_response["metadata"] = {"new": "data"}
coupon_4a_new_response = coupons["4A"].stripe_response.copy()
coupon_4a_new_response["created"] += 1
coupon_4a_new_response["amount_off"] = 99
coupon_4a_new_response["amount_off"] = 9999
# fake limit
stripe_response_part1 = {
"object": "list",
Expand Down Expand Up @@ -272,5 +288,5 @@ def test_refresh_coupons_command(self):
self.assertTrue(coupons["4A"].is_deleted)
new_4a_coupon = StripeCoupon.objects.get(coupon_id="4A", is_deleted=False)
self.assertNotEqual(new_4a_coupon.pk, coupons["4A"].pk)
self.assertEqual(new_4a_coupon.amount_off, coupon_4a_new_response["amount_off"])
self.assertEqual(new_4a_coupon.amount_off, Decimal(coupon_4a_new_response["amount_off"]) / 100)
self.assertTrue(StripeCoupon.objects.filter(coupon_id="1B", is_deleted=False).exists)
2 changes: 1 addition & 1 deletion tests/test_utils.py
Expand Up @@ -17,7 +17,7 @@ def _create_coupon(self, coupon_id, amount_off=None, duration=StripeCoupon.DURAT
stripe_response = {
"id": coupon_id,
"object": "coupon",
"amount_off": amount_off,
"amount_off": int(amount_off * 100) if amount_off else None,
"created": int(time.mktime(datetime.now().timetuple())),
"currency": "usd",
"duration": duration,
Expand Down

0 comments on commit 672eb09

Please sign in to comment.