From acfdfa2a03ef4e46f3428de2785e0328d99da4e1 Mon Sep 17 00:00:00 2001 From: Michal Proszek Date: Tue, 12 Dec 2017 11:55:36 +0100 Subject: [PATCH] [BE] add StripeCard model --- aa_stripe/admin.py | 10 ++- aa_stripe/migrations/0018_stripecard.py | 57 +++++++++++++++++ aa_stripe/models.py | 79 +++++++++++++++++------ aa_stripe/utils.py | 29 +++++++++ tests/test_cards.py | 83 +++++++++++++++++++++++++ tests/test_charge.py | 21 +++++-- tests/test_utils.py | 42 ++++++++++++- 7 files changed, 293 insertions(+), 28 deletions(-) create mode 100644 aa_stripe/migrations/0018_stripecard.py create mode 100644 tests/test_cards.py diff --git a/aa_stripe/admin.py b/aa_stripe/admin.py index 8835045..505d4fc 100644 --- a/aa_stripe/admin.py +++ b/aa_stripe/admin.py @@ -2,8 +2,8 @@ from django.contrib import admin from aa_stripe.forms import StripeCouponForm -from aa_stripe.models import (StripeCharge, StripeCoupon, StripeCustomer, StripeSubscription, StripeSubscriptionPlan, - StripeWebhook) +from aa_stripe.models import (StripeCard, StripeCharge, StripeCoupon, StripeCustomer, StripeSubscription, + StripeSubscriptionPlan, StripeWebhook) class ReadOnlyBase(object): @@ -38,6 +38,11 @@ class ReadOnly(ReadOnlyBase, admin.ModelAdmin): editable_fields = [] +class StripeCardAdmin(ReadOnly): + list_display = ("stripe_card_id", "customer") + list_filter = ("stripe_card_id",) + + class StripeCustomerAdmin(ReadOnly): list_display = ("id", "user", "created", "is_active") ordering = ("-created",) @@ -84,6 +89,7 @@ class StripeWebhookAdmin(ReadOnly): ordering = ("-created",) +admin.site.register(StripeCard, StripeCardAdmin) admin.site.register(StripeCustomer, StripeCustomerAdmin) admin.site.register(StripeCharge, StripeChargeAdmin) admin.site.register(StripeCoupon, StripeCouponAdmin) diff --git a/aa_stripe/migrations/0018_stripecard.py b/aa_stripe/migrations/0018_stripecard.py new file mode 100644 index 0000000..aba78b2 --- /dev/null +++ b/aa_stripe/migrations/0018_stripecard.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-12-11 15:44 +from __future__ import unicode_literals + +import django.core.validators +import django.db.models.deletion +import jsonfield.fields +from django.db import migrations, models + + +def migrate_old_cards(apps, schema_editor): + StripeCard = apps.get_model("aa_stripe", "StripeCard") + StripeCustomer = apps.get_model("aa_stripe", "StripeCustomer") + for customer in StripeCustomer.objects.exclude(stripe_js_response=""): + try: + card_data = customer.stripe_js_response["card"] + card = StripeCard.objects.create(stripe_card_id=card_data["id"], last4=card_data["last4"], + exp_month=card_data["exp_month"], exp_year=card_data["exp_year"], + stripe_response=customer.stripe_js_response, customer=customer) + customer.default_card = card + customer.save() + except KeyError as e: + print("Cannot migrate customer card, key error: {}".format(e)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('aa_stripe', '0017_stripecharge_manual_charge'), + ] + + operations = [ + migrations.CreateModel( + name='StripeCard', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('stripe_response', jsonfield.fields.JSONField(default=dict)), + ('stripe_card_id', models.CharField(help_text='Unique card id in Stripe', db_index=True, max_length=255)), + ('last4', models.CharField(help_text='Last 4 digits of the card', max_length=4)), + ('exp_month', models.PositiveIntegerField(help_text='Two digit number representing the card’s expiration month', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(12)])), + ('exp_year', models.PositiveIntegerField(help_text='Four digit number representing the card’s expiration year', validators=[django.core.validators.MinValueValidator(1900)])), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='aa_stripe.StripeCustomer')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='stripecustomer', + name='default_card', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='aa_stripe.StripeCard'), + ), + migrations.RunPython(migrate_old_cards) + ] diff --git a/aa_stripe/models.py b/aa_stripe/models.py index 67dd97a..cd23914 100644 --- a/aa_stripe/models.py +++ b/aa_stripe/models.py @@ -12,9 +12,11 @@ from django.conf import settings from django.contrib.contenttypes import fields as generic from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils import dateformat, timezone +from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from jsonfield import JSONField @@ -22,7 +24,7 @@ StripeWebhookParseError) from aa_stripe.settings import stripe_settings from aa_stripe.signals import stripe_charge_card_exception, stripe_charge_succeeded -from aa_stripe.utils import timestamp_to_timezone_aware_date +from aa_stripe.utils import SafeDeleteManager, SafeDeleteModel, timestamp_to_timezone_aware_date USER_MODEL = getattr(settings, "STRIPE_USER_MODEL", settings.AUTH_USER_MODEL) @@ -39,13 +41,18 @@ class Meta: abstract = True +@python_2_unicode_compatible class StripeCustomer(StripeBasicModel): user = models.ForeignKey(USER_MODEL, on_delete=models.CASCADE, related_name='stripe_customers') stripe_js_response = JSONField() stripe_customer_id = models.CharField(max_length=255, db_index=True) + default_card = models.ForeignKey("StripeCard", null=True, blank=True, on_delete=models.PROTECT) is_active = models.BooleanField(default=True) is_created_at_stripe = models.BooleanField(default=False) + def __str__(self): + return self.user.email + def create_at_stripe(self): if self.is_created_at_stripe: raise StripeMethodNotAllowed() @@ -71,29 +78,59 @@ 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 +@python_2_unicode_compatible +class StripeCard(SafeDeleteModel, StripeBasicModel): + customer = models.ForeignKey("StripeCustomer", on_delete=models.CASCADE) + stripe_card_id = models.CharField(max_length=255, db_index=True, help_text=_("Unique card id in Stripe")) + last4 = models.CharField(max_length=4, help_text=_("Last 4 digits of the card")) + exp_month = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(12)], + help_text=_("Two digit number representing the card’s expiration month")) + exp_year = models.PositiveIntegerField(validators=[MinValueValidator(1900)], + help_text=_("Four digit number representing the card’s expiration year")) - return deleted_counter, {self.model._meta.label: deleted_counter} + objects = SafeDeleteManager() + + def __str__(self): + return self.stripe_card_id + def _retrieve_from_stripe(self, set_deleted=False): + """ + Retrieve card from Stripe + Will set StripeCard.is_deleted to True if card could not be fetched and the set_deleted parameter is True, + although StripeCard.save() method will not be executed. + """ + stripe.api_key = stripe_settings.API_KEY + try: + customer = stripe.Customer.retrieve(self.customer.stripe_customer_id) + return customer.sources.retrieve(self.stripe_card_id) + except stripe.error.InvalidRequestError: # means that the card is not available - does not exist + if set_deleted: + self.is_deleted = True -class StripeCouponManager(models.Manager): - def all_with_deleted(self): - return StripeCouponQuerySet(self.model, using=self._db) + def save(self, *args, **kwargs): + if self.pk: + card = self._retrieve_from_stripe(set_deleted=True) + if card: + card.exp_month = self.exp_month + card.exp_year = self.exp_year + card.save() + super(StripeCard, self).save(*args, **kwargs) + if not self.customer.default_card: + self.customer.default_card = self + self.customer.save() - def deleted(self): - return self.all_with_deleted().filter(is_deleted=True) + def delete(self, *args, **kwargs): + card = self._retrieve_from_stripe(set_deleted=True) + if card: + card.delete() + self.is_deleted = True - def get_queryset(self): - return self.all_with_deleted().filter(is_deleted=False) + self.save() + return 0, {self._meta.label: 0} -class StripeCoupon(StripeBasicModel): +@python_2_unicode_compatible +class StripeCoupon(SafeDeleteModel, StripeBasicModel): # fields that are fetched from Stripe API STRIPE_FIELDS = { "amount_off", "currency", "duration", "duration_in_months", "livemode", "max_redemptions", @@ -167,10 +204,9 @@ class StripeCoupon(StripeBasicModel): default=False, help_text=_("Taking account of the above properties, whether this coupon can still be applied to a customer.")) created = models.DateTimeField() - is_deleted = models.BooleanField(default=False) is_created_at_stripe = models.BooleanField(default=False) - objects = StripeCouponManager() + objects = SafeDeleteManager() def __init__(self, *args, **kwargs): super(StripeCoupon, self).__init__(*args, **kwargs) @@ -296,6 +332,9 @@ def charge(self): if self.is_charged: raise StripeMethodNotAllowed("Already charged.") + if not self.customer.default_card: + raise ValidationError(_("Customer must have a default_card set to create charge at Stripe")) + stripe.api_key = stripe_settings.API_KEY customer = StripeCustomer.get_latest_active_customer_for_user(self.user) self.customer = customer @@ -304,7 +343,7 @@ def charge(self): stripe_charge = stripe.Charge.create( amount=self.amount, currency="usd", - customer=customer.stripe_customer_id, + source=customer.default_card.stripe_card_id, description=self.description ) except stripe.error.CardError as e: diff --git a/aa_stripe/utils.py b/aa_stripe/utils.py index ed198a3..f011a64 100644 --- a/aa_stripe/utils.py +++ b/aa_stripe/utils.py @@ -1,7 +1,36 @@ from datetime import datetime +from django.db import models from django.utils import timezone def timestamp_to_timezone_aware_date(timestamp): return timezone.make_aware(datetime.fromtimestamp(timestamp)) + + +class SafeDeleteModel(models.Model): + is_deleted = models.BooleanField(default=False) + + class Meta: + abstract = True + + +class SafeDeleteQuerySet(models.query.QuerySet): + def delete(self): + deleted_counter = 0 + for obj in self: + obj.delete() + deleted_counter = deleted_counter + 1 + + return deleted_counter, {self.model._meta.label: deleted_counter} + + +class SafeDeleteManager(models.Manager): + def all_with_deleted(self): + return SafeDeleteQuerySet(self.model, using=self._db) + + def deleted(self): + return self.all_with_deleted().filter(is_deleted=True) + + def get_queryset(self): + return self.all_with_deleted().filter(is_deleted=False) diff --git a/tests/test_cards.py b/tests/test_cards.py new file mode 100644 index 0000000..686432f --- /dev/null +++ b/tests/test_cards.py @@ -0,0 +1,83 @@ +import requests_mock +import simplejson as json +from tests.test_utils import BaseTestCase + +from aa_stripe.models import StripeCard + + +class TestCards(BaseTestCase): + def _setup_customer_api_mock(self, m): + stripe_customer_response = { + "id": "cus_xyz", + "object": "customer", + "created": 1513013595, + "currency": "usd", + "default_source": None, + "metadata": { + }, + "sources": { + "object": "list", + "data": [ + {"exp_month": 8, "exp_year": 2018, "last4": "4242"} + ], + "has_more": False, + "total_count": 0, + "url": "/v1/customers/cus_xyz/sources" + } + } + m.register_uri("GET", "https://api.stripe.com/v1/customers/cus_xyz", status_code=200, + text=json.dumps(stripe_customer_response)) + + def setUp(self): + self._create_user() + self._create_customer("cus_xyz") + + def test_save(self): + # test creating from JS response, Stripe API should not be called + self.assertIsNone(self.customer.default_card) + card = self._create_card() + self.customer.refresh_from_db() + self.assertEqual(self.customer.default_card, card) + + # test creating another card from JS response + # default_card for the customer is already set and should not be updated + card = self._create_card() + self.customer.refresh_from_db() + self.assertNotEqual(self.customer.default_card, card) + + @requests_mock.Mocker() + def test_update(self, m): + card = self._create_card(stripe_card_id="card_xyz") + # test updating a card that no longer exists at Stripe + stripe_card_response = {"id": "card_xyz", "object": "card", "customer": "cus_xyz"} + self._setup_customer_api_mock(m) + m.register_uri( + "GET", "https://api.stripe.com/v1/customers/cus_xyz/sources/card_xyz", [ + {"status_code": 404, "text": json.dumps({"error": {"type": "invalid_request_error"}})}, + {"status_code": 200, "text": json.dumps(stripe_card_response)} + ]) + m.register_uri( + "POST", "https://api.stripe.com/v1/customers/cus_xyz/sources/card_xyz", status_code=200, + text=json.dumps(stripe_card_response) + ) + card.exp_year = 2020 + card.save() + deleted_qs = StripeCard.objects.deleted() + self.assertTrue(deleted_qs.filter(pk=card.pk).exists()) + + # test correct update (card exists) + card = self._create_card(stripe_card_id="card_xyz") + card.exp_year = 2017 + card.save() + self.assertFalse(deleted_qs.filter(pk=card.pk).exists()) + + @requests_mock.Mocker() + def test_delete(self, m): + # try deleting card that does not exist at Stripe API - should not call Stripe (DELETE) + self._setup_customer_api_mock(m) + m.register_uri( + "GET", "https://api.stripe.com/v1/customers/cus_xyz/sources/card_xyz", status_code=404, + text=json.dumps({"error": {"type": "invalid_request_error"}})) + self._create_card(stripe_card_id="card_xyz") + StripeCard.objects.filter(pk=self.card.pk).update(is_deleted=True) + StripeCard.objects.all_with_deleted().get(pk=self.card.pk).delete() diff --git a/tests/test_charge.py b/tests/test_charge.py index ec3a100..195c664 100644 --- a/tests/test_charge.py +++ b/tests/test_charge.py @@ -4,20 +4,21 @@ import mock import stripe from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.core.management import call_command -from django.test import TestCase from django.utils.six import StringIO from stripe.error import CardError, StripeError +from tests.test_utils import BaseTestCase -from aa_stripe.models import StripeCharge, StripeCustomer, StripeMethodNotAllowed +from aa_stripe.models import StripeCard, StripeCharge, StripeCustomer, StripeMethodNotAllowed from aa_stripe.signals import stripe_charge_card_exception, stripe_charge_succeeded UserModel = get_user_model() -class TestCharges(TestCase): +class TestCharges(BaseTestCase): def setUp(self): - self.user = UserModel.objects.create(email="foo@bar.bar", username="foo", password="dump-password") + self._create_user() @mock.patch("aa_stripe.management.commands.charge_stripe.stripe.Charge.create") def test_charges(self, charge_create_mocked): @@ -49,6 +50,7 @@ def exception_handler(sender, instance, **kwargs): user=self.user, stripe_customer_id=data["customer_id"], stripe_js_response="foo") self.assertTrue(customer, StripeCustomer.get_latest_active_customer_for_user(self.user)) + self._create_card(customer) charge = StripeCharge.objects.create(user=self.user, amount=data["amount"], customer=customer, description=data["description"]) manual_charge = StripeCharge.objects.create(user=self.user, amount=data["amount"], customer=customer, @@ -89,7 +91,7 @@ def exception_handler(sender, instance, **kwargs): self.assertFalse(manual_charge.is_charged) self.assertEqual(charge.stripe_response["id"], "AA1") charge_create_mocked.assert_called_with(amount=charge.amount, currency=data["currency"], - customer=data["customer_id"], description=data["description"]) + source=self.card.stripe_card_id, description=data["description"]) # manual call manual_charge.charge() @@ -108,6 +110,7 @@ def test_refund(self, refund_create_mocked): customer = StripeCustomer.objects.create( user=self.user, stripe_customer_id=data["customer_id"], stripe_js_response="foo") self.assertTrue(customer, StripeCustomer.get_latest_active_customer_for_user(self.user)) + self._create_card(customer) charge = StripeCharge.objects.create(user=self.user, amount=data["amount"], customer=customer, description=data["description"]) @@ -132,3 +135,11 @@ def test_refund(self, refund_create_mocked): with self.assertRaises(StripeMethodNotAllowed): charge.refund() self.assertTrue(charge.is_refunded) + + def test_no_default_card(self): + # make sure charging with no default card set is disallowed + self._create_customer() + self.assertEqual(StripeCard.objects.count(), 0) + charge = StripeCharge.objects.create(user=self.user, amount=10, customer=self.customer, description="ABC") + with self.assertRaises(ValidationError): + charge.charge() diff --git a/tests/test_utils.py b/tests/test_utils.py index 9d6c5b3..da24646 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,16 +1,27 @@ import time from datetime import datetime +from uuid import uuid4 import requests_mock import simplejson as json +from django.contrib.auth import get_user_model from rest_framework.test import APITestCase from stripe.webhook import WebhookSignature -from aa_stripe.models import StripeCoupon +from aa_stripe.models import StripeCard, StripeCoupon, StripeCustomer from aa_stripe.settings import stripe_settings +UserModel = get_user_model() + class BaseTestCase(APITestCase): + def _create_user(self, i=1, set_self=True): + user = UserModel.objects.create(email="foo{}@bar.bar".format(i), username="foo{}".format(i), + password="dump-password") + if set_self: + self.user = user + return user + def _create_coupon(self, coupon_id, amount_off=None, duration=StripeCoupon.DURATION_FOREVER, metadata=None): with requests_mock.Mocker() as m: # create a simple coupon which will be used for tests @@ -37,6 +48,35 @@ def _create_coupon(self, coupon_id, amount_off=None, duration=StripeCoupon.DURAT amount_off=amount_off ) + def _create_customer(self, customer_id=None, set_self=True): + customer_id = customer_id or "cus_{}".format(uuid4().hex) + stripe_response = { + "id": customer_id, "object": "customer", "account_balance": 0, "created": 1512126654, "currency": None, + "default_source": "card_1BUCsadPxLoWm2fwZbz", "delinquent": False, + "description": "foo@bar.bar id: 1", "discount": None, "email": None, "livemode": False, "metadata": {}, + "shipping": None, "sources": {"object": "list", "data": [ + {"id": "card_1BAXCPxLoWm2f6pRwe9pGwZbz", "object": "card", "address_city": None, + "address_country": None, "address_line1": None, "address_line1_check": None, "address_line2": None, + "address_state": None, "address_zip": None, "address_zip_check": None, "brand": "Visa", + "country": "US", "customer": customer_id, "cvc_check": "pass", "dynamic_last4": None, "exp_month": 9, + "exp_year": 2025, "fingerprint": "DmBIQwsaiNOChP", "funding": "credit", "last4": "4242", + "metadata": {}, "name": None, "tokenization_method": None + }], "has_more": False, "total_count": 1, "url": "/v1/customers/cus_BrwISa2lfUVaoa/sources" + }, "subscriptions": {"object": "list", "data": [], "has_more": False, "total_count": 0, + "url": "/v1/customers/{}/subscriptions".format(customer_id)}} + customer = StripeCustomer.objects.create( + stripe_response=stripe_response, user=self.user, stripe_customer_id=customer_id, is_created_at_stripe=True) + if set_self: + self.customer = customer + return customer + + def _create_card(self, customer=None, stripe_card_id="", set_self=True): + card = StripeCard.objects.create(customer=customer or self.customer, last4=4242, exp_month=1, exp_year=2025, + stripe_card_id=stripe_card_id or "card_{}".format(uuid4().hex)) + if set_self: + self.card = card + return card + def _get_signature_headers(self, payload): timestamp = int(time.time())