From 8668ee5a7af3e05221afb6fae9b676406d11ee12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=9Awiercz?= Date: Fri, 20 Sep 2019 12:26:16 +0200 Subject: [PATCH] Partial refunds and webhook for disputes (#67) * Partial refunds * Add webhook handler for disputes --- CHANGELOG.txt | 5 + aa_stripe/__init__.py | 2 +- .../0022_stripecharge_amount_refunded.py | 18 ++ aa_stripe/models.py | 216 ++++++++++++------ tests/test_charge.py | 160 +++++++++---- tests/test_webhooks.py | 198 +++++++++------- 6 files changed, 413 insertions(+), 186 deletions(-) create mode 100644 aa_stripe/migrations/0022_stripecharge_amount_refunded.py diff --git a/CHANGELOG.txt b/CHANGELOG.txt index c39f463..a1c1074 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,11 @@ # Change Log All notable changes to this project will be documented in this file. +## [0.9.2] +### Added +- partial refunds +- disputes webhook handler + ## [0.9.1] ### Fixed - fixed migrations diff --git a/aa_stripe/__init__.py b/aa_stripe/__init__.py index a8e2c0c..5a42336 100644 --- a/aa_stripe/__init__.py +++ b/aa_stripe/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- __title__ = "Ro Stripe" -__version__ = "0.9.1" +__version__ = "0.9.2" __author__ = "Remigiusz Dymecki" __license__ = "MIT" __copyright__ = "Copyright 2019 Ro" diff --git a/aa_stripe/migrations/0022_stripecharge_amount_refunded.py b/aa_stripe/migrations/0022_stripecharge_amount_refunded.py new file mode 100644 index 0000000..e90b1f7 --- /dev/null +++ b/aa_stripe/migrations/0022_stripecharge_amount_refunded.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-09-17 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('aa_stripe', '0021_auto_20190906_1623'), + ] + + operations = [ + migrations.AddField( + model_name='stripecharge', + name='amount_refunded', + field=models.IntegerField(default=0, help_text='in cents', null=True), + ), + ] diff --git a/aa_stripe/models.py b/aa_stripe/models.py index b536bc8..38094b2 100644 --- a/aa_stripe/models.py +++ b/aa_stripe/models.py @@ -43,7 +43,7 @@ class Meta: class StripeCustomer(StripeBasicModel): - user = models.ForeignKey(USER_MODEL, on_delete=models.CASCADE, related_name='stripe_customers') + user = models.ForeignKey(USER_MODEL, on_delete=models.CASCADE, related_name="stripe_customers") stripe_js_response = JSONField(blank=True) stripe_customer_id = models.CharField(max_length=255, db_index=True) is_active = models.BooleanField(default=True) @@ -63,10 +63,7 @@ def create_at_stripe(self, description=None): description = "{user} id: {user.id}".format(user=self.user) stripe.api_key = stripe_settings.API_KEY - customer = stripe.Customer.create( - source=self.stripe_js_response["id"], - description=description - ) + customer = stripe.Customer.create(source=self.stripe_js_response["id"], description=description) self.stripe_customer_id = customer["id"] self.stripe_response = customer self.sources = customer.sources.data @@ -152,8 +149,18 @@ def get_queryset(self): 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" + "amount_off", + "currency", + "duration", + "duration_in_months", + "livemode", + "max_redemptions", + "percent_off", + "redeem_by", + "times_redeemed", + "valid", + "metadata", + "created", } DURATION_FOREVER = "forever" @@ -162,10 +169,11 @@ class StripeCoupon(StripeBasicModel): DURATION_CHOICES = ( (DURATION_FOREVER, DURATION_FOREVER), (DURATION_ONCE, DURATION_ONCE), - (DURATION_REPEATING, DURATION_REPEATING) + (DURATION_REPEATING, DURATION_REPEATING), ) # choices must be lowercase, because that is how Stripe API returns currency + # fmt: off 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'), @@ -188,41 +196,74 @@ class StripeCoupon(StripeBasicModel): ('vnd', 'VND'), ('vuv', 'VUV'), ('wst', 'WST'), ('xaf', 'XAF'), ('xcd', 'XCD'), ('xof', 'XOF'), ('xpf', 'XPF'), ('yer', 'YER'), ('zar', 'ZAR'), ('zmw', 'ZMW') ) + # fmt: on coupon_id = models.CharField(max_length=255, help_text=_("Identifier for the coupon")) 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.")) + 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, 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.")) + 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( - max_length=255, choices=DURATION_CHOICES, - help_text=_("Describes how long a customer who applies this coupon will get the discount.")) + max_length=255, + choices=DURATION_CHOICES, + help_text=_("Describes how long a customer who applies this coupon will get the discount."), + ) duration_in_months = models.PositiveIntegerField( - blank=True, null=True, help_text=_("If duration is repeating, the number of months the coupon applies. " - "Null if coupon duration is forever or once.")) + blank=True, + null=True, + help_text=_( + "If duration is repeating, the number of months the coupon applies. " + "Null if coupon duration is forever or once." + ), + ) livemode = models.BooleanField( - default=False, help_text=_("Flag indicating whether the object exists in live mode or test mode.")) + default=False, help_text=_("Flag indicating whether the object exists in live mode or test mode.") + ) max_redemptions = models.PositiveIntegerField( - blank=True, null=True, - help_text=_("Maximum number of times this coupon can be redeemed, in total, before it is no longer valid.")) + blank=True, + null=True, + help_text=_("Maximum number of times this coupon can be redeemed, in total, before it is no longer valid."), + ) metadata = JSONField( - blank=True, help_text=_("Set of key/value pairs that you can attach to an object. It can be useful for " - "storing additional information about the object in a structured format.")) + blank=True, + help_text=_( + "Set of key/value pairs that you can attach to an object. It can be useful for " + "storing additional information about the object in a structured format." + ), + ) percent_off = models.PositiveIntegerField( - blank=True, null=True, - help_text=_("Percent that will be taken off the subtotal of any invoicesfor this customer for the duration of " - "the coupon. For example, a coupon with percent_off of 50 will make a $100 invoice $50 instead.")) + blank=True, + null=True, + help_text=_( + "Percent that will be taken off the subtotal of any invoicesfor this customer for the duration of " + "the coupon. For example, a coupon with percent_off of 50 will make a $100 invoice $50 instead." + ), + ) redeem_by = models.DateTimeField( - blank=True, null=True, help_text=_("Date after which the coupon can no longer be redeemed.")) + blank=True, null=True, help_text=_("Date after which the coupon can no longer be redeemed.") + ) times_redeemed = models.PositiveIntegerField( - default=0, help_text=_("Number of times this coupon has been applied to a customer.")) + default=0, help_text=_("Number of times this coupon has been applied to a customer.") + ) valid = models.BooleanField( default=False, - help_text=_("Taking account of the above properties, whether this coupon can still be applied to a customer.")) + 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) @@ -313,7 +354,7 @@ def save(self, force_retrieve=False, *args, **kwargs): max_redemptions=self.max_redemptions, metadata=self.metadata, percent_off=self.percent_off, - redeem_by=int(dateformat.format(self.redeem_by, "U")) if self.redeem_by else None + redeem_by=int(dateformat.format(self.redeem_by, "U")) if self.redeem_by else None, ) # stripe will generate coupon_id if none was specified in the request if not self.coupon_id: @@ -331,9 +372,10 @@ def delete(self, *args, **kwargs): class StripeCharge(StripeBasicModel): - user = models.ForeignKey(USER_MODEL, on_delete=models.CASCADE, related_name='stripe_charges') + user = models.ForeignKey(USER_MODEL, on_delete=models.CASCADE, related_name="stripe_charges") customer = models.ForeignKey(StripeCustomer, on_delete=models.SET_NULL, null=True) amount = models.IntegerField(null=True, help_text=_("in cents")) + amount_refunded = models.IntegerField(null=True, help_text=_("in cents"), default=0) is_charged = models.BooleanField(default=False) is_refunded = models.BooleanField(default=False) # if True, it will not be triggered through stripe_charge command @@ -345,7 +387,7 @@ class StripeCharge(StripeBasicModel): comment = models.CharField(max_length=255, help_text=_("Comment for internal information")) content_type = models.ForeignKey(ContentType, null=True, on_delete=models.CASCADE) object_id = models.PositiveIntegerField(null=True, db_index=True) - source = generic.GenericForeignKey('content_type', 'object_id') + source = generic.GenericForeignKey("content_type", "object_id") statement_descriptor = models.CharField(max_length=22, blank=True) def charge(self): @@ -358,17 +400,14 @@ def charge(self): customer = StripeCustomer.get_latest_active_customer_for_user(self.user) self.customer = customer if customer: - metadata = { - "object_id": self.object_id, - "content_type_id": self.content_type_id - } + metadata = {"object_id": self.object_id, "content_type_id": self.content_type_id} params = { "amount": self.amount, "currency": "usd", "customer": customer.stripe_customer_id, "description": self.description, - "metadata": metadata + "metadata": metadata, } if self.statement_descriptor: params["statement_descriptor"] = self.statement_descriptor @@ -381,7 +420,7 @@ def charge(self): except stripe.error.CardError as e: self.charge_attempt_failed = True self.is_charged = False - self.stripe_charge_id = e.json_body.get('error', {}).get('charge', '') + self.stripe_charge_id = e.json_body.get("error", {}).get("charge", "") self.stripe_response = e.json_body self.save() stripe_charge_card_exception.send(sender=StripeCharge, instance=self, exception=e) @@ -417,7 +456,7 @@ def _lookup_double_charge(self, customer, metadata): return charge return None - def refund(self): + def refund(self, amount_to_refund=None): stripe.api_key = stripe_settings.API_KEY if not self.is_charged: @@ -426,8 +465,19 @@ def refund(self): if self.is_refunded: raise StripeMethodNotAllowed("Already refunded.") - stripe_refund = stripe.Refund.create(charge=self.stripe_charge_id) - self.is_refunded = True + if amount_to_refund is None: + # refund all remaining amount + amount_to_refund = self.amount - self.amount_refunded + else: + # just to make sure + amount_to_refund = abs(amount_to_refund) + + if (amount_to_refund + self.amount_refunded) > self.amount: + raise StripeMethodNotAllowed("Refunds exceed charge") + + stripe_refund = stripe.Refund.create(charge=self.stripe_charge_id, amount=amount_to_refund) + self.is_refunded = (amount_to_refund + self.amount_refunded) == self.amount + self.amount_refunded += amount_to_refund self.stripe_refund_id = stripe_refund["id"] self.save() stripe_charge_refunded.send(sender=StripeCharge, instance=self) @@ -447,29 +497,43 @@ class StripeSubscriptionPlan(StripeBasicModel): ) is_created_at_stripe = models.BooleanField(default=False) - source = JSONField(blank=True, help_text=_("Source of the plan, ie: {\"prescription\": 1}")) + source = JSONField(blank=True, help_text=_('Source of the plan, ie: {"prescription": 1}')) amount = models.BigIntegerField(help_text=_("In cents. More: https://stripe.com/docs/api#create_plan-amount")) currency = models.CharField( - max_length=3, help_text=_("3 letter ISO code, default USD, https://stripe.com/docs/api#create_plan-currency"), - default="USD") + max_length=3, + help_text=_("3 letter ISO code, default USD, https://stripe.com/docs/api#create_plan-currency"), + default="USD", + ) name = models.CharField( - max_length=255, help_text=_("Name of the plan, to be displayed on invoices and in the web interface.")) + max_length=255, help_text=_("Name of the plan, to be displayed on invoices and in the web interface.") + ) interval = models.CharField( - max_length=10, help_text=_("Specifies billing frequency. Either day, week, month or year."), - choices=INTERVAL_CHOICES) + max_length=10, + help_text=_("Specifies billing frequency. Either day, week, month or year."), + choices=INTERVAL_CHOICES, + ) interval_count = models.IntegerField(default=1, validators=[MinValueValidator(1)]) metadata = JSONField( blank=True, - help_text=_("A set of key/value pairs that you can attach to a plan object. It can be useful" - " for storing additional information about the plan in a structured format.")) + help_text=_( + "A set of key/value pairs that you can attach to a plan object. It can be useful" + " for storing additional information about the plan in a structured format." + ), + ) statement_descriptor = models.CharField( - max_length=22, help_text=_("An arbitrary string to be displayed on your customer’s credit card statement."), - blank=True) + max_length=22, + help_text=_("An arbitrary string to be displayed on your customer’s credit card statement."), + blank=True, + ) trial_period_days = models.IntegerField( - default=0, validators=[MinValueValidator(0)], - help_text=_("Specifies a trial period in (an integer number of) days. If you include a trial period," - " the customer won’t be billed for the first time until the trial period ends. If the customer " - "cancels before the trial period is over, she’ll never be billed at all.")) + default=0, + validators=[MinValueValidator(0)], + help_text=_( + "Specifies a trial period in (an integer number of) days. If you include a trial period," + " the customer won’t be billed for the first time until the trial period ends. If the customer " + "cancels before the trial period is over, she’ll never be billed at all." + ), + ) def create_at_stripe(self): if self.is_created_at_stripe: @@ -486,7 +550,7 @@ def create_at_stripe(self): name=self.name, metadata=self.metadata, statement_descriptor=self.statement_descriptor, - trial_period_days=self.trial_period_days + trial_period_days=self.trial_period_days, ) except stripe.error.StripeError: self.is_created_at_stripe = False @@ -519,18 +583,30 @@ class StripeSubscription(StripeBasicModel): user = models.ForeignKey(USER_MODEL, on_delete=models.CASCADE, related_name="stripe_subscriptions") customer = models.ForeignKey(StripeCustomer, on_delete=models.SET_NULL, null=True) status = models.CharField( - max_length=255, help_text="https://stripe.com/docs/api/python#subscription_object-status, " - "empty if not sent created at stripe", blank=True, choices=STATUS_CHOICES) + max_length=255, + help_text="https://stripe.com/docs/api/python#subscription_object-status, " + "empty if not sent created at stripe", + blank=True, + choices=STATUS_CHOICES, + ) metadata = JSONField(blank=True, help_text="https://stripe.com/docs/api/python#create_subscription-metadata") tax_percent = models.DecimalField( - default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], decimal_places=2, max_digits=3, - help_text="https://stripe.com/docs/api/python#subscription_object-tax_percent") + default=0, + validators=[MinValueValidator(0), MaxValueValidator(100)], + decimal_places=2, + max_digits=3, + help_text="https://stripe.com/docs/api/python#subscription_object-tax_percent", + ) # application_fee_percent = models.DecimalField( # default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], decimal_places=2, max_digits=3, # help_text="https://stripe.com/docs/api/python#create_subscription-application_fee_percent") coupon = models.ForeignKey( - StripeCoupon, blank=True, null=True, on_delete=models.SET_NULL, - help_text="https://stripe.com/docs/api/python#create_subscription-coupon") + StripeCoupon, + blank=True, + null=True, + on_delete=models.SET_NULL, + help_text="https://stripe.com/docs/api/python#create_subscription-coupon", + ) end_date = models.DateField(null=True, blank=True, db_index=True) canceled_at = models.DateTimeField(null=True, blank=True, db_index=True) at_period_end = models.BooleanField(default=False) @@ -591,8 +667,7 @@ def cancel(self, at_period_end=False): @classmethod def get_subcriptions_for_cancel(cls): today = timezone.localtime(timezone.now() + relativedelta(hours=1)).date() - return cls.objects.filter( - end_date__lte=today, status=cls.STATUS_ACTIVE) + return cls.objects.filter(end_date__lte=today, status=cls.STATUS_ACTIVE) @classmethod def end_subscriptions(cls, at_period_end=False): @@ -649,6 +724,9 @@ def _parse_customer_notification(self, model, action): except (StripeCustomer.DoesNotExist, stripe.error.StripeError) as e: logger.warning("[AA-Stripe] cannot parse customer.updated webhook: {}".format(e)) + def _parse_dispute_notification(self, action): + logger.info("[AA-Stripe] New dispute for charge {}".format(self.raw_data["data"]["object"]["charge"])) + def parse(self, save=False): if self.is_parsed: raise StripeWebhookAlreadyParsed @@ -660,8 +738,12 @@ def parse(self, save=False): event_model, event_action = None, None webhook_pre_parse.send( - sender=self.__class__, instance=self, event_type=event_type, event_model=event_model, - event_action=event_action) + sender=self.__class__, + instance=self, + event_type=event_type, + event_model=event_model, + event_action=event_action, + ) # parse if event_model: @@ -669,6 +751,8 @@ def parse(self, save=False): self._parse_coupon_notification(event_action) elif event_model in ["customer", "customer.source"]: self._parse_customer_notification(event_model, event_action) + elif event_model == "charge.dispute": + self._parse_dispute_notification(event_action) self.is_parsed = True if save: diff --git a/tests/test_charge.py b/tests/test_charge.py index a8349b8..444acf0 100644 --- a/tests/test_charge.py +++ b/tests/test_charge.py @@ -17,7 +17,9 @@ class TestCharges(TestCase): def setUp(self): - self.user = UserModel.objects.create(email="foo@bar.bar", username="foo", password="dump-password") + self.user = UserModel.objects.create( + email="foo@bar.bar", username="foo", password="dump-password" + ) @mock.patch("aa_stripe.management.commands.charge_stripe.stripe.Charge.list") @mock.patch("aa_stripe.management.commands.charge_stripe.stripe.Charge.create") @@ -30,6 +32,7 @@ def success_handler(sender, instance, **kwargs): def exception_handler(sender, instance, **kwargs): self.exception_signal_was_called = True + stripe_charge_succeeded.connect(success_handler) stripe_charge_card_exception.connect(exception_handler) @@ -37,62 +40,87 @@ def exception_handler(sender, instance, **kwargs): "customer_id": "cus_AlSWz1ZQw7qG2z", "currency": "usd", "amount": 100, - "description": "ABC" + "description": "ABC", } charge_create_mocked.return_value = stripe.Charge(id="AA1") charge_list_mocked.return_value = stripe.ListObject.construct_from( - {"has_more": False, "data": [{"captured": True, "metadata": {"object_id": "a", "content_type_id": "b"}}]}, "mykey" + { + "has_more": False, + "data": [ + { + "captured": True, + "metadata": {"object_id": "a", "content_type_id": "b"}, + } + ], + }, + "mykey", ) StripeCustomer.objects.create( - user=self.user, stripe_customer_id="bum", stripe_js_response='"aa"') + user=self.user, stripe_customer_id="bum", stripe_js_response='"aa"' + ) StripeCustomer.objects.create( - user=self.user, stripe_customer_id=data["customer_id"], stripe_js_response='"foo"') + user=self.user, + stripe_customer_id=data["customer_id"], + stripe_js_response='"foo"', + ) 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)) + 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) + ) - 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, - description=data["description"]) + 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, + description=data["description"], + ) self.assertFalse(charge.is_charged) # test in case of an API error - stripe_error_json_body = { - 'error': {'type': 'api_error'} - } + stripe_error_json_body = {"error": {"type": "api_error"}} charge_create_mocked.side_effect = StripeError(json_body=stripe_error_json_body) with self.assertRaises(SystemExit): out = StringIO() sys.stdout = out self.success_signal_was_called = False self.exception_signal_was_called = False - call_command('charge_stripe') + call_command("charge_stripe") self.assertFalse(self.success_signal_was_called) self.assertFalse(self.exception_signal_was_called) charge.refresh_from_db() self.assertFalse(charge.is_charged) self.assertFalse(charge.charge_attempt_failed) self.assertDictEqual(charge.stripe_response, stripe_error_json_body) - self.assertIn('Exception happened', out.getvalue()) + self.assertIn("Exception happened", out.getvalue()) # test in case of an hard API error charge_create_mocked.reset_mock() stripe_error_json_body = { - 'error': {'code': 'resource_missing', - 'doc_url': 'https://stripe.com/docs/error-codes/resource-missing', - 'message': 'No such customer: cus_ESrgXHlDA3E7mQ', - 'param': 'customer', - 'type': 'invalid_request_error' - } + "error": { + "code": "resource_missing", + "doc_url": "https://stripe.com/docs/error-codes/resource-missing", + "message": "No such customer: cus_ESrgXHlDA3E7mQ", + "param": "customer", + "type": "invalid_request_error", + } } charge_create_mocked.side_effect = StripeError(json_body=stripe_error_json_body) self.success_signal_was_called = False self.exception_signal_was_called = False - call_command('charge_stripe') + call_command("charge_stripe") self.assertFalse(self.success_signal_was_called) self.assertTrue(self.exception_signal_was_called) charge.refresh_from_db() @@ -103,15 +131,18 @@ def exception_handler(sender, instance, **kwargs): # test regular case charge_create_mocked.reset_mock() card_error_json_body = { - 'error': {'charge': 'ch_1F5C8nBszOVoiLmgPWC36cnI', - 'code': 'card_declined', - 'decline_code': 'generic_decline', - 'doc_url': 'https://stripe.com/docs/error-codes/card-declined', - 'message': 'Your card was declined.', - 'type': 'card_error' - } + "error": { + "charge": "ch_1F5C8nBszOVoiLmgPWC36cnI", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "type": "card_error", + } } - charge_create_mocked.side_effect = CardError(message="a", param="b", code="c", json_body=card_error_json_body) + charge_create_mocked.side_effect = CardError( + message="a", param="b", code="c", json_body=card_error_json_body + ) self.success_signal_was_called = False self.exception_signal_was_called = False charge.charge_attempt_failed = False @@ -141,9 +172,13 @@ def exception_handler(sender, instance, **kwargs): manual_charge.refresh_from_db() 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"], - metadata={'object_id': None, 'content_type_id': None}) + charge_create_mocked.assert_called_with( + amount=charge.amount, + currency=data["currency"], + customer=data["customer_id"], + description=data["description"], + metadata={"object_id": None, "content_type_id": None}, + ) # manual call manual_charge.charge() @@ -157,7 +192,20 @@ def exception_handler(sender, instance, **kwargs): charge.is_charged = False charge.save() charge_list_mocked.return_value = stripe.ListObject.construct_from( - {"has_more": False, "data": [{"id": "match", "captured": True, "metadata": {"object_id": charge.object_id, "content_type_id": charge.content_type_id}}]}, "mykey" + { + "has_more": False, + "data": [ + { + "id": "match", + "captured": True, + "metadata": { + "object_id": charge.object_id, + "content_type_id": charge.content_type_id, + }, + } + ], + }, + "mykey", ) charge.charge() self.assertTrue(charge.is_charged) @@ -182,15 +230,24 @@ def test_refund(self, refund_create_mocked): "customer_id": "cus_AlSWz1ZQw7qG2z", "currency": "usd", "amount": 100, - "description": "ABC" + "description": "ABC", } refund_create_mocked.return_value = stripe.Refund(id="R1") 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)) - charge = StripeCharge.objects.create(user=self.user, amount=data["amount"], customer=customer, - description=data["description"]) + 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) + ) + charge = StripeCharge.objects.create( + user=self.user, + amount=data["amount"], + customer=customer, + description=data["description"], + ) self.assertFalse(charge.is_refunded) @@ -203,12 +260,33 @@ def test_refund(self, refund_create_mocked): charge.stripe_charge_id = "abc" charge.save() + # partial refund + with mock.patch("aa_stripe.signals.stripe_charge_refunded.send") as refund_signal_send: + to_refund = charge.amount - 1 + charge.refund(to_refund) + refund_create_mocked.assert_called_with( + charge=charge.stripe_charge_id, amount=to_refund + ) + self.assertFalse(charge.is_refunded) + refund_signal_send.assert_called_with(sender=StripeCharge, instance=charge) + # refund > amount + with self.assertRaises(StripeMethodNotAllowed): + charge.refund(charge.amount + 1) + self.assertFalse(charge.is_refunded) + + charge.amount_refunded = 0 + charge.stripe_refund_id = "" + charge.save() + # refund - passes with mock.patch("aa_stripe.signals.stripe_charge_refunded.send") as refund_signal_send: charge.refund() - refund_create_mocked.assert_called_with(charge=charge.stripe_charge_id) + refund_create_mocked.assert_called_with( + charge=charge.stripe_charge_id, amount=charge.amount + ) self.assertTrue(charge.is_refunded) self.assertEqual(charge.stripe_refund_id, "R1") + self.assertEqual(charge.amount_refunded, charge.amount) refund_signal_send.assert_called_with(sender=StripeCharge, instance=charge) # refund - error: already refunded diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 4e7438b..e60bcaf 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -21,7 +21,8 @@ class TestWebhook(BaseTestCase): def _create_ping_webhook(self): - payload = json.loads("""{ + payload = json.loads( + """{ "id": "", "object": "event", "api_version": "2017-06-05", @@ -33,7 +34,8 @@ def _create_ping_webhook(self): "idempotency_key": null }, "type": "ping" - }""") + }""" + ) payload["id"] = "evt_{}".format(uuid4()) payload["request"]["id"] = "req_{}".format(uuid4()) payload["created"] = int(time.mktime(datetime.now().timetuple())) @@ -41,7 +43,8 @@ def _create_ping_webhook(self): def test_subscription_creation(self): self.assertEqual(StripeWebhook.objects.count(), 0) - payload = json.loads("""{ + payload = json.loads( + """{ "created": 1326853478, "livemode": false, "id": "evt_00000000000000", @@ -116,15 +119,14 @@ def test_subscription_creation(self): "transfer_group": null } } - }""") + }""" + ) url = reverse("stripe-webhooks") response = self.client.post(url, data=payload, format="json") self.assertEqual(response.status_code, 400) # not signed - headers = { - "HTTP_STRIPE_SIGNATURE": "wrong", # todo: generate signature - } + headers = {"HTTP_STRIPE_SIGNATURE": "wrong"} # todo: generate signature self.client.credentials(**headers) response = self.client.post(url, data=payload, format="json") self.assertEqual(response.status_code, 400) # wrong signature @@ -140,7 +142,8 @@ def test_subscription_creation(self): def test_coupon_create(self): self.assertEqual(StripeCoupon.objects.count(), 0) - payload = json.loads("""{ + payload = json.loads( + """{ "id": "evt_1AtuXzLoWm2f6pRwC5YntNLU", "object": "event", "api_version": "2017-06-05", @@ -171,8 +174,10 @@ def test_coupon_create(self): "idempotency_key": null }, "type": "coupon.created" - }""") - stripe_response = json.loads("""{ + }""" + ) + stripe_response = json.loads( + """{ "id": "nicecoupon", "object": "coupon", "amount_off": 1000, @@ -188,7 +193,8 @@ def test_coupon_create(self): "redeem_by": null, "times_redeemed": 0, "valid": true - }""") + }""" + ) url = reverse("stripe-webhooks") with requests_mock.Mocker() as m: m.register_uri("GET", "https://api.stripe.com/v1/coupons/nicecoupon", text=json.dumps(stripe_response)) @@ -202,11 +208,12 @@ def test_coupon_create(self): self.assertEqual(coupon.coupon_id, "nicecoupon") # test coupon.created while the coupon has already been deleted from Stripe before the webhook arrived - m.register_uri("GET", "https://api.stripe.com/v1/coupons/doesnotexist", status_code=404, text=json.dumps({ - "error": { - "type": "invalid_request_error" - } - })) + m.register_uri( + "GET", + "https://api.stripe.com/v1/coupons/doesnotexist", + status_code=404, + text=json.dumps({"error": {"type": "invalid_request_error"}}), + ) payload["id"] = "evt_another" payload["request"]["id"] = ["req_blahblah"] payload["data"]["object"]["id"] = "doesnotexist" @@ -225,8 +232,10 @@ def test_coupon_create(self): self.client.credentials(**self._get_signature_headers(payload)) response = self.client.post(url, data=payload, format="json") self.assertEqual(response.status_code, 201) - self.assertEqual(StripeWebhook.objects.get(id=response.data["id"]).parse_error, - "Coupon with this coupon_id and creation date already exists") + self.assertEqual( + StripeWebhook.objects.get(id=response.data["id"]).parse_error, + "Coupon with this coupon_id and creation date already exists", + ) self.assertEqual(coupon_qs.count(), 1) # test receiving coupon.created to a coupon that already exists in our @@ -258,14 +267,18 @@ def test_coupon_create(self): self.client.credentials(**self._get_signature_headers(payload)) response = self.client.post(url, data=payload, format="json") self.assertEqual(response.status_code, 201) - self.assertEqual(StripeWebhook.objects.get(id=response.data["id"]).parse_error, - "Coupon with this coupon_id and creation date already exists") + self.assertEqual( + StripeWebhook.objects.get(id=response.data["id"]).parse_error, + "Coupon with this coupon_id and creation date already exists", + ) self.assertEqual(coupon_qs.count(), 2) def test_coupon_update(self): - coupon = self._create_coupon("nicecoupon", amount_off=100, duration=StripeCoupon.DURATION_ONCE, - metadata={"nie": "tak", "lol1": "rotfl"}) - payload = json.loads("""{ + coupon = self._create_coupon( + "nicecoupon", amount_off=100, duration=StripeCoupon.DURATION_ONCE, metadata={"nie": "tak", "lol1": "rotfl"} + ) + payload = json.loads( + """{ "id": "evt_1AtuTOLoWm2f6pRw6dYfQzWh", "object": "event", "api_version": "2017-06-05", @@ -305,7 +318,8 @@ def test_coupon_update(self): "idempotency_key": null }, "type": "coupon.updated" - }""") + }""" + ) payload["data"]["object"]["created"] = coupon.stripe_response["created"] url = reverse("stripe-webhooks") @@ -313,10 +327,7 @@ def test_coupon_update(self): response = self.client.post(url, data=payload, format="json") coupon.refresh_from_db() self.assertEqual(response.status_code, 201) - self.assertEqual(coupon.metadata, { - "lol1": "rotfl2", - "lol2": "yeah" - }) + self.assertEqual(coupon.metadata, {"lol1": "rotfl2", "lol2": "yeah"}) # test updating non existing coupon - nothing else than saving the webhook should happen payload["id"] = "evt_1" @@ -332,7 +343,8 @@ def test_coupon_update(self): def test_coupon_delete(self, m): coupon = self._create_coupon("nicecoupon", amount_off=100, duration=StripeCoupon.DURATION_ONCE) self.assertFalse(coupon.is_deleted) - payload = json.loads("""{ + payload = json.loads( + """{ "id": "evt_1Atthtasdsaf6pRwkdLOSKls", "object": "event", "api_version": "2017-06-05", @@ -363,13 +375,15 @@ def test_coupon_delete(self, m): "idempotency_key": null }, "type": "coupon.deleted" - }""") + }""" + ) payload["data"]["object"]["created"] = coupon.stripe_response["created"] - m.register_uri("GET", "https://api.stripe.com/v1/coupons/nicecoupon", status_code=404, text=json.dumps({ - "error": { - "type": "invalid_request_error" - } - })) + m.register_uri( + "GET", + "https://api.stripe.com/v1/coupons/nicecoupon", + status_code=404, + text=json.dumps({"error": {"type": "invalid_request_error"}}), + ) url = reverse("stripe-webhooks") self.client.credentials(**self._get_signature_headers(payload)) @@ -379,8 +393,13 @@ def test_coupon_delete(self, m): self.assertTrue(StripeCoupon.objects.deleted().filter(pk=coupon.pk).exists()) webhook = StripeWebhook.objects.first() self.assertTrue(webhook.is_parsed) - mocked_signal.assert_called_with(event_action="deleted", event_model="coupon", event_type="coupon.deleted", - instance=webhook, sender=StripeWebhook) + mocked_signal.assert_called_with( + event_action="deleted", + event_model="coupon", + event_type="coupon.deleted", + instance=webhook, + sender=StripeWebhook, + ) # test deleting event that has already been deleted - should not raise any errors # it will just make sure is_deleted is set for this coupon @@ -399,7 +418,8 @@ def test_coupon_delete(self, m): def test_ping(self): # test receiving ping event (the only event without "." inside the event name) StripeWebhook.objects.all().delete() - payload = json.loads("""{ + payload = json.loads( + """{ "id": "evt_1Atthtasdsaf6pRwkdLOhKls", "object": "event", "api_version": "2017-06-05", @@ -411,13 +431,19 @@ def test_ping(self): "idempotency_key": null }, "type": "ping" - }""") + }""" + ) self.client.credentials(**self._get_signature_headers(payload)) with mock.patch("aa_stripe.models.webhook_pre_parse.send") as mocked_signal: response = self.client.post(reverse("stripe-webhooks"), data=payload, format="json") self.assertEqual(response.status_code, 201) - mocked_signal.assert_called_with(event_action=None, event_model=None, event_type="ping", - instance=StripeWebhook.objects.first(), sender=StripeWebhook) + mocked_signal.assert_called_with( + event_action=None, + event_model=None, + event_type="ping", + instance=StripeWebhook.objects.first(), + sender=StripeWebhook, + ) @override_settings(ADMINS=["admin@example.com"]) def test_check_pending_webhooks_command(self): @@ -428,12 +454,14 @@ def test_check_pending_webhooks_command(self): webhook = self._create_ping_webhook() # create response with fake limits - base_stripe_response = json.loads("""{ + base_stripe_response = json.loads( + """{ "object": "list", "url": "/v1/events", "has_more": true, "data": [] - }""") + }""" + ) event1_data = webhook.raw_data.copy() event1_data["id"] = "evt_1" stripe_response_part1 = base_stripe_response.copy() @@ -448,19 +476,24 @@ def test_check_pending_webhooks_command(self): last_webhook = StripeWebhook.objects.first() with requests_mock.Mocker() as m: m.register_uri( - "GET", "https://api.stripe.com/v1/events/{}".format(last_webhook.id), [ + "GET", + "https://api.stripe.com/v1/events/{}".format(last_webhook.id), + [ {"text": json.dumps(last_webhook.raw_data)}, {"text": json.dumps(last_webhook.raw_data)}, - {"text": json.dumps({"error": {"type": "invalid_request_error"}}), "status_code": 404} - ] + {"text": json.dumps({"error": {"type": "invalid_request_error"}}), "status_code": 404}, + ], ) m.register_uri( - "GET", "https://api.stripe.com/v1/events?ending_before={}&limit=100".format(last_webhook.id), - text=json.dumps(stripe_response_part1) + "GET", + "https://api.stripe.com/v1/events?ending_before={}&limit=100".format(last_webhook.id), + text=json.dumps(stripe_response_part1), ) m.register_uri( - "GET", "https://api.stripe.com/v1/events?ending_before={}&limit=100".format(event1_data["id"]), - text=json.dumps(stripe_response_part2)) + "GET", + "https://api.stripe.com/v1/events?ending_before={}&limit=100".format(event1_data["id"]), + text=json.dumps(stripe_response_part2), + ) with self.assertRaises(StripePendingWebooksLimitExceeded): call_command("check_pending_webhooks") @@ -480,35 +513,51 @@ def test_check_pending_webhooks_command(self): # in case the last event in the database does not longer exist at Stripe # the url below must be called (events are removed after 30 days) - m.register_uri("GET", "https://api.stripe.com/v1/events?&limit=100", - text=json.dumps(stripe_response_part2)) + m.register_uri("GET", "https://api.stripe.com/v1/events?&limit=100", text=json.dumps(stripe_response_part2)) call_command("check_pending_webhooks") # make sure the --site parameter works - pass not existing site id - should fail with self.assertRaises(Site.DoesNotExist): call_command("check_pending_webhooks", site=-1) - @requests_mock.Mocker() - def test_customer_update(self, m): + def test_dispute(self): + self.assertEqual(StripeWebhook.objects.count(), 0) payload = { - "id": "evt_xyz", "object": "event", + "type": "charge.dispute.created", + "id": "evt_123", "api_version": "2018-01-01", "created": 1503477866, "data": { "object": { - "id": "cus_xyz", - "object": "customer", - "sources": [ - {"id": "card_xyz"} - ] + "id": "dp_ANSJH7zPDQPGqPEw0Cxq", + "object": "dispute", + "charge": "ch_5Q4BjL06oPWwho", + "evidence": { + "customer_name": "Jane Austen", + "customer_purchase_ip": "127.0.0.1", + "product_description": "An Awesome product", + "shipping_tracking_number": "Z01234567890", + "uncategorized_text": "Additional notes and comments", + }, + "evidence_details": {"due_by": 1403047735, "submission_count": 1}, } }, - "request": { - "id": "req_putcEg4hE9bkUb", - "idempotency_key": None - }, - "type": "customer.updated" + } + self.client.credentials(**self._get_signature_headers(payload)) + response = self.client.post(reverse("stripe-webhooks"), data=payload, format="json") + self.assertEqual(201, response.status_code) + + @requests_mock.Mocker() + def test_customer_update(self, m): + payload = { + "id": "evt_xyz", + "object": "event", + "api_version": "2018-01-01", + "created": 1503477866, + "data": {"object": {"id": "cus_xyz", "object": "customer", "sources": [{"id": "card_xyz"}]}}, + "request": {"id": "req_putcEg4hE9bkUb", "idempotency_key": None}, + "type": "customer.updated", } customer_api_response = { "id": "cus_xyz", @@ -522,8 +571,7 @@ def test_customer_update(self, m): "discount": None, "email": None, "livemode": False, - "metadata": { - }, + "metadata": {}, "shipping": None, "sources": { "object": "list", @@ -532,17 +580,15 @@ def test_customer_update(self, m): ], "has_more": False, "total_count": 1, - "url": "/v1/customers/cus_xyz/sources" + "url": "/v1/customers/cus_xyz/sources", }, "subscriptions": { "object": "list", - "data": [ - - ], + "data": [], "has_more": False, "total_count": 0, - "url": "/v1/customers/cus_xyz/subscriptions" - } + "url": "/v1/customers/cus_xyz/subscriptions", + }, } self._create_customer() @@ -558,11 +604,7 @@ def test_customer_update(self, m): # (although if a card is removed or added, Stripe will trigger customer.updated webhook) payload["type"] = "customer.source.updated" payload["id"] = "evt_123" - payload["data"]["object"] = { - "id": "card_1BhOfILoWm2f6pRwe4gkIJc7", - "object": "card", - "customer": "cus_xyz" - } + payload["data"]["object"] = {"id": "card_1BhOfILoWm2f6pRwe4gkIJc7", "object": "card", "customer": "cus_xyz"} self.client.credentials(**self._get_signature_headers(payload)) with mock.patch("aa_stripe.models.StripeCustomer.refresh_from_stripe") as mocked_refresh: response = self.client.post(url, data=payload, format="json")