Skip to content

Commit

Permalink
[BE] add StripeCard model
Browse files Browse the repository at this point in the history
  • Loading branch information
poxip committed Dec 12, 2017
1 parent afa8bb2 commit acfdfa2
Show file tree
Hide file tree
Showing 7 changed files with 293 additions and 28 deletions.
10 changes: 8 additions & 2 deletions aa_stripe/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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",)
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions aa_stripe/migrations/0018_stripecard.py
Original file line number Diff line number Diff line change
@@ -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)
]
79 changes: 59 additions & 20 deletions aa_stripe/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@
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

from aa_stripe.exceptions import (StripeCouponAlreadyExists, StripeMethodNotAllowed, StripeWebhookAlreadyParsed,
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)

Expand All @@ -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()
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions aa_stripe/utils.py
Original file line number Diff line number Diff line change
@@ -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)
83 changes: 83 additions & 0 deletions tests/test_cards.py
Original file line number Diff line number Diff line change
@@ -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()
Loading

0 comments on commit acfdfa2

Please sign in to comment.