Skip to content

Commit

Permalink
RO-1093 - [BE] add management command to refresh coupons
Browse files Browse the repository at this point in the history
  • Loading branch information
poxip committed Aug 24, 2017
1 parent 37d5933 commit ccd2e7f
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 34 deletions.
47 changes: 47 additions & 0 deletions aa_stripe/management/commands/refresh_coupons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
import stripe
from django.conf import settings
from django.core.management.base import BaseCommand

from aa_stripe.models import StripeCoupon
from aa_stripe.utils import timestamp_to_timezone_aware_date


class Command(BaseCommand):
help = "Update the coupon list from Stripe API"

def handle(self, *args, **options):
stripe.api_key = settings.STRIPE_API_KEY

counts = {
"created": 0,
"updated": 0,
"deleted": 0
}
active_coupons_ids = []
last_stripe_coupon = None
while True:
stripe_coupon_list = stripe.Coupon.list(starting_after=last_stripe_coupon)
for stripe_coupon in stripe_coupon_list["data"]:
try:
coupon = StripeCoupon.objects.get(
coupon_id=stripe_coupon.id, created=timestamp_to_timezone_aware_date(stripe_coupon["created"]),
is_deleted=False)
counts["updated"] += coupon.update_from_stripe_data(stripe_coupon)
except StripeCoupon.DoesNotExist:
coupon = StripeCoupon(coupon_id=stripe_coupon.id)
coupon.save(force_retrieve=True)
counts["created"] += 1

# indicate which coupons should have is_deleted=False
active_coupons_ids.append(coupon.pk)

if not stripe_coupon_list["has_more"]:
break
else:
last_stripe_coupon = stripe_coupon_list["data"][-1]

