diff --git a/.circleci/config.yml b/.circleci/config.yml index b0a828a12..dc1243ca2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -123,7 +123,7 @@ jobs: environment: - TOXENV=py36-dj20-postgres - PINAX_STRIPE_DATABASE_HOST=127.0.0.1 - - PINAX_STRIPE_DATABASE_USER=root + - PINAX_STRIPE_DATABASE_USER=postgres - PINAX_STRIPE_DATABASE_NAME=circle_test - image: circleci/postgres:9.6-alpine release: @@ -153,86 +153,6 @@ workflows: version: 2 test: jobs: - - lint: - filters: - tags: - only: /.*/ - - py27dj18: - filters: - tags: - only: /.*/ - - py27dj110: - filters: - tags: - only: /.*/ - - py27dj111: - filters: - tags: - only: /.*/ - - py34dj18: - filters: - tags: - only: /.*/ - - py34dj110: - filters: - tags: - only: /.*/ - - py34dj111: - filters: - tags: - only: /.*/ - - py34dj20: - filters: - tags: - only: /.*/ - - py35dj18: - filters: - tags: - only: /.*/ - - py35dj110: - filters: - tags: - only: /.*/ - - py35dj111: - filters: - tags: - only: /.*/ - - py35dj20: - filters: - tags: - only: /.*/ - - py36dj111: - filters: - tags: - only: /.*/ - - py36dj20: - filters: - tags: - only: /.*/ - - py36dj20psql: - filters: - tags: - only: /.*/ - - release: - context: org-global - requires: - - lint - - py27dj18 - - py27dj110 - - py27dj111 - - py34dj18 - - py34dj110 - - py34dj111 - - py34dj20 - - py35dj18 - - py35dj110 - - py35dj111 - - py35dj20 - - py36dj111 - - py36dj20 - - py36dj20psql - filters: - tags: - only: /[0-9]+(\.[0-9]+)*/ - branches: - ignore: /.*/ + - lint + - py27dj111 + - py36dj20psql diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 4db6386b2..000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,18 +0,0 @@ -#### Issue Summary - -A short summary of the issue - - ---- - -_If this is a bug instead of a question or feature request, please fill out the sections below._ - ---- - -#### Steps to Reproduce - -It's essential that you provide enough information for someone else to replicate the problem you're seeing. Simply describing something that's broken on your current project is not enough! - -#### What were you expecting to happen? - -#### What actually happened? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 3ffa6b722..000000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,14 +0,0 @@ -#### What's this PR do? - -#### Any background context you want to provide? - -#### What ticket or issue # does this fix? - -Closes #[issue number] - -#### Definition of Done (check if considered and/or addressed): - -- [ ] Are all backwards incompatible changes documented in this PR? -- [ ] Have all new dependencies been documented in this PR? -- [ ] Has the appropriate documentation been updated (if applicable)? -- [ ] Have you written tests to prove this change works (if applicable)? diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a2a04cc18 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: makemigrations mergemigrations + +makemigrations mergemigrations: PINAX_STRIPE_DATABASE_ENGINE=django.db.backends.sqlite3 + +makemigrations: + django-admin makemigrations pinax_stripe + +mergemigrations: + django-admin makemigrations --merge pinax_stripe diff --git a/pinax/stripe/actions/coupons.py b/pinax/stripe/actions/coupons.py index bcf6d08a7..f89c61f9b 100644 --- a/pinax/stripe/actions/coupons.py +++ b/pinax/stripe/actions/coupons.py @@ -6,9 +6,15 @@ def sync_coupons(): """ Synchronizes all coupons from the Stripe API + + TODO: Support connect / stripe_account param """ coupons = stripe.Coupon.auto_paging_iter() for coupon in coupons: + sync_coupon_from_stripe_data(coupon) + + +def sync_coupon_from_stripe_data(coupon, stripe_account=None): defaults = dict( amount_off=( utils.convert_amount_for_db(coupon["amount_off"], coupon["currency"]) @@ -18,15 +24,24 @@ def sync_coupons(): currency=coupon["currency"] or "", duration=coupon["duration"], duration_in_months=coupon["duration_in_months"], + livemode=coupon["livemode"], max_redemptions=coupon["max_redemptions"], metadata=coupon["metadata"], percent_off=coupon["percent_off"], redeem_by=utils.convert_tstamp(coupon["redeem_by"]) if coupon["redeem_by"] else None, times_redeemed=coupon["times_redeemed"], valid=coupon["valid"], + stripe_account=stripe_account, ) obj, created = models.Coupon.objects.get_or_create( stripe_id=coupon["id"], + stripe_account=stripe_account, defaults=defaults ) utils.update_with_defaults(obj, defaults, created) + return obj + + +def purge_local(coupon, stripe_account=None): + return models.Coupon.objects.filter( + stripe_id=coupon["id"], stripe_account=stripe_account).delete() diff --git a/pinax/stripe/actions/subscriptions.py b/pinax/stripe/actions/subscriptions.py index 1a2e77853..ce24c8602 100644 --- a/pinax/stripe/actions/subscriptions.py +++ b/pinax/stripe/actions/subscriptions.py @@ -6,6 +6,7 @@ import stripe from .. import hooks, models, utils +from .coupons import sync_coupon_from_stripe_data def cancel(subscription, at_period_end=True): @@ -167,6 +168,18 @@ def sync_subscription_from_stripe_data(customer, subscription): defaults=defaults ) sub = utils.update_with_defaults(sub, defaults, created) + if subscription.get("discount", None): + defaults = { + "start": utils.convert_tstamp(subscription["discount"]["start"]), + "end": utils.convert_tstamp(subscription["discount"]["end"]) if subscription["discount"]["end"] else None, + "coupon": sync_coupon_from_stripe_data(subscription["discount"]["coupon"], stripe_account=customer.stripe_account), + } + + obj, created = models.Discount.objects.get_or_create( + subscription=sub, + defaults=defaults + ) + utils.update_with_defaults(obj, defaults, created) return sub diff --git a/pinax/stripe/admin.py b/pinax/stripe/admin.py index cbe6be417..ece84a1bc 100644 --- a/pinax/stripe/admin.py +++ b/pinax/stripe/admin.py @@ -28,10 +28,11 @@ def user_search_fields(): User = get_user_model() fields = [ - "user__{0}".format(User.USERNAME_FIELD) + "user__{0}".format(User.USERNAME_FIELD), + "users__{0}".format(User.USERNAME_FIELD), ] if "email" in [f.name for f in User._meta.fields]: # pragma: no branch - fields += ["user__email"] + fields += ["user__email", "users__email"] return fields @@ -169,6 +170,7 @@ class ChargeAdmin(ModelAdmin): "disputed", "refunded", "receipt_sent", + "display_outcome", "created_at", ] list_select_related = [ @@ -193,6 +195,12 @@ class ChargeAdmin(ModelAdmin): "stripe_account_stripe_id", ] + def display_outcome(self, obj): + return "{} / {}".format( + obj.outcome.get("type", "-"), + obj.outcome.get("risk_level", "-")) if obj.outcome else None + display_outcome.short_description = "Outcome" + def get_queryset(self, request): qs = super(ChargeAdmin, self).get_queryset(request) return qs.prefetch_related("customer__user", "customer__users") @@ -225,6 +233,9 @@ class EventAdmin(ModelAdmin): "created_at", "stripe_account", ] + list_select_related = [ + "stripe_account", + ] list_filter = [ "kind", "created_at", @@ -267,7 +278,7 @@ class CustomerAdmin(ModelAdmin): raw_id_fields = ["user", "stripe_account"] list_display = [ "stripe_id", - "user", + "__str__", "account_balance", "currency", "delinquent", @@ -276,6 +287,10 @@ class CustomerAdmin(ModelAdmin): "date_purged", "stripe_account", ] + list_select_related = [ + "stripe_account", + "user", + ] list_filter = [ "delinquent", CustomerHasCardListFilter, @@ -291,6 +306,10 @@ class CustomerAdmin(ModelAdmin): BitcoinReceiverInline ] + def get_queryset(self, request): + qs = super(CustomerAdmin, self).get_queryset(request) + return qs.prefetch_related("users") + class InvoiceItemInline(admin.TabularInline): model = InvoiceItem @@ -364,6 +383,9 @@ class PlanAdmin(ModelAdmin): "trial_period_days", "stripe_account", ] + list_select_related = [ + "stripe_account", + ] search_fields = [ "stripe_id", "name", @@ -376,6 +398,7 @@ class PlanAdmin(ModelAdmin): class CouponAdmin(ModelAdmin): + raw_id_fields = ["stripe_account"] list_display = [ "stripe_id", "amount_off", @@ -412,6 +435,9 @@ class TransferAdmin(ModelAdmin): "description", "stripe_account", ] + list_select_related = [ + "stripe_account", + ] search_fields = [ "stripe_id", "event__stripe_id", diff --git a/pinax/stripe/migrations/0010_connect_squashed_0026_merge_20171106_2259.py b/pinax/stripe/migrations/0010_connect_squashed_0026_merge_20171106_2259.py new file mode 100644 index 000000000..4e6249494 --- /dev/null +++ b/pinax/stripe/migrations/0010_connect_squashed_0026_merge_20171106_2259.py @@ -0,0 +1,380 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-14 14:13 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.migrations.operations.special +import django.db.models.deletion +import django.utils.timezone +import jsonfield.fields + + +class Migration(migrations.Migration): + + replaces = [ + ('pinax_stripe', '0010_connect'), + ('pinax_stripe', '0011_subscription_connect'), + ('pinax_stripe', '0012_user_account'), + ('pinax_stripe', '0013_revert_0011'), + ('pinax_stripe', '0014_auto_20171018_1024'), + ('pinax_stripe', '0015_account-as-foreign-key'), + ('pinax_stripe', '0016_remove-user-account-account'), + ('pinax_stripe', '0017_user-account-account'), + ('pinax_stripe', '0018_add_discounts'), + ('pinax_stripe', '0011_auto_20171024_1209'), + ('pinax_stripe', '0011_auto_20171017_1234'), + ('pinax_stripe', '0012_merge_20171025_1443'), + ('pinax_stripe', '0019_merge_20171025_1519'), + ('pinax_stripe', '0020_account-publishablke-key'), + ('pinax_stripe', '0021_account-authorized'), + ('pinax_stripe', '0022_sub-account-related'), + ('pinax_stripe', '0011_account-as-foreign-key'), + ('pinax_stripe', '0012_merge_20171026_1310'), + ('pinax_stripe', '0013_auto_20171027_1443'), + ('pinax_stripe', '0023_merge_20171027_1626'), + ('pinax_stripe', '0013_auto_20171025_2153'), + ('pinax_stripe', '0011_account_publishable_key'), + ('pinax_stripe', '0014_merge_20171030_1554'), + ('pinax_stripe', '0015_merge_20171030_1852'), + ('pinax_stripe', '0024_merge_20171030_1941'), + ] + + dependencies = [ + ('pinax_stripe', '0009_auto_20170825_1841'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Account', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_id', models.CharField(max_length=191, unique=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('business_name', models.TextField(blank=True, null=True)), + ('business_url', models.TextField(blank=True, null=True)), + ('charges_enabled', models.BooleanField(default=False)), + ('country', models.CharField(max_length=2)), + ('debit_negative_balances', models.BooleanField(default=False)), + ('decline_charge_on_avs_failure', models.BooleanField(default=False)), + ('decline_charge_on_cvc_failure', models.BooleanField(default=False)), + ('default_currency', models.CharField(max_length=3)), + ('details_submitted', models.BooleanField(default=False)), + ('display_name', models.TextField(blank=True, null=True)), + ('email', models.TextField(blank=True, null=True)), + ('legal_entity_address_city', models.TextField(blank=True, null=True)), + ('legal_entity_address_country', models.TextField(blank=True, null=True)), + ('legal_entity_address_line1', models.TextField(blank=True, null=True)), + ('legal_entity_address_line2', models.TextField(blank=True, null=True)), + ('legal_entity_address_postal_code', models.TextField(blank=True, null=True)), + ('legal_entity_address_state', models.TextField(blank=True, null=True)), + ('legal_entity_dob', models.DateField(null=True)), + ('legal_entity_first_name', models.TextField(blank=True, null=True)), + ('legal_entity_gender', models.TextField(blank=True, null=True)), + ('legal_entity_last_name', models.TextField(blank=True, null=True)), + ('legal_entity_maiden_name', models.TextField(blank=True, null=True)), + ('legal_entity_personal_id_number_provided', models.BooleanField(default=False)), + ('legal_entity_phone_number', models.TextField(blank=True, null=True)), + ('legal_entity_ssn_last_4_provided', models.BooleanField(default=False)), + ('legal_entity_type', models.TextField(blank=True, null=True)), + ('legal_entity_verification_details', models.TextField(blank=True, null=True)), + ('legal_entity_verification_details_code', models.TextField(blank=True, null=True)), + ('legal_entity_verification_document', models.TextField(blank=True, null=True)), + ('legal_entity_verification_status', models.TextField(blank=True, null=True)), + ('type', models.TextField(blank=True, null=True)), + ('metadata', jsonfield.fields.JSONField(null=True)), + ('product_description', models.TextField(blank=True, null=True)), + ('statement_descriptor', models.TextField(blank=True, null=True)), + ('support_email', models.TextField(blank=True, null=True)), + ('support_phone', models.TextField(blank=True, null=True)), + ('timezone', models.TextField(blank=True, null=True)), + ('tos_acceptance_date', models.DateField(null=True)), + ('tos_acceptance_ip', models.TextField(blank=True, null=True)), + ('tos_acceptance_user_agent', models.TextField(blank=True, null=True)), + ('transfer_schedule_delay_days', models.PositiveSmallIntegerField(null=True)), + ('transfer_schedule_interval', models.TextField(blank=True, null=True)), + ('transfer_schedule_monthly_anchor', models.PositiveSmallIntegerField(null=True)), + ('transfer_schedule_weekly_anchor', models.TextField(blank=True, null=True)), + ('transfer_statement_descriptor', models.TextField(blank=True, null=True)), + ('transfers_enabled', models.BooleanField(default=False)), + ('verification_disabled_reason', models.TextField(blank=True, null=True)), + ('verification_due_by', models.DateTimeField(blank=True, null=True)), + ('verification_timestamp', models.DateTimeField(blank=True, null=True)), + ('verification_fields_needed', jsonfield.fields.JSONField(null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stripe_accounts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BankAccount', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_id', models.CharField(max_length=191, unique=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('account_holder_name', models.TextField()), + ('account_holder_type', models.TextField()), + ('bank_name', models.TextField(blank=True, null=True)), + ('country', models.TextField()), + ('currency', models.TextField()), + ('default_for_currency', models.BooleanField(default=False)), + ('fingerprint', models.TextField()), + ('last4', models.CharField(max_length=4)), + ('metadata', jsonfield.fields.JSONField(null=True)), + ('routing_number', models.TextField()), + ('status', models.TextField()), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bank_accounts', to='pinax_stripe.Account')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='charge', + name='available', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='charge', + name='available_on', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='charge', + name='fee', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), + ), + migrations.AddField( + model_name='charge', + name='fee_currency', + field=models.CharField(blank=True, max_length=10, null=True), + ), + migrations.AddField( + model_name='charge', + name='transfer_group', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='amount_reversed', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), + ), + migrations.AddField( + model_name='transfer', + name='application_fee', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), + ), + migrations.AddField( + model_name='transfer', + name='created', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='destination', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='destination_payment', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='failure_code', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='failure_message', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='livemode', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='transfer', + name='metadata', + field=jsonfield.fields.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='method', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='reversed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='transfer', + name='source_transaction', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='source_type', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='statement_descriptor', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='transfer_group', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='transfer', + name='type', + field=models.TextField(blank=True, null=True), + ), + migrations.CreateModel( + name='UserAccount', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_accounts', related_query_name='user_account', to='pinax_stripe.Customer')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_accounts', related_query_name='user_account', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='customer', + name='users', + field=models.ManyToManyField(related_name='customers', related_query_name='customers', through='pinax_stripe.UserAccount', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='useraccount', + name='account', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_accounts', related_query_name='user_account', to='pinax_stripe.Account'), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='useraccount', + unique_together=set([('user', 'account')]), + ), + migrations.CreateModel( + name='Discount', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start', models.DateTimeField(null=True)), + ('end', models.DateTimeField(null=True)), + ], + ), + migrations.AddField( + model_name='coupon', + name='stripe_account', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), + ), + migrations.AlterField( + model_name='coupon', + name='duration', + field=models.CharField(choices=[('forever', 'forever'), ('once', 'once'), ('repeating', 'repeating')], default='once', max_length=10), + ), + migrations.AlterField( + model_name='coupon', + name='stripe_id', + field=models.CharField(max_length=191), + ), + migrations.AlterField( + model_name='coupon', + name='metadata', + field=jsonfield.fields.JSONField(blank=True, null=True), + ), + migrations.AlterUniqueTogether( + name='coupon', + unique_together=set([('stripe_id', 'stripe_account')]), + ), + migrations.AddField( + model_name='discount', + name='coupon', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Coupon'), + ), + migrations.AddField( + model_name='discount', + name='customer', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Customer'), + ), + migrations.AddField( + model_name='discount', + name='subscription', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Subscription'), + ), + migrations.AlterField( + model_name='account', + name='display_name', + field=models.TextField(default=''), + preserve_default=False, + ), + migrations.AlterField( + model_name='account', + name='metadata', + field=jsonfield.fields.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='account', + name='verification_fields_needed', + field=jsonfield.fields.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='bankaccount', + name='metadata', + field=jsonfield.fields.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='event', + name='validated_message', + field=jsonfield.fields.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='plan', + name='metadata', + field=jsonfield.fields.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='account', + name='stripe_publishable_key', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='account', + name='authorized', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='customer', + name='stripe_account', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), + ), + migrations.AddField( + model_name='event', + name='stripe_account', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), + ), + migrations.AddField( + model_name='plan', + name='stripe_account', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), + ), + migrations.AddField( + model_name='transfer', + name='stripe_account', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), + ), + migrations.AlterField( + model_name='customer', + name='account_balance', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), + ), + migrations.AlterField( + model_name='customer', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/pinax/stripe/migrations/0011_auto_20171123_2016.py b/pinax/stripe/migrations/0011_rename_account_transfers_payout.py similarity index 93% rename from pinax/stripe/migrations/0011_auto_20171123_2016.py rename to pinax/stripe/migrations/0011_rename_account_transfers_payout.py index 4f3ed3707..300bfe574 100644 --- a/pinax/stripe/migrations/0011_auto_20171123_2016.py +++ b/pinax/stripe/migrations/0011_rename_account_transfers_payout.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-11-23 20:16 +# Generated by Django 1.11.8 on 2017-11-15 18:31 from __future__ import unicode_literals from django.db import migrations, models @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ('pinax_stripe', '0011_auto_20171121_1648'), + ('pinax_stripe', '0010_connect_squashed_0026_merge_20171106_2259'), ] operations = [ diff --git a/pinax/stripe/migrations/0012_merge_20171121_1652.py b/pinax/stripe/migrations/0012_merge_20171121_1652.py new file mode 100644 index 000000000..4fb0f708e --- /dev/null +++ b/pinax/stripe/migrations/0012_merge_20171121_1652.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-21 16:52 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0011_rename_account_transfers_payout'), + ('pinax_stripe', '0011_auto_20171121_1648'), + ] + + operations = [ + ] diff --git a/pinax/stripe/migrations/0013_charge_outcome.py b/pinax/stripe/migrations/0013_charge_outcome.py index bcb9183e8..ef3229b6c 100644 --- a/pinax/stripe/migrations/0013_charge_outcome.py +++ b/pinax/stripe/migrations/0013_charge_outcome.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('pinax_stripe', '0014_blank_with_null'), + ('pinax_stripe', '0012_merge_20171121_1652'), ] operations = [ diff --git a/pinax/stripe/migrations/0014_blank_with_null.py b/pinax/stripe/migrations/0014_blank_with_null.py index 6673432aa..1763531b6 100644 --- a/pinax/stripe/migrations/0014_blank_with_null.py +++ b/pinax/stripe/migrations/0014_blank_with_null.py @@ -1,23 +1,19 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-11-24 16:30 +# Generated by Django 1.11.6 on 2017-11-23 15:43 from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion +import jsonfield.fields class Migration(migrations.Migration): dependencies = [ - ('pinax_stripe', '0011_auto_20171123_2016'), + ('pinax_stripe', '0013_charge_outcome'), ] operations = [ - migrations.AlterField( - model_name='account', - name='legal_entity_dob', - field=models.DateField(blank=True, null=True), - ), migrations.AlterField( model_name='account', name='tos_acceptance_date', @@ -83,6 +79,26 @@ class Migration(migrations.Migration): name='date_purged', field=models.DateTimeField(blank=True, editable=False, null=True), ), + migrations.AlterField( + model_name='discount', + name='customer', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Customer'), + ), + migrations.AlterField( + model_name='discount', + name='end', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='discount', + name='start', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='discount', + name='subscription', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Subscription'), + ), migrations.AlterField( model_name='event', name='customer', diff --git a/pinax/stripe/migrations/0015_blank_account_legal_entity_dob.py b/pinax/stripe/migrations/0015_blank_account_legal_entity_dob.py new file mode 100644 index 000000000..ef2aa0ce5 --- /dev/null +++ b/pinax/stripe/migrations/0015_blank_account_legal_entity_dob.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-27 12:55 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0014_blank_with_null'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='legal_entity_dob', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/pinax/stripe/migrations/0016_merge_20180416_1250.py b/pinax/stripe/migrations/0016_merge_20180416_1250.py new file mode 100644 index 000000000..84e241922 --- /dev/null +++ b/pinax/stripe/migrations/0016_merge_20180416_1250.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2018-04-16 12:50 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0015_blank_account_legal_entity_dob'), + ('pinax_stripe', '0014_auto_20180413_1959'), + ] + + operations = [ + ] diff --git a/pinax/stripe/migrations/0017_auto_20210813_1549.py b/pinax/stripe/migrations/0017_auto_20210813_1549.py new file mode 100644 index 000000000..ae3caf823 --- /dev/null +++ b/pinax/stripe/migrations/0017_auto_20210813_1549.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-08-13 13:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0016_merge_20180416_1250'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='stripe_publishable_key', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index 584f34e56..d867475cb 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -109,11 +109,15 @@ def stripe_plan(self): @python_2_unicode_compatible -class Coupon(StripeObject): - +class Coupon(UniquePerAccountStripeObject): + DURATION_CHOICES = ( + ("forever", "forever"), + ("once", "once"), + ("repeating", "repeating"), + ) amount_off = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) currency = models.CharField(max_length=10, default="usd") - duration = models.CharField(max_length=10, default="once") + duration = models.CharField(max_length=10, default="once", choices=DURATION_CHOICES) duration_in_months = models.PositiveIntegerField(null=True, blank=True) livemode = models.BooleanField(default=False) max_redemptions = models.PositiveIntegerField(null=True, blank=True) @@ -131,6 +135,28 @@ def __str__(self): return "Coupon for {}, {}".format(description, self.duration) + def __repr__(self): + return ("Coupon(pk={!r}, valid={!r}, amount_off={!r}, percent_off={!r}, currency={!r}, " + "duration={!r}, livemode={!r}, max_redemptions={!r}, times_redeemed={!r}, stripe_id={!r})".format( + self.pk, + self.valid, + self.amount_off, + self.percent_off, + str(self.currency), + str(self.duration), + self.livemode, + self.max_redemptions, + self.times_redeemed, + str(self.stripe_id), + )) + + @property + def stripe_coupon(self): + return stripe.Coupon.retrieve( + self.stripe_id, + stripe_account=self.stripe_account.stripe_id, + ) + @python_2_unicode_compatible class EventProcessingException(models.Model): @@ -297,9 +323,9 @@ def __repr__(self): self.stripe_id, ) elif self.id: - return "Customer(pk={!r}, users={}, stripe_id={!r})".format( + return "Customer(pk={!r}, users={!r}, stripe_id={!r})".format( self.pk, - ", ".join(repr(user) for user in self.users.all()), + list(self.users.all()), self.stripe_id, ) return "Customer(pk={!r}, stripe_id={!r})".format(self.pk, self.stripe_id) @@ -355,6 +381,35 @@ class BitcoinReceiver(StripeObject): used_for_payment = models.BooleanField(default=False) +@python_2_unicode_compatible +class Discount(models.Model): + + coupon = models.ForeignKey("Coupon", on_delete=models.CASCADE) + customer = models.OneToOneField("Customer", null=True, blank=True, on_delete=models.CASCADE) + subscription = models.OneToOneField("Subscription", null=True, blank=True, on_delete=models.CASCADE) + start = models.DateTimeField(null=True, blank=True) + end = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return "{} - {}".format(self.coupon, self.subscription) + + def __repr__(self): + return "Discount(pk={!r}, coupon={!r}, subscription={!r})".format( + self.pk, + self.coupon, + self.subscription, + ) + + def apply_discount(self, amount): + if self.end is not None and self.end < timezone.now(): + return amount + if self.coupon.amount_off: + return decimal.Decimal(amount - self.coupon.amount_off) + elif self.coupon.percent_off: + return decimal.Decimal("{:.2f}".format(amount - (decimal.Decimal(self.coupon.percent_off) / 100 * amount))) + return amount + + class Subscription(StripeAccountFromCustomerMixin, StripeObject): STATUS_CURRENT = ["trialing", "active"] @@ -379,7 +434,10 @@ def stripe_subscription(self): @property def total_amount(self): - return self.plan.amount * self.quantity + total_amount = self.plan.amount * self.quantity + if hasattr(self, "discount"): + total_amount = self.discount.apply_discount(total_amount) + return total_amount def plan_display(self): return self.plan.name @@ -590,7 +648,7 @@ class Account(StripeObject): metadata = JSONField(null=True, blank=True) - stripe_publishable_key = models.CharField(null=True, blank=True, max_length=100) + stripe_publishable_key = models.CharField(null=True, blank=True, max_length=255) product_description = models.TextField(null=True, blank=True) statement_descriptor = models.TextField(null=True, blank=True) diff --git a/pinax/stripe/tests/test_actions.py b/pinax/stripe/tests/test_actions.py index 2439b007e..438e313a3 100644 --- a/pinax/stripe/tests/test_actions.py +++ b/pinax/stripe/tests/test_actions.py @@ -15,6 +15,7 @@ from ..actions import ( accounts, charges, + coupons, customers, events, externalaccounts, @@ -30,7 +31,9 @@ BitcoinReceiver, Card, Charge, + Coupon, Customer, + Discount, Event, Invoice, Plan, @@ -292,6 +295,24 @@ def test_update_availability(self, SyncMock): self.assertTrue(SyncMock.called) +class CouponsTests(TestCase): + + def test_purge_local(self): + Coupon.objects.create(stripe_id="100OFF", percent_off=decimal.Decimal(100.00)) + self.assertTrue(Coupon.objects.filter(stripe_id="100OFF").exists()) + coupons.purge_local({"id": "100OFF"}) + self.assertFalse(Coupon.objects.filter(stripe_id="100OFF").exists()) + + def test_purge_local_with_account(self): + account = Account.objects.create(stripe_id="acc_XXX") + Coupon.objects.create(stripe_id="100OFF", percent_off=decimal.Decimal(100.00), stripe_account=account) + self.assertTrue(Coupon.objects.filter(stripe_id="100OFF").exists()) + coupons.purge_local({"id": "100OFF"}) + self.assertTrue(Coupon.objects.filter(stripe_id="100OFF").exists()) + coupons.purge_local({"id": "100OFF"}, stripe_account=account) + self.assertFalse(Coupon.objects.filter(stripe_id="100OFF").exists()) + + class CustomersTests(TestCase): def setUp(self): @@ -1197,6 +1218,40 @@ def setUp(self): stripe_id="cus_xxxxxxxxxxxxxxx" ) + def test_sync_coupon_from_stripe_data(self): + account = Account.objects.create( + stripe_id="acct_X", + type="standard", + ) + coupon = { + "id": "35OFF", + "object": "coupon", + "amount_off": None, + "created": 1391694467, + "currency": None, + "duration": "repeating", + "duration_in_months": 3, + "livemode": True, + "max_redemptions": None, + "metadata": { + }, + "percent_off": 35, + "redeem_by": None, + "times_redeemed": 1, + "valid": True + } + cs1 = coupons.sync_coupon_from_stripe_data(coupon) + self.assertTrue(cs1.livemode) + c1 = Coupon.objects.get(stripe_id=coupon["id"], stripe_account=None) + self.assertEquals(c1, cs1) + self.assertEquals(c1.percent_off, decimal.Decimal(35.00)) + + cs2 = coupons.sync_coupon_from_stripe_data(coupon, stripe_account=account) + c2 = Coupon.objects.get(stripe_id=coupon["id"], stripe_account=account) + self.assertEquals(c2, cs2) + self.assertEquals(c2.percent_off, decimal.Decimal(35.00)) + self.assertFalse(c1 == c2) + @patch("stripe.Plan.auto_paging_iter", create=True) def test_sync_plans(self, PlanAutoPagerMock): PlanAutoPagerMock.return_value = [ @@ -1557,6 +1612,34 @@ def test_sync_subscription_from_stripe_data(self): self.assertEquals(Subscription.objects.get(stripe_id=subscription["id"]), sub) self.assertEquals(sub.status, "trialing") + subscription["discount"] = { + "object": "discount", + "coupon": { + "id": "35OFF", + "object": "coupon", + "amount_off": None, + "created": 1391694467, + "currency": None, + "duration": "repeating", + "duration_in_months": 3, + "livemode": False, + "max_redemptions": None, + "metadata": { + }, + "percent_off": 35, + "redeem_by": None, + "times_redeemed": 1, + "valid": True + }, + "customer": self.customer.stripe_id, + "end": 1399384361, + "start": 1391694761, + "subscription": subscription["id"] + } + subscriptions.sync_subscription_from_stripe_data(self.customer, subscription) + d = Subscription.objects.get(stripe_id=subscription["id"]).discount + self.assertEquals(d.coupon.percent_off, decimal.Decimal(35.00)) + def test_sync_subscription_from_stripe_data_updated(self): Plan.objects.create(stripe_id="pro2", interval="month", interval_count=1, amount=decimal.Decimal("19.99")) subscription = { @@ -1568,7 +1651,30 @@ def test_sync_subscription_from_stripe_data_updated(self): "current_period_end": 1448758544, "current_period_start": 1448499344, "customer": self.customer.stripe_id, - "discount": None, + "discount": { + "object": "discount", + "coupon": { + "id": "35OFF", + "object": "coupon", + "amount_off": None, + "created": 1391694467, + "currency": None, + "duration": "repeating", + "duration_in_months": 3, + "livemode": False, + "max_redemptions": None, + "metadata": { + }, + "percent_off": 35, + "redeem_by": None, + "times_redeemed": 1, + "valid": True + }, + "customer": self.customer.stripe_id, + "end": 1399384361, + "start": 1391694761, + "subscription": "sub_7Q4BX0HMfqTpN8" + }, "ended_at": None, "metadata": { }, @@ -1594,11 +1700,16 @@ def test_sync_subscription_from_stripe_data_updated(self): "trial_end": 1448758544, "trial_start": 1448499344 } + with self.assertRaises(Discount.DoesNotExist): + Discount.objects.get(subscription__stripe_id="sub_7Q4BX0HMfqTpN8") subscriptions.sync_subscription_from_stripe_data(self.customer, subscription) self.assertEquals(Subscription.objects.get(stripe_id=subscription["id"]).status, "trialing") subscription.update({"status": "active"}) subscriptions.sync_subscription_from_stripe_data(self.customer, subscription) - self.assertEquals(Subscription.objects.get(stripe_id=subscription["id"]).status, "active") + s = Subscription.objects.get(stripe_id=subscription["id"]) + self.assertEquals(s.status, "active") + self.assertTrue(Discount.objects.filter(subscription__stripe_id="sub_7Q4BX0HMfqTpN8").exists()) + self.assertEquals(s.discount.coupon.stripe_id, "35OFF") @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") diff --git a/pinax/stripe/tests/test_admin.py b/pinax/stripe/tests/test_admin.py index 742979216..a573ba77e 100644 --- a/pinax/stripe/tests/test_admin.py +++ b/pinax/stripe/tests/test_admin.py @@ -6,7 +6,7 @@ from django.test.utils import CaptureQueriesContext from django.utils import timezone -from ..models import Account, Customer, Invoice, Plan, Subscription +from ..models import Account, Charge, Customer, Invoice, Plan, Subscription try: from django.urls import reverse @@ -167,6 +167,8 @@ def test_plan_admin(self): self.assertEqual(response.status_code, 200) def test_charge_admin(self): + Charge.objects.create(stripe_id="ch_1") + Charge.objects.create(stripe_id="ch_2", outcome={"risk_level": "normal"}) url = reverse("admin:pinax_stripe_charge_changelist") response = self.client.get(url) self.assertEqual(response.status_code, 200) diff --git a/pinax/stripe/tests/test_models.py b/pinax/stripe/tests/test_models.py index 088f98b13..0e77a8f08 100644 --- a/pinax/stripe/tests/test_models.py +++ b/pinax/stripe/tests/test_models.py @@ -20,6 +20,7 @@ Charge, Coupon, Customer, + Discount, Event, EventProcessingException, Invoice, @@ -136,9 +137,9 @@ def test_connected_customer_str_and_repr(self): UserAccount.objects.create(customer=customer, user=user, account=account) self.assertEqual(str(customer), "") if PY2: - self.assertEqual(repr(customer), "Customer(pk={c.pk}, users=, stripe_id=u'cus_A')".format(c=customer)) + self.assertEqual(repr(customer), "Customer(pk={c.pk}, users=[], stripe_id=u'cus_A')".format(c=customer)) else: - self.assertEqual(repr(customer), "Customer(pk={c.pk}, users=, stripe_id='cus_A')".format(c=customer)) + self.assertEqual(repr(customer), "Customer(pk={c.pk}, users=[], stripe_id='cus_A')".format(c=customer)) def test_charge_repr(self): charge = Charge() @@ -172,6 +173,10 @@ def test_plan_display_invoiceitem(self): i = InvoiceItem(plan=p) self.assertEquals(i.plan_display(), "My Plan") + def test_coupon_repr(self): + c = Coupon(id=1, percent_off=25, duration="repeating", duration_in_months=3,) + self.assertEquals(repr(c), "Coupon(pk=1, valid=False, amount_off=None, percent_off=25, currency='usd', duration='repeating', livemode=False, max_redemptions=None, times_redeemed=None, stripe_id='')") + def test_coupon_percent(self): c = Coupon(percent_off=25, duration="repeating", duration_in_months=3) self.assertEquals(str(c), "Coupon for 25% off, repeating") @@ -180,6 +185,13 @@ def test_coupon_absolute(self): c = Coupon(amount_off=decimal.Decimal(50.00), duration="once", currency="usd") self.assertEquals(str(c), "Coupon for $50, once") + @patch("stripe.Coupon.retrieve") + def test_coupon_stripe_coupon(self, RetrieveMock): + c = Coupon(stripe_id="coupon", stripe_account=Account(stripe_id="acct_A")) + self.assertEqual(c.stripe_coupon, RetrieveMock.return_value) + self.assertTrue(RetrieveMock.call_args_list, [ + call("coupon", stripe_account="acct_A")]) + def test_model_table_name(self): self.assertEquals(Customer()._meta.db_table, "pinax_stripe_customer") @@ -193,6 +205,25 @@ def test_invoice_status(self): def test_invoice_status_not_paid(self): self.assertEquals(Invoice(paid=False).status, "Open") + def test_discount_repr(self): + c = Coupon() + d = Discount(coupon=c) + self.assertEquals(repr(d), "Discount(pk=None, coupon=Coupon(pk=None, valid=False, amount_off=None, percent_off=None, currency='usd', duration='once', livemode=False, max_redemptions=None, times_redeemed=None, stripe_id=''), subscription=None)") + + def test_discount_apply_discount(self): + c = Coupon(duration="once", currency="usd") + d = Discount(coupon=c) + self.assertEquals(d.apply_discount(decimal.Decimal(50.00)), decimal.Decimal(50.00)) + c = Coupon(amount_off=decimal.Decimal(50.00), duration="once", currency="usd") + d = Discount(coupon=c) + self.assertEquals(d.apply_discount(decimal.Decimal(50.00)), decimal.Decimal(0.00)) + c = Coupon(percent_off=decimal.Decimal(50.00), duration="once", currency="usd") + d.coupon = c + self.assertEquals(d.apply_discount(decimal.Decimal(100.00)), decimal.Decimal(50.00)) + c = Coupon(percent_off=decimal.Decimal(50.00), duration="repeating", currency="usd") + d.end = timezone.now() - datetime.timedelta(days=1) + self.assertEquals(d.apply_discount(decimal.Decimal(100.00)), decimal.Decimal(100.00)) + def test_subscription_repr(self): s = Subscription() if PY2: @@ -216,6 +247,14 @@ def test_subscription_total_amount(self): sub = Subscription(plan=Plan(name="Pro Plan", amount=decimal.Decimal("100")), quantity=2) self.assertEquals(sub.total_amount, decimal.Decimal("200")) + @patch("pinax.stripe.models.Discount.apply_discount") + def test_subscription_total_amount_discount(self, ApplyDiscountMock): + c = Coupon(amount_off=decimal.Decimal(50.00), duration="once", currency="usd") + sub = Subscription(plan=Plan(name="Pro Plan", amount=decimal.Decimal("100")), quantity=2) + Discount(coupon=c, subscription=sub) + sub.total_amount() + self.assertTrue(ApplyDiscountMock.called) + def test_subscription_plan_display(self): sub = Subscription(plan=Plan(name="Pro Plan")) self.assertEquals(sub.plan_display(), "Pro Plan") @@ -310,6 +349,12 @@ def test_stripe_charge(self, RetrieveMock): Charge().stripe_charge self.assertTrue(RetrieveMock.called) + @patch("stripe.Charge.retrieve") + def test_stripe_charge_with_account(self, RetrieveMock): + cu = Customer(stripe_account=Account()) + Charge(customer=cu).stripe_charge + self.assertTrue(RetrieveMock.called) + @patch("stripe.Customer.retrieve") def test_stripe_customer(self, RetrieveMock): Customer().stripe_customer diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index d0ccdc6a9..c7659814a 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -28,6 +28,11 @@ AccountExternalAccountCreatedWebhook, AccountUpdatedWebhook, ChargeCapturedWebhook, + ChargeDisputeFundsWithdrawnWebhook, + CouponCreatedWebhook, + CouponDeletedWebhook, + CouponUpdatedWebhook, + CustomerCreatedWebhook, CustomerDeletedWebhook, CustomerSourceCreatedWebhook, CustomerSourceDeletedWebhook, @@ -241,6 +246,29 @@ def test_process_webhook_connect(self, SyncMock, RetrieveMock): self.assertEquals(kwargs["expand"], ["balance_transaction"]) self.assertEquals(kwargs["stripe_account"], "acc_A") + @patch("stripe.Charge.retrieve") + @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") + def test_process_webhook_dispute(self, SyncMock, RetrieveMock): + account = Account.objects.create(stripe_id="acc_A") + event = Event.objects.create( + kind=ChargeDisputeFundsWithdrawnWebhook.name, + webhook_message={}, + valid=True, + processed=False, + stripe_account=account + ) + event.validated_message = dict(data=dict(object=dict( + id=1, + object="dispute", + charge="ch_XXX", + ))) + ChargeDisputeFundsWithdrawnWebhook(event).process_webhook() + self.assertTrue(SyncMock.called) + args, kwargs = RetrieveMock.call_args + self.assertEquals(args, ("ch_XXX",)) + self.assertEquals(kwargs["expand"], ["balance_transaction"]) + self.assertEquals(kwargs["stripe_account"], "acc_A") + class CustomerDeletedWebhookTest(TestCase): @@ -285,6 +313,86 @@ def test_process_webhook_with_customer_with_data(self, SyncMock): self.assertIs(SyncMock.call_args[0][1], obj) +class CouponCreatedWebhookTest(TestCase): + + @patch("pinax.stripe.actions.coupons.sync_coupon_from_stripe_data") + def test_process_webhook(self, SyncMock): + event = Event.objects.create(kind=CouponCreatedWebhook.name, webhook_message={}, valid=True, processed=False, stripe_account=None) + obj = object() + event.validated_message = dict(data=dict(object=obj)) + CouponCreatedWebhook(event).process_webhook() + SyncMock.assert_called_with(event.message["data"]["object"], stripe_account=None) + + @patch("pinax.stripe.actions.coupons.sync_coupon_from_stripe_data") + def test_process_webhook_with_stripe_account(self, SyncMock): + account = Account.objects.create(stripe_id="acc_A") + event = Event.objects.create(kind=CouponCreatedWebhook.name, webhook_message={}, valid=True, processed=False, stripe_account=account) + obj = object() + event.validated_message = dict(data=dict(object=obj)) + CouponCreatedWebhook(event).process_webhook() + SyncMock.assert_called_with(event.message["data"]["object"], stripe_account=account) + + +class CouponUpdatedWebhookTest(TestCase): + + @patch("pinax.stripe.actions.coupons.sync_coupon_from_stripe_data") + def test_process_webhook(self, SyncMock): + event = Event.objects.create(kind=CouponUpdatedWebhook.name, webhook_message={}, valid=True, processed=False, stripe_account=None) + obj = object() + event.validated_message = dict(data=dict(object=obj)) + CouponUpdatedWebhook(event).process_webhook() + SyncMock.assert_called_with(event.message["data"]["object"], stripe_account=None) + + @patch("pinax.stripe.actions.coupons.sync_coupon_from_stripe_data") + def test_process_webhook_with_stripe_account(self, SyncMock): + account = Account.objects.create(stripe_id="acc_A") + event = Event.objects.create(kind=CouponUpdatedWebhook.name, webhook_message={}, valid=True, processed=False, stripe_account=account) + obj = object() + event.validated_message = dict(data=dict(object=obj)) + CouponUpdatedWebhook(event).process_webhook() + SyncMock.assert_called_with(event.message["data"]["object"], stripe_account=account) + + +class CouponDeletedWebhookTest(TestCase): + + @patch("pinax.stripe.actions.coupons.purge_local") + def test_process_webhook(self, PurgeMock): + event = Event.objects.create(kind=CouponDeletedWebhook.name, webhook_message={}, valid=True, processed=False, stripe_account=None) + obj = object() + event.validated_message = dict(data=dict(object=obj)) + CouponDeletedWebhook(event).process_webhook() + PurgeMock.assert_called_with(event.message["data"]["object"], stripe_account=None) + + @patch("pinax.stripe.actions.coupons.purge_local") + def test_process_webhook_with_stripe_account(self, PurgeMock): + account = Account.objects.create(stripe_id="acc_A") + event = Event.objects.create(kind=CouponDeletedWebhook.name, webhook_message={}, valid=True, processed=False, stripe_account=account) + obj = object() + event.validated_message = dict(data=dict(object=obj)) + CouponDeletedWebhook(event).process_webhook() + PurgeMock.assert_called_with(event.message["data"]["object"], stripe_account=account) + + +class CustomerCreatedWebhookTest(TestCase): + + @patch("pinax.stripe.actions.customers.create") + def test_process_webhook(self, CreateMock): + event = Event.objects.create(kind=CustomerCreatedWebhook.name, webhook_message={}, valid=True, processed=False) + obj = object() + event.validated_message = dict(data=dict(object=obj)) + CustomerCreatedWebhook(event).process_webhook() + CreateMock.assert_not_called() + + @patch("pinax.stripe.actions.customers.create") + def test_process_webhook_with_stripe_account(self, CreateMock): + account = Account.objects.create(stripe_id="acc_A") + event = Event.objects.create(kind=CustomerCreatedWebhook.name, webhook_message={}, valid=True, processed=False, stripe_account=account) + obj = object() + event.validated_message = dict(data=dict(object=obj)) + CustomerCreatedWebhook(event).process_webhook() + CreateMock.assert_not_called() + + class CustomerSourceCreatedWebhookTest(TestCase): @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") diff --git a/pinax/stripe/webhooks.py b/pinax/stripe/webhooks.py index d40fea2d9..e60667fa7 100644 --- a/pinax/stripe/webhooks.py +++ b/pinax/stripe/webhooks.py @@ -9,6 +9,7 @@ from .actions import ( accounts, charges, + coupons, customers, exceptions, invoices, @@ -240,10 +241,15 @@ class BitcoinReceiverTransactionCreatedWebhook(Webhook): class ChargeWebhook(Webhook): - def process_webhook(self): + message = self.event.message + if message["data"]["object"].get("object", "charge") == "charge": + stripe_id = message["data"]["object"]["id"] + else: + stripe_id = message["data"]["object"]["charge"] + charges.sync_charge( - self.event.message["data"]["object"]["id"], + stripe_id, stripe_account=self.event.stripe_account_stripe_id, ) @@ -302,16 +308,25 @@ class CouponCreatedWebhook(Webhook): name = "coupon.created" description = "Occurs whenever a coupon is created." + def process_webhook(self): + coupons.sync_coupon_from_stripe_data(self.event.message["data"]["object"], stripe_account=self.event.stripe_account) + class CouponDeletedWebhook(Webhook): name = "coupon.deleted" description = "Occurs whenever a coupon is deleted." + def process_webhook(self): + coupons.purge_local(self.event.message["data"]["object"], stripe_account=self.event.stripe_account) + class CouponUpdatedWebhook(Webhook): name = "coupon.updated" description = "Occurs whenever a coupon is updated." + def process_webhook(self): + coupons.sync_coupon_from_stripe_data(self.event.message["data"]["object"], stripe_account=self.event.stripe_account) + class CustomerCreatedWebhook(Webhook): name = "customer.created" diff --git a/setup.py b/setup.py index f763a0034..8ceb8a92b 100644 --- a/setup.py +++ b/setup.py @@ -89,7 +89,7 @@ "django-appconf>=1.0.1", "jsonfield>=1.0.3", "stripe>=2.0", - "django>=1.8", + "django>=1.8,<2.3", "pytz", "six", "django-ipware==2.1.0"