Skip to content

Commit

Permalink
Merge branch 'feature/coupons' into feature/validation
Browse files Browse the repository at this point in the history
  • Loading branch information
poxip committed Sep 1, 2017
2 parents f6bad36 + 97b2839 commit bc1e331
Show file tree
Hide file tree
Showing 8 changed files with 80 additions and 15 deletions.
56 changes: 56 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
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 @@ -128,6 +157,33 @@ Stripe has the weird tendency to stop sending webhooks, and they have not fixed
In case there is more pending webhooks than specified in the ``PENDING_EVENTS_THRESHOLD`` variable in settings (default: ``20``), an email to project admins will be sent with ids of the pending events, and also the command will fail raising an exception,
so if you have some kind of error tracking service configured on your servers (for example: `Sentry <https://sentry.io>`_), you will be notified.

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
2 changes: 1 addition & 1 deletion aa_stripe/api_urls.py
Original file line number Diff line number Diff line change
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
2 changes: 1 addition & 1 deletion aa_stripe/management/commands/refresh_coupons.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,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
Original file line number Diff line number Diff line change
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, 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
15 changes: 10 additions & 5 deletions aa_stripe/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from decimal import Decimal
from time import sleep

import simplejson as json
Expand Down Expand Up @@ -105,9 +106,10 @@ 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,
help_text=_("If amount_off has been set, the three-letter ISO code for the currency of the amount to take "
Expand Down Expand Up @@ -154,9 +156,12 @@ def update_from_stripe_data(self, stripe_coupon, exclude_fields=None):
"""
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)
Expand Down Expand Up @@ -195,7 +200,7 @@ def save(self, force_retrieve=False, *args, **kwargs):
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
9 changes: 5 additions & 4 deletions tests/test_coupons.py
Original file line number Diff line number Diff line change
@@ -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 @@ -102,7 +103,7 @@ def test_delete(self):
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 Down Expand Up @@ -187,7 +188,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 +230,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 +273,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
Original file line number Diff line number Diff line change
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
4 changes: 2 additions & 2 deletions tests/test_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ def test_coupon_create(self):
self.assertEqual(coupon.coupon_id, "nicecoupon")

def test_coupon_update(self):
coupon = self._create_coupon("nicecoupon", amount_off=10000, duration=StripeCoupon.DURATION_ONCE,
coupon = self._create_coupon("nicecoupon", amount_off=100, duration=StripeCoupon.DURATION_ONCE,
metadata={"nie": "tak", "lol1": "rotfl"})
payload = json.loads("""{
"id": "evt_1AtuTOLoWm2f6pRw6dYfQzWh",
Expand Down Expand Up @@ -261,7 +261,7 @@ def test_coupon_update(self):
})

def test_coupon_delete(self):
coupon = self._create_coupon("nicecoupon", amount_off=10000, duration=StripeCoupon.DURATION_ONCE)
coupon = self._create_coupon("nicecoupon", amount_off=100, duration=StripeCoupon.DURATION_ONCE)
self.assertFalse(coupon.is_deleted)
payload = json.loads("""{
"id": "evt_1Atthtasdsaf6pRwkdLOSKls",
Expand Down

0 comments on commit bc1e331

Please sign in to comment.