# update can be used here, because those coupons does not exist in the Stripe API anymore
counts["deleted"] = StripeCoupon.objects.filter(is_deleted=False).exclude(
pk__in=active_coupons_ids).update(is_deleted=True)
print("Coupons created: {created}, updated: {updated}, deleted {deleted}".format(**counts))
2 changes: 1 addition & 1 deletion aa_stripe/migrations/0010_auto_20170822_1004.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class Migration(migrations.Migration):
('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)),
('currency', models.CharField(default='usd', help_text='If amount_off has been set, the three-letter ISO code for thecurrency of the amount to take off.', max_length=3)),
('currency', models.CharField(default='usd', blank=True, null=True, help_text='If amount_off has been set, the three-letter ISO code for thecurrency of the amount to take off.', max_length=3)),
('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)),
('livemode', models.BooleanField(default=False, help_text='Flag indicating whether the object exists in live mode or test mode.')),
Expand Down
80 changes: 48 additions & 32 deletions aa_stripe/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from time import sleep

import simplejson as json
import six
import stripe
from django.conf import settings
from django.contrib.contenttypes import fields as generic
Expand All @@ -14,6 +14,7 @@
from jsonfield import JSONField

from aa_stripe.exceptions import StripeMethodNotAllowed, StripeWebhookAlreadyParsed
from aa_stripe.utils import timestamp_to_timezone_aware_date

USER_MODEL = getattr(settings, "STRIPE_USER_MODEL", settings.AUTH_USER_MODEL)

Expand Down Expand Up @@ -60,6 +61,12 @@ class Meta:


class StripeCoupon(StripeBasicModel):
# fields that are fetched from Stripe API
STRIPE_FIELDS = {
"amount_off", "currency", "duration", "duration_in_months", "livemode", "max_redemptions",
"percent_off", "redeem_by", "times_redeemed", "valid", "metadata", "created"
}

DURATION_FOREVER = "forever"
DURATION_ONCE = "once"
DURATION_REPEATING = "repeating"
Expand All @@ -69,35 +76,36 @@ class StripeCoupon(StripeBasicModel):
(DURATION_REPEATING, DURATION_REPEATING)
)

# choices must be lowercase, because that is how Stripe API returns currency
CURRENCY_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")
('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')
)

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."))
currency = models.CharField(
max_length=3, default="USD", choices=CURRENCY_CHOICES,
max_length=3, default="USD", 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 @@ -135,6 +143,22 @@ def __init__(self, *args, **kwargs):
def __str__(self):
return self.coupon_id

def update_from_stripe_data(self, stripe_coupon, exclude_fields=None):
"""
Update StripeCoupon object with data from stripe.Coupon without calling stripe.Coupon.retrieve.
Returns the number of rows altered.
"""
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:
update_data["created"] = timestamp_to_timezone_aware_date(update_data["created"])

# 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)

def save(self, force_retrieve=False, *args, **kwargs):
"""
Use the force_retrieve parameter to create a new StripeCoupon object from an existing coupon created at Stripe
Expand All @@ -159,16 +183,8 @@ def save(self, force_retrieve=False, *args, **kwargs):
coupon.save()

# update all fields in the local object in case someone tried to change them
self.update_from_stripe_data(coupon, exclude_fields=["metadata"] if not force_retrieve else [])
self.stripe_response = coupon
fields_to_update = [
"amount_off", "currency", "duration", "duration_in_months", "livemode", "max_redemptions",
"percent_off", "redeem_by", "times_redeemed", "valid"
]
if force_retrieve:
fields_to_update.append("metadata")

for field in fields_to_update:
setattr(self, field, getattr(coupon, field))
except stripe.error.InvalidRequestError:
self.is_deleted = True
else:
Expand All @@ -187,7 +203,7 @@ def save(self, force_retrieve=False, *args, **kwargs):
if not self.coupon_id:
self.coupon_id = self.stripe_response["id"]

self.created = timezone.make_aware(datetime.fromtimestamp(self.stripe_response["created"]))
self.created = timestamp_to_timezone_aware_date(self.stripe_response["created"])
# for future
self.is_created_at_stripe = True
return super(StripeCoupon, self).save(*args, **kwargs)
Expand Down
7 changes: 7 additions & 0 deletions aa_stripe/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from datetime import datetime

from django.utils import timezone


def timestamp_to_timezone_aware_date(timestamp):
return timezone.make_aware(datetime.fromtimestamp(timestamp))
1 change: 1 addition & 0 deletions requirements/requirements-base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ django-jsonfield>=1.0.1
stripe>=1.49.0
djangorestframework>=3.6.0
simplejson>=3.10.0
six>=1.10.0
55 changes: 54 additions & 1 deletion tests/test_coupons.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import requests_mock
import simplejson as json
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.utils import timezone
from freezegun import freeze_time
from rest_framework.reverse import reverse
Expand Down Expand Up @@ -126,7 +127,7 @@ def test_admin_form(self):
data = {
"coupon_id": "25OFF",
"amount_off": 1,
"currency": "USD",
"currency": "usd",
"duration": StripeCoupon.DURATION_ONCE,
"metadata": {},
"times_redeemed": 0,
Expand Down Expand Up @@ -212,3 +213,55 @@ def test_details_api(self):
StripeCoupon.objects.filter(pk=coupon.pk).update(is_deleted=True)
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, 404)

def test_refresh_coupons_command(self):
# check number
coupons = {
"1A": self._create_coupon("1A", amount_off=100),
"2A": self._create_coupon("2A"),
"3A": self._create_coupon("3A")
}
self.assertEqual(StripeCoupon.objects.count(), 3)
# fake deleted coupon, this coupon should be recreated in the database
StripeCoupon.objects.filter(pk=coupons["3A"].pk).update(is_deleted=True)

coupon_1a_new_response = coupons["1A"].stripe_response.copy()
coupon_1a_new_response["metadata"] = {"new": "data"}
# fake limit
stripe_response_part1 = {
"object": "list",
"url": "/v1/coupons",
"has_more": True,
"data": [
coupon_1a_new_response, # 1A will be updated, # 2A will be deleted
coupons["3A"].stripe_response, # 3A will be recreated
]
}
new_coupon_stripe_response = coupons["1A"].stripe_response.copy()
new_coupon_stripe_response["id"] = "1B"
stripe_response_part2 = stripe_response_part1.copy()
stripe_response_part2.update({
"has_more": False,
"data": [new_coupon_stripe_response]
})
with requests_mock.Mocker() as m:
m.register_uri("GET", "https://api.stripe.com/v1/coupons", text=json.dumps(stripe_response_part1))
m.register_uri("GET", "https://api.stripe.com/v1/coupons?starting_after=3A",
text=json.dumps(stripe_response_part2))
# 3A will be recreated
m.register_uri("GET", "https://api.stripe.com/v1/coupons/3A",
text=json.dumps(coupons["3A"].stripe_response))
# 1B will be created
m.register_uri("GET", "https://api.stripe.com/v1/coupons/1B",
text=json.dumps(coupons["3A"].stripe_response))

call_command("refresh_coupons")
self.assertEqual(StripeCoupon.objects.count(), 5) # 3 + 2 were created
for coupon in coupons.values():
coupon.refresh_from_db()

self.assertEqual(coupons["1A"].metadata, coupon_1a_new_response["metadata"])
self.assertTrue(coupons["2A"].is_deleted)
new_3a_coupon = StripeCoupon.objects.get(coupon_id="3A", is_deleted=False)
self.assertNotEqual(new_3a_coupon.pk, coupons["3A"].pk)
self.assertTrue(StripeCoupon.objects.filter(coupon_id="1B", is_deleted=False).exists)

0 comments on commit ccd2e7f

Please sign in to comment.